diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d5b0301..839a5b4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1084,6 +1084,18 @@ See [BUILTIN_DEVICES.md](BUILTIN_DEVICES.md) for controls and explicit limitatio --- +## Phase 39 — Detachable transport and mixer + +- [x] Float/dock the existing transport and mixer without duplicating state. +- [x] Keep BPM and loop settings in the shared transport controls. +- [x] Recover arrangement space and retain resizable mixer channels. +- [x] Support close-to-dock, Dock all, floating playback shortcuts and safe shutdown. +- [x] Exercise lifecycle, settings and resizing in GUI tests and capture floating views. + +See [WORKSPACE.md](WORKSPACE.md) for usage and session-only layout limitations. + +--- + ## 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. diff --git a/docs/WORKSPACE.md b/docs/WORKSPACE.md index f369b21..4a66fa5 100644 --- a/docs/WORKSPACE.md +++ b/docs/WORKSPACE.md @@ -149,3 +149,31 @@ Fit song resets the viewport without editing the project. Verification covers real FLTK palette filtering, empty searches and cancellation, tempo callbacks/undo/failed adoption, inspector edits and in-progress numeric input, fit and follow navigation, and existing core/GUI/live JACK workflows. + + +## Detachable transport and mixer (Phase 39) + +Use **View → Detach transport** or **View → Detach mixer** to move either panel +into a separate window. Toggle the same menu action, use the floating panel's +**Dock** button, or close its window to return it to the main workspace. +**View → Dock all panels** restores both. **View → Mixer** (Ctrl+2) also raises +the floating mixer. The main arrangement expands when the transport is detached. +The mixer remains the same live view, with its channel selection, faders, routing +and meters; the main Mixer tab offers Show and Dock actions while it is floating. + +The transport owns the playback buttons, position, **BPM**, **Loop** toggle and +**Loop range...** settings in both layouts. Shift-drag on the sequencer ruler +still updates the same loop region. BPM remains an undoable project edit; +loop preferences retain their existing Preferences / Apply persistence behavior. +Space and menu playback shortcuts work from either floating panel, with normal +numeric/text editing protection. Closing the main window closes all panels only +after the existing unsaved/recording/backend shutdown checks succeed. + +Window detachment and positions are session-only; startup uses the docked layout. +The floating transport is a wide toolbar (minimum 1220 pixels); the floating +mixer resizes down to 600 × 420 and retains horizontal track scrolling. + +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. diff --git a/screenshots/README.md b/screenshots/README.md index 5e380e2..bc47be3 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -1,7 +1,8 @@ # xtc screenshots -Captured from the Phase 38 FLTK application on 2026-10-28, using a virtual X11 -screen. Images show actual rendered widgets, without mockups or image edits. +Captured from the Phase 38 FLTK application on 2026-10-28, with Phase 39 +transport, mixer, arrangement and command captures refreshed on 2026-10-30 using +a virtual X11 screen. Images show actual rendered widgets, without mockups or image edits. ![Overview of xtc screenshots](overview.png) @@ -49,3 +50,10 @@ arrangement navigation. No user configuration or example project was overwritten - [Internal instruments](internal-instruments.png) - [Internal delay](internal-delay.png) - [Internal reverb](internal-reverb.png) + +- [Detached transport with BPM and loop settings](transport-detached.png) +- [Detached mixer](mixer-detached.png) +- [Workspace with transport and mixer detached](workspace-detached.png) + +Detach panels from the View menu; close a floating window or use its Dock button +to return it to the main window. The main arrangement uses the freed toolbar space. diff --git a/screenshots/commands.png b/screenshots/commands.png index 0e02c1f..5b445cf 100644 Binary files a/screenshots/commands.png and b/screenshots/commands.png differ diff --git a/screenshots/overview.png b/screenshots/overview.png index c7c7574..224f5ff 100644 Binary files a/screenshots/overview.png and b/screenshots/overview.png differ diff --git a/screenshots/sequencer-loop.png b/screenshots/sequencer-loop.png index dc60599..28befa6 100644 Binary files a/screenshots/sequencer-loop.png and b/screenshots/sequencer-loop.png differ diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index b6d9cb4..7f9ac43 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -34,6 +34,16 @@ #include namespace { +class PanelWindow : public Fl_Double_Window { + MainWindow *owner_; + public: + PanelWindow(int width, int height, const char *title, MainWindow *owner) + : Fl_Double_Window(width,height,title), owner_(owner) {} + int handle(int event) override { + if (event == FL_SHORTCUT && owner_->panel_shortcut(event)) return 1; + return Fl_Double_Window::handle(event); + } +}; bool editing_number(Fl_Value_Input *input) { return Fl::focus() == input || Fl::focus() == &input->input || Fl::pushed() == input || @@ -141,6 +151,17 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) (void)static_cast(c)->return_to_start(); }, this); + menu->add("View/Detach transport",0,[](Fl_Widget *,void *c) { + auto *w=static_cast(c); w->detach_transport(!w->transport_detached_); + },this,FL_MENU_TOGGLE); + menu->add("View/Detach mixer",0,[](Fl_Widget *,void *c) { + auto *w=static_cast(c); w->detach_mixer(!w->mixer_detached_); + },this,FL_MENU_TOGGLE); + menu->add("View/Dock all panels",0,[](Fl_Widget *,void *c) { + auto *w=static_cast(c); w->detach_transport(false); w->detach_mixer(false); + },this); + transport_group_=new Fl_Group(0,28,1280,90); + transport_group_->begin(); play_ = new Fl_Button(12, 40, 80, 30, "Play"); play_->callback(play_callback, this); play_->tooltip("Start the audio engine (F5 or Space). Play audio clips and the modular graph."); @@ -222,6 +243,8 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) if(std::sscanf(text,"%llu %llu %c",&start,&end,&extra)==2)r=w->set_loop(start,end,w->settings_.loop_enabled!=0); if(r!=XTC_OK)w->report_error("Loop unchanged",r);},this); + transport_group_->end(); + tabs_ = new Fl_Tabs(216, 126, 1056, 578); tabs_->begin(); auto *sequencer = new Fl_Group(216, 158, 1056, 546, "Sequencer"); @@ -313,6 +336,7 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) sequencer->resizable(timeline_scroll); sequencer->end(); auto *mixer = new Fl_Group(216, 158, 1056, 546, "Mixer"); + mixer_page_=mixer; mixer->begin(); mixer_ = new MixerWidget(228, 168, 1032, 522, project_.get(), before_arrangement_edit, this); mixer_->callback( @@ -320,6 +344,14 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) static_cast(c)->mark_dirty(); }, this); + mixer_placeholder_=new Fl_Group(228,210,1032,120); + auto *detached_hint=new Fl_Box(240,220,600,30,"The mixer is open in a separate window."); + detached_hint->align(FL_ALIGN_LEFT|FL_ALIGN_INSIDE); + auto *show_mixer=new Fl_Button(240,260,170,30,"Show mixer window"); + show_mixer->callback([](Fl_Widget *,void *c){static_cast(c)->mixer_window_->show();},this); + auto *dock_mixer=new Fl_Button(424,260,140,30,"Dock mixer"); + dock_mixer->callback([](Fl_Widget *,void *c){static_cast(c)->detach_mixer(false);},this); + mixer_placeholder_->end(); mixer_placeholder_->hide(); mixer->resizable(mixer_); mixer->end(); auto *graph = new Fl_Group(216, 158, 1056, 546, "Modular Graph"); @@ -389,6 +421,7 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) menu->add("Help/About", 0, about_callback); auto *sidebar = new Fl_Group(12,126,192,578); + sidebar_=sidebar; sidebar->box(FL_BORDER_BOX); auto *create_title = new Fl_Box(24,140,168,24,"CREATE TRACKS"); create_title->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); @@ -455,6 +488,72 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) Fl::add_timeout(static_cast(settings_.refresh_ms)/1000,timer_callback,this); } +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)); + 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_->callback([](Fl_Widget *,void *c){static_cast(c)->detach_transport(false);},this); + Fl_Group::current(current); + } + transport_detached_=detached; + transport_group_->parent()->remove(transport_group_); + if (detached) { + transport_window_->add(transport_group_); + transport_group_->resize(0,32,transport_window_->w(),90); + transport_window_->resizable(transport_group_); + } else { + transport_window_->resizable(nullptr); + add(transport_group_); transport_group_->resize(0,28,w(),90); + transport_window_->hide(); + } + int delta=detached ? -90 : 90; + tabs_->resize(tabs_->x(),tabs_->y()+delta,tabs_->w(),tabs_->h()-delta); + sidebar_->resize(sidebar_->x(),sidebar_->y()+delta,sidebar_->w(),sidebar_->h()-delta); + init_sizes(); + auto *item=const_cast(menu_->find_item("View/Detach transport")); + if (detached) item->set(); else item->clear(); + if (detached) transport_window_->show(); + redraw(); +} + +void MainWindow::detach_mixer(bool detached) +{ + if (detached==mixer_detached_ || !mixer_) return; + if (detached && !mixer_window_) { + auto *current=Fl_Group::current(); Fl_Group::current(nullptr); + mixer_window_.reset(new PanelWindow(1056,600,"xtc — Mixer (close to dock)",this)); + auto *dock=new Fl_Button(12,6,140,28,"Dock mixer"); + dock->callback([](Fl_Widget *,void *c){static_cast(c)->detach_mixer(false);},this); + 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); + Fl_Group::current(current); + } + mixer_detached_=detached; + mixer_->parent()->remove(mixer_); + if (detached) { + mixer_page_->resizable(nullptr); + mixer_window_->add(mixer_); mixer_->resize(12,44,mixer_window_->w()-24,mixer_window_->h()-56); + mixer_window_->resizable(mixer_); mixer_placeholder_->show(); + } else { + mixer_window_->resizable(nullptr); + mixer_page_->add(mixer_); + mixer_->resize(mixer_page_->x()+12,mixer_page_->y()+10,mixer_page_->w()-24,mixer_page_->h()-24); + mixer_page_->resizable(mixer_); mixer_placeholder_->hide(); mixer_window_->hide(); + } + mixer_->show(); mixer_page_->init_sizes(); + auto *item=const_cast(menu_->find_item("View/Detach mixer")); + if (detached) item->set(); else item->clear(); + if (detached) mixer_window_->show(); + redraw(); +} + void MainWindow::draw() { // FLTK may reload the platform scheme when a window first becomes visible. @@ -472,6 +571,9 @@ void MainWindow::draw() MainWindow::~MainWindow() { Fl::remove_timeout(timer_callback, this); + // Return ownership before member windows and the main widget tree destruct. + detach_transport(false); + detach_mixer(false); // Normal window/menu close already synchronized callbacks. This fallback // covers exceptional event-loop exit. A failed close must retain its C // callback context until process exit, rather than free active audio state. @@ -821,6 +923,8 @@ bool MainWindow::close_window() Fl::remove_timeout(timer_callback, this); if (piano_window_) piano_window_->hide(); + if (transport_window_) transport_window_->hide(); + if (mixer_window_) mixer_window_->hide(); hide(); return true; } @@ -833,7 +937,7 @@ bool MainWindow::close_window() * @param event FLTK event code, such as FL_SHORTCUT. * @return 1 when Space was consumed; otherwise the Fl_Double_Window result. */ -int MainWindow::handle(int event) +int MainWindow::panel_shortcut(int event) { if (event == FL_SHORTCUT && dynamic_cast(Fl::focus()) && (Fl::event_key() == ' ' || (Fl::event_state(FL_CTRL) && @@ -845,6 +949,13 @@ int MainWindow::handle(int event) start_engine(); return 1; } + if (event == FL_SHORTCUT) return menu_->test_shortcut() != nullptr; + return 0; +} + +int MainWindow::handle(int event) +{ + if (panel_shortcut(event)) return 1; return Fl_Double_Window::handle(event); } @@ -900,6 +1011,7 @@ void MainWindow::view_callback(Fl_Widget *widget, void *view) auto *window = static_cast(widget->window()); window->tabs_->value(static_cast(view)); window->tabs_->redraw(); + if (view==window->mixer_page_ && window->mixer_detached_) window->mixer_window_->show(); } /** @@ -1680,7 +1792,9 @@ void MainWindow::refresh_workspace() std::snprintf(info, sizeof(info), "%zu tracks / %zu clips / %llu bars", tracks, clips, static_cast((end + 3839) / 3840)); set_label(session_info_, info); - uint64_t target = tabs_->value() == tabs_->child(1) ? mixer_->selected_track() : sequencer_->selected_track(); + bool mixer_context = tabs_->value() == mixer_page_ || + (mixer_detached_ && Fl::focus() && Fl::focus()->window() == mixer_window_.get()); + uint64_t target = mixer_context ? mixer_->selected_track() : sequencer_->selected_track(); bool selection_changed = target != inspected_track_; inspected_track_ = UINT64_MAX; for (size_t i = 0; i < tracks; ++i) { diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index a0c988e..82f0e7d 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -16,6 +16,7 @@ class MixerWidget; class PianoRollWidget; class SequencerWidget; class Fl_Box; +class Fl_Group; class Fl_Button; class Fl_Tabs; class Fl_Menu_Bar; @@ -33,6 +34,13 @@ class MainWindow : public Fl_Double_Window { xtc_result create_track(bool midi); 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 detach_transport(bool detached); + void detach_mixer(bool detached); + Fl_Double_Window *transport_window() const { return transport_window_.get(); } + Fl_Double_Window *mixer_window() const { return mixer_window_.get(); } + int panel_shortcut(int event); + void fit_song(); const xtc_settings &settings() const { return settings_; } xtc_result apply_preferences(const xtc_settings &settings); @@ -189,6 +197,10 @@ class MainWindow : public Fl_Double_Window { xtc_result return_to_start(); private: + std::unique_ptr transport_window_, mixer_window_; + Fl_Group *transport_group_=nullptr, *mixer_page_=nullptr, *sidebar_=nullptr; + Fl_Group *mixer_placeholder_=nullptr; + bool transport_detached_=false, mixer_detached_=false; xtc_settings settings_{}; std::string configuration_; Fl_Check_Button *loop_button_=nullptr; diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 8df8466..1b43c08 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -1134,6 +1134,47 @@ int main(int argc, char **argv) Fl::add_timeout(.05,[](void *context){int old=Fl::e_keysym;Fl::e_keysym=FL_Escape;static_cast(context)->handle(FL_KEYDOWN);Fl::e_keysym=old;},&palette); CHECK(palette.run().empty()); } + reset(); + { + MainWindow window(false,""); window.show(); + CHECK(window.create_track(true)==XTC_OK); + auto *sequence=timeline(&window); auto *mix=window.mixer(); + auto *play=find(&window,"Play"); + auto *bpm=dynamic_cast(find(&window,"BPM")); + auto *loop=dynamic_cast(find(&window,"Loop")); + auto *tabs=dynamic_cast(sequence->parent()->parent()->parent()); + CHECK(play && bpm && loop && tabs); + int original_y=tabs->y(); + for (int pass=0;pass<3;++pass) { + window.detach_transport(true); window.detach_mixer(true); pump(); + CHECK(window.transport_window()->shown() && window.mixer_window()->shown()); + CHECK(play->window()==window.transport_window() && mix->window()==window.mixer_window()); + CHECK(bpm->window()==window.transport_window() && loop->window()==window.transport_window()); + CHECK(tabs->y()==original_y-90); + Fl::focus(mix); int old_key=Fl::e_keysym, old_state=Fl::e_state; + Fl::e_keysym=' '; Fl::e_state=0; unsigned int old_starts=starts; + CHECK(window.mixer_window()->handle(FL_SHORTCUT)==1 && starts==old_starts+1); + CHECK(window.transport_window()->handle(FL_SHORTCUT)==1); + Fl::e_keysym=old_key; Fl::e_state=old_state; + + bpm->value(132); bpm->do_callback(); + CHECK(xtc_project_tempo(sequence->project())==132000); + CHECK(window.set_loop(0,3840,true)==XTC_OK); pump(); CHECK(loop->value()==1); + CHECK(window.set_loop(0,3840,false)==XTC_OK); pump(); CHECK(loop->value()==0); + window.mixer_window()->resize(10,10,800,500); pump(); + CHECK(mix->x()+mix->w()<=window.mixer_window()->w()); + window.resize(window.x(),window.y(),pass==0 ? 1600 : 1220,pass==0 ? 1000 : 720); + window.transport_window()->do_callback(); window.mixer_window()->do_callback(); pump(); + CHECK(!window.transport_window()->shown() && !window.mixer_window()->shown()); + CHECK(play->window()==&window && mix->window()==&window); + CHECK(tabs->y()==original_y && mix->w()>0 && mix->h()>0); + } + window.detach_transport(true); window.detach_mixer(true); + close_result=XTC_ERR_IO; window.do_callback(); + CHECK(window.shown() && window.transport_window()->shown() && window.mixer_window()->shown()); + close_result=XTC_OK; window.do_callback(); + CHECK(!window.shown() && !window.transport_window()->shown() && !window.mixer_window()->shown()); + } if (failures != 0) return 1; std::printf("GUI controls/errors/views/timers/cleanup passed: %u opens, %u closes.\n", opens, closes); return 0;