From 73e8bf09dc1b9fd17a24e382c2b25dd064437427 Mon Sep 17 00:00:00 2001 From: hanez Date: Thu, 5 Nov 2026 23:57:56 +0100 Subject: [PATCH] feat: persist floating panel layouts and wrap the compact transport --- docs/PREFERENCES.md | 5 +++ docs/ROADMAP.md | 16 +++++--- docs/WORKSPACE.md | 16 ++++++++ include/xtc/xtc_settings.h | 3 ++ src/gui/main_window.cpp | 75 +++++++++++++++++++++++++++++++--- src/gui/main_window.hpp | 6 +++ src/settings/settings.c | 8 ++++ tests/integration/test_gui.cpp | 29 +++++++++++++ tests/unit/test_settings.c | 4 +- 9 files changed, 149 insertions(+), 13 deletions(-) diff --git a/docs/PREFERENCES.md b/docs/PREFERENCES.md index 4d4a30c..a07748e 100644 --- a/docs/PREFERENCES.md +++ b/docs/PREFERENCES.md @@ -71,3 +71,8 @@ Phase 44 adds `[transport] tail_ms=3000` (0..60000) and `stop_after_tail=0` (0/1). Export always includes the configured tail; optional playback stopping uses the GUI timer and is disabled during looping/recording. See BUILTIN_DEVICES.md for endpoint, cancellation and reset semantics. + +Phase 45 adds `[interface] transport_detached`, `mixer_detached`, and each +panel's `_x`, `_y`, `_width`, `_height` rectangle. Use Preferences / Apply after +moving panels to save the captured layout. Restoration clamps rectangles to the +current display; the floating transport wraps at narrow widths. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6614074..09f1bbb 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1261,14 +1261,18 @@ External-plugin history remains retained. See BUILTIN_DEVICES.md for limits. Retain useful floating layouts across sessions and support narrower displays. ### Tasks and acceptance criteria -- [ ] Persist docked/floating state and panel geometry through the application preferences workflow. -- [ ] Restore layouts safely when monitors or display sizes change; keep all panels reachable. -- [ ] Provide a compact/responsive floating transport while retaining BPM, loop and playback controls. -- [ ] Preserve close-to-dock, Dock all, shortcuts, editing focus protection and safe shutdown. -- [ ] Test restart, invalid settings, off-screen geometry, narrow sizes and repeated dock/resize cycles. +- [x] Persist docked/floating state and panel geometry through the application preferences workflow. +- [x] Restore layouts safely when monitors or display sizes change; keep all panels reachable. +- [x] Provide a compact/responsive floating transport while retaining BPM, loop and playback controls. +- [x] Preserve close-to-dock, Dock all, shortcuts, editing focus protection and safe shutdown. +- [x] Test restart, invalid settings, off-screen geometry, narrow sizes and repeated dock/resize cycles. ### Implementation status -Planned. Phase 39 starts docked each session and its transport minimum is 1220 pixels. +Implemented: validated panel preferences, geometry capture on Preferences/Apply, +safe display-area restoration and a wrapping floating transport down to 400 +pixels. Core/settings and GUI tests pass, including restart, invalid geometry, +off-screen clamping, narrow control bounds and existing dock/lifecycle checks. +The full-window minimum is unchanged. See WORKSPACE.md and PREFERENCES.md. --- diff --git a/docs/WORKSPACE.md b/docs/WORKSPACE.md index 4a66fa5..4f8ae9b 100644 --- a/docs/WORKSPACE.md +++ b/docs/WORKSPACE.md @@ -177,3 +177,19 @@ Verified with `make BUILD_DIR=build/phase39 gui test-gui test-gui-instruments test-gui-jack` (one line), plus the GUI suite under AddressSanitizer/UBSan. Tests cover repeated reparenting, close-to-dock, resize, shared BPM/loop state, playback shortcuts, failed shutdown and cleanup. + +## Phase 45 saved panel layouts + +Preferences / Apply now saves transport/mixer dock state and window rectangles. +Open Preferences after arranging the windows to capture their current positions. +Closing the application alone does not save preferences. Startup restores the +saved layout; off-screen rectangles are clamped to an available display's work +area. Invalid dimensions are rejected transactionally by the settings reader. + +The floating transport supports widths down to 400 pixels. Controls wrap into +rows below 1220 pixels, retaining playback, BPM, loop and navigation actions; +its height grows as necessary to keep every control available. Docking restores +the wide toolbar and arrangement geometry. Mixer minimum remains 600 × 420. +Existing close-to-dock, Dock all and shortcuts retain their shared widget state. +Tests cover narrow control bounds, save/restart, off-screen restoration, invalid +geometry, repeated docking/resizing and shared transport state. diff --git a/include/xtc/xtc_settings.h b/include/xtc/xtc_settings.h index 6d23ed9..e5a45e7 100644 --- a/include/xtc/xtc_settings.h +++ b/include/xtc/xtc_settings.h @@ -11,6 +11,9 @@ 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 width, height, piano_width, piano_height, refresh_ms; + int64_t transport_detached,mixer_detached; + int64_t transport_x,transport_y,transport_width,transport_height; + int64_t mixer_x,mixer_y,mixer_width,mixer_height; int64_t sequencer_snap, piano_snap, sequencer_zoom; int64_t clip_ticks, note_ticks, note_velocity, note_channel, filter_channel, filter_low, filter_high; int64_t tempo, volume_cc, pan_cc, sustain_cc; diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 962e266..9a43ed1 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -32,6 +32,7 @@ #include #include #include +#include namespace { class PanelWindow : public Fl_Double_Window { @@ -39,11 +40,22 @@ class PanelWindow : public Fl_Double_Window { public: PanelWindow(int width, int height, const char *title, MainWindow *owner) : Fl_Double_Window(width,height,title), owner_(owner) {} + void resize(int x,int y,int width,int height) override { + Fl_Double_Window::resize(x,y,width,height); + if(owner_ && owner_->transport_window()==this)owner_->layout_transport(); + } int handle(int event) override { if (event == FL_SHORTCUT && owner_->panel_shortcut(event)) return 1; return Fl_Double_Window::handle(event); } }; +void restore_panel(Fl_Window *window,int x,int y,int width,int height,int min_width,int min_height) +{ + int sx,sy,sw,sh;Fl::screen_work_area(sx,sy,sw,sh,Fl::screen_num(x,y,width,height)); + width=std::min(std::max(width,min_width),sw);height=std::min(std::max(height,min_height),sh); + x=std::clamp(x,sx,sx+sw-width);y=std::clamp(y,sy,sy+sh-height); + window->resize(x,y,width,height); +} bool editing_number(Fl_Value_Input *input) { return Fl::focus() == input || Fl::focus() == &input->input || Fl::pushed() == input || @@ -254,6 +266,10 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) if(r!=XTC_OK)w->report_error("Loop unchanged",r);},this); transport_group_->end(); + for(int i=0;ichildren();++i) { + auto *item=transport_group_->child(i); + transport_items_.push_back({item,item->x(),item->y()-28,item->w(),item->h()}); + } tabs_ = new Fl_Tabs(216, 126, 1056, 578); tabs_->begin(); @@ -498,20 +514,57 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) Fl::add_timeout(static_cast(settings_.refresh_ms)/1000,timer_callback,this); } +void MainWindow::resize(int x,int y,int width,int height) +{ + Fl_Double_Window::resize(x,y,width,height); + if(transport_group_)layout_transport(); +} +void MainWindow::layout_transport() +{ + if(layout_transport_busy_ || !transport_group_ || transport_items_.empty())return; + layout_transport_busy_=true; + int width=transport_detached_ && transport_window_ ? transport_window_->w() : w(); + int origin=transport_detached_ ? 32 : 28; + bool compact=transport_detached_ && width<1220; + int left=12,top=12; + transport_group_->resizable(nullptr); + for(const auto &item:transport_items_) { + int x,y; + if(compact) { + if(left+item.w+(item.widget==tempo_display_ ? 36 : 0)>width-12){left=12;top+=40;} + x=left;y=origin+top;left+=item.w+(item.widget==tempo_display_ ? 36 : 0)+8; + } else {x=item.x*width/1280;y=origin+item.y;} + item.widget->resize(x,y,item.w,item.h); + } + int height=compact ? top+48 : 90; + transport_group_->resize(0,origin,width,height); + // Group resize can transform children: restore exact layout after it. + left=12;top=12; + for(const auto &item:transport_items_) { + int x,y;if(compact){if(left+item.w+(item.widget==tempo_display_ ? 36 : 0)>width-12){left=12;top+=40;}x=left;y=origin+top;left+=item.w+(item.widget==tempo_display_ ? 36 : 0)+8;} + else{x=item.x*width/1280;y=origin+item.y;} + item.widget->resize(x,y,item.w,item.h); + } + transport_group_->init_sizes(); + if(transport_detached_ && transport_window_ && transport_window_->h()resize(transport_window_->x(),transport_window_->y(),width,origin+height+8); + layout_transport_busy_=false; +} void MainWindow::detach_transport(bool detached) { if (detached==transport_detached_ || !transport_group_) return; if (detached && !transport_window_) { auto *current=Fl_Group::current(); Fl_Group::current(nullptr); - transport_window_.reset(new PanelWindow(w(),130,"xtc — Transport (close to dock)",this)); + transport_window_.reset(new PanelWindow(static_cast(settings_.transport_width),static_cast(settings_.transport_height),"xtc — Transport (close to dock)",this)); auto *dock=new Fl_Button(12,4,140,24,"Dock transport"); dock->callback([](Fl_Widget *,void *c){static_cast(c)->detach_transport(false);},this); xtc_ui::style(transport_window_.get()); - transport_window_->end(); transport_window_->size_range(1220,130,0,130); + transport_window_->end(); transport_window_->size_range(400,130); transport_window_->callback([](Fl_Widget *,void *c){static_cast(c)->detach_transport(false);},this); + restore_panel(transport_window_.get(),static_cast(settings_.transport_x),static_cast(settings_.transport_y),static_cast(settings_.transport_width),static_cast(settings_.transport_height),400,130); Fl_Group::current(current); } - transport_detached_=detached; + transport_detached_=detached;settings_.transport_detached=detached; transport_group_->parent()->remove(transport_group_); if (detached) { transport_window_->add(transport_group_); @@ -528,6 +581,7 @@ void MainWindow::detach_transport(bool detached) init_sizes(); auto *item=const_cast(menu_->find_item("View/Detach transport")); if (detached) item->set(); else item->clear(); + layout_transport(); if (detached) transport_window_->show(); redraw(); } @@ -543,9 +597,10 @@ void MainWindow::detach_mixer(bool detached) xtc_ui::style(mixer_window_.get()); mixer_window_->end(); mixer_window_->size_range(600,420); mixer_window_->callback([](Fl_Widget *,void *c){static_cast(c)->detach_mixer(false);},this); + restore_panel(mixer_window_.get(),static_cast(settings_.mixer_x),static_cast(settings_.mixer_y),static_cast(settings_.mixer_width),static_cast(settings_.mixer_height),600,420); Fl_Group::current(current); } - mixer_detached_=detached; + mixer_detached_=detached;settings_.mixer_detached=detached; mixer_->parent()->remove(mixer_); if (detached) { mixer_page_->resizable(nullptr); @@ -1624,7 +1679,10 @@ xtc_result MainWindow::open_piano_roll() xtc_ui::style(piano_window_.get()); piano_window_->resizable(scroll); scroll->scroll_to(0, 60 * 16); - settings_.width=w();settings_.height=h(); + 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_.width=w();settings_.height=h(); settings_.sequencer_snap=static_cast(sequencer_->grid());settings_.sequencer_zoom=sequencer_->zoom_level(); apply_ui_preferences(); } else @@ -2256,6 +2314,9 @@ xtc_result MainWindow::set_loop(uint64_t start,uint64_t end,bool enabled) } 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_.width=w();settings_.height=h();settings_.sequencer_snap=static_cast(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(piano_->grid()); @@ -2264,6 +2325,10 @@ void MainWindow::collect_preferences() } void MainWindow::apply_ui_preferences() { + bool floating_transport=settings_.transport_detached!=0,floating_mixer=settings_.mixer_detached!=0; + detach_transport(floating_transport);detach_mixer(floating_mixer); + if(transport_window_)restore_panel(transport_window_.get(),static_cast(settings_.transport_x),static_cast(settings_.transport_y),static_cast(settings_.transport_width),static_cast(settings_.transport_height),400,130); + if(mixer_window_)restore_panel(mixer_window_.get(),static_cast(settings_.mixer_x),static_cast(settings_.mixer_y),static_cast(settings_.mixer_width),static_cast(settings_.mixer_height),600,420); follow_->value(static_cast(settings_.follow_playhead)); resize(x(),y(),static_cast(settings_.width),static_cast(settings_.height)); sequencer_->grid(static_cast(settings_.sequencer_snap));sequencer_->set_zoom(static_cast(settings_.sequencer_zoom)); diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index 7a02f4a..af5bf80 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -3,6 +3,7 @@ #include #include +#include #include #include "xtc/xtc_project.h" #include "xtc/xtc_backend.h" @@ -35,6 +36,8 @@ class MainWindow : public Fl_Double_Window { xtc_result set_tempo(unsigned int milli_bpm); void show_commands(); /* Reparent the existing controls on the UI thread; closing a panel docks it. */ + void layout_transport(); + void resize(int x,int y,int width,int height) override; void detach_transport(bool detached); void detach_mixer(bool detached); Fl_Double_Window *transport_window() const { return transport_window_.get(); } @@ -203,6 +206,9 @@ class MainWindow : public Fl_Double_Window { std::unique_ptr transport_window_, mixer_window_; Fl_Group *transport_group_=nullptr, *mixer_page_=nullptr, *sidebar_=nullptr; Fl_Group *mixer_placeholder_=nullptr; + struct TransportItem {Fl_Widget *widget;int x,y,w,h;}; + std::vector transport_items_; + bool layout_transport_busy_=false; bool transport_detached_=false, mixer_detached_=false; xtc_settings settings_{}; std::string configuration_; diff --git a/src/settings/settings.c b/src/settings/settings.c index 1463dcd..d4a6f83 100644 --- a/src/settings/settings.c +++ b/src/settings/settings.c @@ -23,6 +23,12 @@ static const xtc_setting_info schema[] = { STR("plugins",ladspa_paths,"LADSPA search paths (colon-separated)"), 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), + NUM("interface",mixer_detached,"Floating mixer: 0 or 1",0,1), + NUM("interface",transport_x,"Transport window X",-32768,32768),NUM("interface",transport_y,"Transport window Y",-32768,32768), + NUM("interface",transport_width,"Transport width",400,3840),NUM("interface",transport_height,"Transport height",130,2160), + NUM("interface",mixer_x,"Mixer window X",-32768,32768),NUM("interface",mixer_y,"Mixer window Y",-32768,32768), + NUM("interface",mixer_width,"Mixer window width",600,3840),NUM("interface",mixer_height,"Mixer window height",420,2160), NUM("interface",refresh_ms,"Display refresh interval, ms",20,1000), NUM("editing",sequencer_snap,"Sequencer snap ticks (1 means Off)",1,3840), NUM("editing",piano_snap,"Piano snap ticks (1 means Off)",1,3840), @@ -54,6 +60,8 @@ void xtc_settings_defaults(xtc_settings *s) strcpy(s->ladspa_paths,"~/.ladspa:/usr/local/lib/ladspa:/usr/lib/ladspa"); s->max_frames=65536; s->channels=2; s->transition_ms=5; s->offline_rate=48000; s->recording_seconds=2; s->width=1280; s->height=800; s->piano_width=900; s->piano_height=620; s->refresh_ms=100; + s->transport_x=s->transport_y=80;s->transport_width=800;s->transport_height=300; + s->mixer_x=s->mixer_y=100;s->mixer_width=1056;s->mixer_height=600; s->sequencer_snap=s->piano_snap=240; s->sequencer_zoom=48; s->clip_ticks=3840; s->note_ticks=960; s->note_velocity=100; s->filter_channel=-1; s->filter_high=127; s->tempo=120000; s->volume_cc=7; s->pan_cc=10; s->sustain_cc=64; s->loop_end=3840;s->tail_ms=3000; diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index b6ecd18..7a07f23 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -1237,6 +1237,35 @@ int main(int argc, char **argv) close_result=XTC_OK; window.do_callback(); CHECK(!window.shown() && !window.transport_window()->shown() && !window.mixer_window()->shown()); } + reset(); + { + std::string path="/tmp/xtc-panel-layout-"+std::to_string(getpid())+".conf"; + { + MainWindow window(false,path.c_str());window.show(); + window.detach_transport(true);window.detach_mixer(true); + window.transport_window()->resize(40,40,400,400);window.mixer_window()->resize(80,80,700,460);pump(); + auto *group=dynamic_cast(find(window.transport_window(),"Play")->parent());CHECK(group); + for(int i=0;ichildren();++i) { + auto *widget=group->child(i);CHECK(widget->x()>=0 && widget->x()+widget->w()<=400); + CHECK(widget->y()+widget->h()<=window.transport_window()->h()); + } + Fl::add_timeout(.05,[](void *) {auto *d=Fl::first_window();press(find(d,"Apply"));press(find(d,"Close"));}); + window.show_preferences();xtc_settings saved{};CHECK(xtc_settings_load(&saved,path.c_str())==XTC_OK); + CHECK(saved.transport_detached && saved.mixer_detached && saved.transport_width==400 && saved.mixer_width==700); + saved.transport_x=30000;saved.transport_y=-30000;CHECK(xtc_settings_save(&saved,path.c_str())==XTC_OK); + window.do_callback(); + } + { + MainWindow window(false,path.c_str());window.show();pump(); + CHECK(window.transport_window() && window.transport_window()->shown()); + CHECK(window.mixer_window() && window.mixer_window()->shown()); + int sx,sy,sw,sh;Fl::screen_work_area(sx,sy,sw,sh,0); + CHECK(window.transport_window()->x()>=sx && window.transport_window()->x()+window.transport_window()->w()<=sx+sw); + CHECK(window.transport_window()->y()>=sy && window.transport_window()->y()+window.transport_window()->h()<=sy+sh); + window.detach_transport(false);window.detach_mixer(false);window.do_callback(); + } + unlink(path.c_str()); + } if (failures != 0) return 1; std::printf("GUI controls/errors/views/timers/cleanup passed: %u opens, %u closes.\n", opens, closes); return 0; diff --git a/tests/unit/test_settings.c b/tests/unit/test_settings.c index 8a25f44..5122ae4 100644 --- a/tests/unit/test_settings.c +++ b/tests/unit/test_settings.c @@ -13,13 +13,13 @@ int main(void) CHECK(fd>=0);close(fd);unlink(path); xtc_settings_defaults(&a);CHECK(xtc_settings_validate(&a)==XTC_OK); b=a;CHECK(xtc_settings_load(&b,path)==XTC_ERR_NOT_FOUND); - strcpy(a.clap_paths,"~/space folder:/tmp/quoted\"name\\path");a.loop_enabled=1;a.follow_playhead=1;a.note_velocity=87; + strcpy(a.clap_paths,"~/space folder:/tmp/quoted\"name\\path");a.loop_enabled=1;a.follow_playhead=1;a.transport_detached=1;a.mixer_detached=1;a.transport_x=-30000;a.transport_width=400;a.note_velocity=87; CHECK(xtc_settings_save(&a,path)==XTC_OK);CHECK(stat(path,&st)==0 && (st.st_mode&0777)==0600); CHECK(xtc_settings_load(&b,path)==XTC_OK);CHECK(memcmp(&a,&b,sizeof(a))==0); xtc_settings_schema(&n); for(i=0;i=0);CHECK(fclose(f)==0);before=b;CHECK(xtc_settings_load(&b,path)==XTC_ERR_FORMAT);CHECK(memcmp(&before,&b,sizeof(b))==0);}} f=fopen(path,"wb");CHECK(f);CHECK(fwrite(";x\0hidden",1,9,f)==9);fclose(f);CHECK(xtc_settings_load(&b,path)==XTC_ERR_FORMAT); CHECK(xtc_settings_save(&a,path)==XTC_OK);b=a;b.loop_end=b.loop_start;CHECK(xtc_settings_save(&b,path)==XTC_ERR_INVALID_ARGUMENT);CHECK(xtc_settings_load(&b,path)==XTC_OK && b.loop_end==a.loop_end);