feat(settings): persist application defaults and workspace on exit (phase 100)

This commit is contained in:
Johannes Findeisen 2026-11-06 02:20:33 +01:00
commit 2a86d766ca
8 changed files with 150 additions and 4 deletions

View file

@ -596,3 +596,7 @@ test: test-creative
test-gui-creative: $(BUILD_DIR)/test_gui
XTC_TEST_CREATIVE_ONLY=1 xvfb-run -a -s "-screen 0 1280x900x24" ./$(BUILD_DIR)/test_gui
-include $(BUILD_DIR)/tests/unit/test_creative.d
.PHONY: test-gui-midi-workflows
test-gui-midi-workflows: $(BUILD_DIR)/test_gui
XTC_TEST_MIDI_WORKFLOWS_ONLY=1 xvfb-run -a -s "-screen 0 1440x960x24" ./$(BUILD_DIR)/test_gui

15
docs/MIDI_WORKFLOWS.md Normal file
View file

@ -0,0 +1,15 @@
# Application configuration and MIDI workflows
`~/.xtc.conf` is the application's base configuration. It is read before creating
its first project and backend. Missing keys use compiled defaults. Preferences
Apply writes every tab atomically with owner-only permissions; clean exit also
collects workspace, snap/zoom, loop, piano filters and note defaults. A missing
file is created on Apply or clean exit. Invalid files produce a visible startup
error and are preserved until an explicit successful Apply replaces them. A save
failure keeps the window open for retry. Project contents, plugin states and
session clipboards are project/session data, not application preferences.
All persistent preference fields are listed by the shared C schema and available
in Preferences. Existing project tempo/controller maps remain project data;
Preferences project defaults apply to New. No configuration I/O runs in an audio
callback. Tests and screenshot captures use isolated configuration paths.

View file

@ -2220,6 +2220,96 @@ A/B snapshots copy supported metadata, not opaque plugin states or DSP histories
Crossfades use existing linear envelopes; strumming is a source-note edit.
Project format remains 17, readable from versions 1–17; no dependencies were added.
## Phase 100 — Complete Application Configuration Persistence
### Goal
Load ~/.xtc.conf before creating projects or audio backends; save all application preferences and workspace defaults on Apply and clean exit. Preserve malformed files until explicitly replaced.
### Tasks and acceptance criteria
- [x] Implement and document the workflow with bounded validation and safe ownership.
- [x] Pass meaningful core and GUI checks, including errors and persistence or undo.
- [x] Commit this phase separately after validation.
### Implementation status
Implemented: ~/.xtc.conf is loaded before project/backend creation and saved atomically on Preferences Apply and clean exit. Workspace position, inspector visibility, snap/zoom, loop and note defaults are collected. Invalid startup files are preserved until explicit Apply. Configuration roundtrip/permissions tests, focused startup/exit/malformed-file GUI checks and full GUI suite passed.
---
## Phase 101 — MIDI Device Input and Output Backend
### Goal
Expose JACK MIDI source/destination discovery, routing, input filtering and external MIDI output with bounded callback work and panic safety.
### Tasks and acceptance criteria
- [ ] Implement and document the workflow with bounded validation and safe ownership.
- [ ] Pass meaningful core and GUI checks, including errors and persistence or undo.
- [ ] Commit this phase separately after validation.
### Implementation status
Planned.
---
## Phase 102 — MIDI Preferences and Device Defaults
### Goal
Provide device dropdowns and refresh in Preferences, persistent default input/output ports, live channel filters and MIDI thru; retain editing and controller defaults in ~/.xtc.conf.
### Tasks and acceptance criteria
- [ ] Implement and document the workflow with bounded validation and safe ownership.
- [ ] Pass meaningful core and GUI checks, including errors and persistence or undo.
- [ ] Commit this phase separately after validation.
### Implementation status
Planned.
---
## Phase 103 — Export Selected Clips as Standard MIDI Files
### Goal
Export the audible part of a selected MIDI clip as an atomic Standard MIDI File with note timing, channels, velocities and project tempo.
### Tasks and acceptance criteria
- [ ] Implement and document the workflow with bounded validation and safe ownership.
- [ ] Pass meaningful core and GUI checks, including errors and persistence or undo.
- [ ] Commit this phase separately after validation.
### Implementation status
Planned.
---
## Phase 104 — Import Standard MIDI Files
### Goal
Import type 0/1 PPQN MIDI notes onto the selected track or a new track, with safe parsing, explicit limits, tempo policy and one undoable prepared adoption.
### Tasks and acceptance criteria
- [ ] Implement and document the workflow with bounded validation and safe ownership.
- [ ] Pass meaningful core and GUI checks, including errors and persistence or undo.
- [ ] Commit this phase separately after validation.
### Implementation status
Planned.
---
## Phase 105 — MIDI Workflow Screenshots and Qualification
### Goal
Capture current MIDI preferences, file workflows and imported notes, refresh the gallery, and qualify configuration, MIDI I/O and file workflows together.
### Tasks and acceptance criteria
- [ ] Implement and document the workflow with bounded validation and safe ownership.
- [ ] Pass meaningful core and GUI checks, including errors and persistence or undo.
- [ ] Commit this phase separately after validation.
### Implementation status
Planned.
---
## Recommended Execution Order
Reorder the phases when the audit reveals a blocking dependency. Do not mark a phase complete merely because code exists: its acceptance criteria must be demonstrated by builds, tests, or documented manual verification.

