feat: persist floating panel layouts and wrap the compact transport

This commit is contained in:
Johannes Findeisen 2026-11-05 23:57:56 +01:00
commit 73e8bf09dc
9 changed files with 149 additions and 13 deletions

View file

@ -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.

View file

@ -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.
---

View file

@ -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.

View file

@ -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;

View file

@ -32,6 +32,7 @@
#include <cstdlib>
#include <cmath>
#include <cerrno>
#include <algorithm>
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;i<transport_group_->children();++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<double>(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()<origin+height+8)
transport_window_->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<int>(settings_.transport_width),static_cast<int>(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<MainWindow *>(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<MainWindow *>(c)->detach_transport(false);},this);
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);
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<Fl_Menu_Item *>(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<MainWindow *>(c)->detach_mixer(false);},this);
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);
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<int64_t>(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<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());
@ -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<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));
sequencer_->grid(static_cast<uint64_t>(settings_.sequencer_snap));sequencer_->set_zoom(static_cast<int>(settings_.sequencer_zoom));

View file

@ -3,6 +3,7 @@
#include <FL/Fl_Double_Window.H>
#include <memory>
#include <vector>
#include <string>
#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<Fl_Double_Window> 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<TransportItem> transport_items_;
bool layout_transport_busy_=false;
bool transport_detached_=false, mixer_detached_=false;
xtc_settings settings_{};
std::string configuration_;

View file

@ -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;

View file

@ -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<Fl_Group *>(find(window.transport_window(),"Play")->parent());CHECK(group);
for(int i=0;i<group->children();++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;

View file

@ -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<n;++i){CHECK(xtc_settings_get(&a,i,value,sizeof(value))==XTC_OK);CHECK(xtc_settings_set(&b,i,value)==XTC_OK);}
CHECK(memcmp(&a,&b,sizeof(a))==0);
{const char *bad[]={"[unknown]\n","[audio]\nchannels=3\n","[audio]\nchannels=1\nchannels=2\n","[audio]\nchannels=9999999999999999999999\n","[plugins]\nclap_paths=\"unterminated\n","[transport]\nloop_start=4000\nloop_end=2\n"};
{const char *bad[]={"[unknown]\n","[interface]\ntransport_width=399\n","[interface]\nmixer_detached=2\n","[audio]\nchannels=3\n","[audio]\nchannels=1\nchannels=2\n","[audio]\nchannels=9999999999999999999999\n","[plugins]\nclap_paths=\"unterminated\n","[transport]\nloop_start=4000\nloop_end=2\n"};
for(i=0;i<sizeof(bad)/sizeof(bad[0]);++i){f=fopen(path,"wb");CHECK(f);CHECK(fputs(bad[i],f)>=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);