View file

@ -10,6 +10,7 @@ extern "C" {
typedef struct {
char client_name[64], clap_paths[2048], vst2_paths[2048], vst3_paths[2048], ladspa_paths[2048];
int64_t max_frames, channels, transition_ms, offline_rate, recording_seconds;
int64_t main_x, main_y, inspector_visible;
int64_t width, height, piano_width, piano_height, refresh_ms;
int64_t transport_detached,mixer_detached,sequencer_detached,graph_detached,piano_detached;
int64_t sequencer_x,sequencer_y,sequencer_width,sequencer_height;

View file

@ -110,6 +110,7 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration)
else if(xtc_settings_path(config_path,sizeof(config_path))==XTC_OK) configuration_=config_path;
xtc_result config_result=configuration_.empty() ? XTC_ERR_NOT_FOUND : xtc_settings_load(&settings_,configuration_.c_str());
configuration_valid_=config_result==XTC_OK || config_result==XTC_ERR_NOT_FOUND;
xtc_project *initial = nullptr;
xtc_result result = xtc_project_create(&initial);
if (result != XTC_OK)
@ -572,7 +573,7 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration)
size_range(1220, 720);
callback(close_callback, this);
apply_ui_preferences();
show_track_inspector(false);
show_track_inspector(settings_.inspector_visible!=0);
open_backend();
if(config_result!=XTC_OK && config_result!=XTC_ERR_NOT_FOUND) report_error("Preferences file invalid; using defaults",config_result);
Fl::add_timeout(static_cast<double>(settings_.refresh_ms)/1000,timer_callback,this);
@ -1122,6 +1123,10 @@ bool MainWindow::close_window()
backend_ = nullptr;
ready_ = false;
running_ = false;
if(!configuration_.empty() && configuration_valid_) {
xtc_result saved=save_preferences();
if(saved!=XTC_OK) {report_error("Could not save application settings; window kept open for retry",saved);return false;}
}
closing_ = true;
Fl::remove_timeout(timer_callback, this);
for(auto &panel:editors_)if(panel.window)panel.window->hide();
@ -2473,10 +2478,12 @@ void MainWindow::collect_preferences()
settings_.transport_detached=transport_detached_;settings_.mixer_detached=mixer_detached_;
if(transport_window_) {settings_.transport_x=transport_window_->x();settings_.transport_y=transport_window_->y();settings_.transport_width=transport_window_->w();settings_.transport_height=transport_window_->h();}
if(mixer_window_) {settings_.mixer_x=mixer_window_->x();settings_.mixer_y=mixer_window_->y();settings_.mixer_width=mixer_window_->w();settings_.mixer_height=mixer_window_->h();}
settings_.main_x=x();settings_.main_y=y();settings_.inspector_visible=sidebar_->visible();
settings_.width=w();settings_.height=h();settings_.sequencer_snap=static_cast<int64_t>(sequencer_->grid());settings_.sequencer_zoom=sequencer_->zoom_level();
if(piano_) {
settings_.piano_width=piano_window_->w();settings_.piano_height=piano_window_->h();settings_.piano_snap=static_cast<int64_t>(piano_->grid());
settings_.filter_channel=piano_->filter_channel();settings_.filter_low=piano_->filter_low();settings_.filter_high=piano_->filter_high();
settings_.note_ticks=static_cast<int64_t>(piano_->new_note_ticks);settings_.note_velocity=piano_->new_note_velocity;settings_.note_channel=piano_->new_note_channel;
}
}
void MainWindow::apply_ui_preferences()
@ -2490,7 +2497,8 @@ void MainWindow::apply_ui_preferences()
if(transport_window_)restore_panel(transport_window_.get(),static_cast<int>(settings_.transport_x),static_cast<int>(settings_.transport_y),static_cast<int>(settings_.transport_width),static_cast<int>(settings_.transport_height),400,130);
if(mixer_window_)restore_panel(mixer_window_.get(),static_cast<int>(settings_.mixer_x),static_cast<int>(settings_.mixer_y),static_cast<int>(settings_.mixer_width),static_cast<int>(settings_.mixer_height),600,420);
follow_->value(static_cast<int>(settings_.follow_playhead));
resize(x(),y(),static_cast<int>(settings_.width),static_cast<int>(settings_.height));
restore_panel(this,static_cast<int>(settings_.main_x),static_cast<int>(settings_.main_y),static_cast<int>(settings_.width),static_cast<int>(settings_.height),1220,720);
show_track_inspector(settings_.inspector_visible!=0);
sequencer_->grid(static_cast<uint64_t>(settings_.sequencer_snap));sequencer_->set_zoom(static_cast<int>(settings_.sequencer_zoom));
sequencer_->new_clip_ticks=static_cast<uint64_t>(settings_.clip_ticks);
sequencer_->loop_region(static_cast<uint64_t>(settings_.loop_start),static_cast<uint64_t>(settings_.loop_end),settings_.loop_enabled!=0);
@ -2509,7 +2517,12 @@ xtc_result MainWindow::apply_preferences(const xtc_settings &candidate)
if(r==XTC_OK)r=configure_loop(project_.get(),candidate);
if(r==XTC_OK)r=xtc_settings_save(&candidate,configuration_.c_str());
if(r!=XTC_OK) {if(ready_)(void)configure_loop(project_.get(),settings_);refresh_status();return r;}
settings_=candidate;apply_ui_preferences();refresh_status();return XTC_OK;
settings_=candidate;configuration_valid_=true;apply_ui_preferences();refresh_status();return XTC_OK;
}
xtc_result MainWindow::save_preferences()
{
if(configuration_.empty() || !configuration_valid_)return XTC_ERR_INVALID_STATE;
collect_preferences();return xtc_settings_save(&settings_,configuration_.c_str());
}
void MainWindow::show_preferences()
{

View file

@ -66,6 +66,7 @@ class MainWindow : public Fl_Double_Window {
const xtc_settings &settings() const { return settings_; }
xtc_result apply_preferences(const xtc_settings &settings);
void show_preferences();
xtc_result save_preferences();
xtc_result set_loop(uint64_t start,uint64_t end,bool enabled);
Fl_Widget *velocity_lane() const { return velocity_lane_; }
/* Control-thread plugin commands, transactional model adoption. */
@ -247,6 +248,7 @@ class MainWindow : public Fl_Double_Window {
bool transport_detached_=false, mixer_detached_=false;
xtc_settings settings_{};
std::string configuration_;
bool configuration_valid_=true;
Fl_Check_Button *loop_button_=nullptr;
xtc_result configure_loop(const xtc_project *project,const xtc_settings &settings);
void apply_ui_preferences();

View file

@ -21,6 +21,8 @@ static const xtc_setting_info schema[] = {
STR("plugins",vst2_paths,"VST2 search paths (colon-separated)"),
STR("plugins",vst3_paths,"VST3 search paths (colon-separated)"),
STR("plugins",ladspa_paths,"LADSPA search paths (colon-separated)"),
NUM("interface",main_x,"Main window X",-32768,32768), NUM("interface",main_y,"Main window Y",-32768,32768),
NUM("interface",inspector_visible,"Track inspector visible: 0 or 1",0,1),
NUM("interface",width,"Main window width",1220,3840), NUM("interface",height,"Main window height",720,2160),
NUM("interface",piano_width,"Piano window width",800,3840), NUM("interface",piano_height,"Piano window height",520,2160),
NUM("interface",transport_detached,"Floating transport: 0 or 1",0,1),
@ -175,7 +177,7 @@ xtc_result xtc_settings_save(const xtc_settings *s,const char *path)
n=snprintf(temporary,sizeof(temporary),"%s.tmp-XXXXXX",path); if(n<0 || (size_t)n>=sizeof(temporary)) return XTC_ERR_CAPACITY;
fd=mkstemp(temporary); if(fd<0) return XTC_ERR_IO;
f=fdopen(fd,"wb"); if(!f) {close(fd);unlink(temporary);return XTC_ERR_IO;}
r=XTC_OK; if(fputs("; xtc application preferences. Saved by Preferences / Apply.\n",f)<0) r=XTC_ERR_IO;
r=XTC_OK; if(fputs("; xtc application preferences. Saved on Preferences / Apply and clean exit.\n",f)<0) r=XTC_ERR_IO;
for(i=0;i<COUNT && r==XTC_OK;++i) {
char value[4096]; const char *p;
if(strcmp(section,schema[i].section)) { section=schema[i].section; if(fprintf(f,"\n[%s]\n",section)<0) {r=XTC_ERR_IO;break;} }

View file

@ -346,8 +346,27 @@ int track_insert_tests()
CHECK(xtc_project_get_chain(s->project(),track,&chain)==XTC_OK && chain.count==1);unlink("/tmp/xtc-inserts-gui.xtc");
window.do_callback();puts("Track inserts GUI adoption/undo/rollback/reload passed.");return failures ? 1 : 0;
}
int midi_workflow_tests()
{
char dir[]="/tmp/xtc-midi-settings-XXXXXX";CHECK(mkdtemp(dir));std::string config=std::string(dir)+"/.xtc.conf";
reset();{
MainWindow window(false,config.c_str());window.show();auto *s=timeline(&window);
s->grid(480);window.show_track_inspector(true);window.resize(20,30,1300,820);
CHECK(window.set_loop(960,3840,true)==XTC_OK);window.do_callback();CHECK(!window.shown());
}
xtc_settings saved{};CHECK(xtc_settings_load(&saved,config.c_str())==XTC_OK && saved.sequencer_snap==480 && saved.inspector_visible && saved.loop_start==960 && saved.width==1300);
reset();{
MainWindow window(false,config.c_str());CHECK(window.settings().sequencer_snap==480 && window.settings().inspector_visible && window.settings().loop_enabled);
window.do_callback();
}
{FILE *f=fopen(config.c_str(),"wb");CHECK(f);if(f){fputs("[invalid]\n",f);fclose(f);}}
reset();{MainWindow window(false,config.c_str());CHECK(window.save_preferences()==XTC_ERR_INVALID_STATE);window.do_callback();}
CHECK(xtc_settings_load(&saved,config.c_str())==XTC_ERR_FORMAT);
unlink(config.c_str());rmdir(dir);puts("MIDI workflow/configuration GUI checks passed.");return failures ? 1 : 0;
}
int main(int argc, char **argv)
{
if(std::getenv("XTC_TEST_MIDI_WORKFLOWS_ONLY"))return midi_workflow_tests();
if(std::getenv("XTC_TEST_CREATIVE_ONLY"))return creative_workflow_tests();
if(std::getenv("XTC_TEST_TRACK_FX_ONLY"))return track_insert_tests();
if(std::getenv("XTC_TEST_SAMPLE_EDITOR_ONLY"))return sample_editor_tests();