Detached mixer and transport from main window.

This commit is contained in:
Johannes Findeisen 2026-10-30 12:39:47 +01:00
commit cdea091b37
9 changed files with 219 additions and 4 deletions

View file

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

@ -149,3 +149,31 @@ Fit song resets the viewport without editing the project.
Verification covers real FLTK palette filtering, empty searches and cancellation, Verification covers real FLTK palette filtering, empty searches and cancellation,
tempo callbacks/undo/failed adoption, inspector edits and in-progress numeric tempo callbacks/undo/failed adoption, inspector edits and in-progress numeric
input, fit and follow navigation, and existing core/GUI/live JACK workflows. 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.

View file

@ -1,7 +1,8 @@
# xtc screenshots # xtc screenshots
Captured from the Phase 38 FLTK application on 2026-10-28, using a virtual X11 Captured from the Phase 38 FLTK application on 2026-10-28, with Phase 39
screen. Images show actual rendered widgets, without mockups or image edits. 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) ![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 instruments](internal-instruments.png)
- [Internal delay](internal-delay.png) - [Internal delay](internal-delay.png)
- [Internal reverb](internal-reverb.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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Before After
Before After

View file

@ -34,6 +34,16 @@
#include <cerrno> #include <cerrno>
namespace { 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) bool editing_number(Fl_Value_Input *input)
{ {
return Fl::focus() == input || Fl::focus() == &input->input || Fl::pushed() == 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<MainWindow *>(c)->return_to_start(); (void)static_cast<MainWindow *>(c)->return_to_start();
}, this); }, this);
menu->add("View/Detach transport",0,[](Fl_Widget *,void *c) {
auto *w=static_cast<MainWindow *>(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<MainWindow *>(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<MainWindow *>(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_ = new Fl_Button(12, 40, 80, 30, "Play");
play_->callback(play_callback, this); play_->callback(play_callback, this);
play_->tooltip("Start the audio engine (F5 or Space). Play audio clips and the modular graph."); 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(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); if(r!=XTC_OK)w->report_error("Loop unchanged",r);},this);
transport_group_->end();
tabs_ = new Fl_Tabs(216, 126, 1056, 578); tabs_ = new Fl_Tabs(216, 126, 1056, 578);
tabs_->begin(); tabs_->begin();
auto *sequencer = new Fl_Group(216, 158, 1056, 546, "Sequencer"); 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->resizable(timeline_scroll);
sequencer->end(); sequencer->end();
auto *mixer = new Fl_Group(216, 158, 1056, 546, "Mixer"); auto *mixer = new Fl_Group(216, 158, 1056, 546, "Mixer");
mixer_page_=mixer;
mixer->begin(); mixer->begin();
mixer_ = new MixerWidget(228, 168, 1032, 522, project_.get(), before_arrangement_edit, this); mixer_ = new MixerWidget(228, 168, 1032, 522, project_.get(), before_arrangement_edit, this);
mixer_->callback( mixer_->callback(
@ -320,6 +344,14 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration)
static_cast<MainWindow *>(c)->mark_dirty(); static_cast<MainWindow *>(c)->mark_dirty();
}, },
this); 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<MainWindow *>(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<MainWindow *>(c)->detach_mixer(false);},this);
mixer_placeholder_->end(); mixer_placeholder_->hide();
mixer->resizable(mixer_); mixer->resizable(mixer_);
mixer->end(); mixer->end();
auto *graph = new Fl_Group(216, 158, 1056, 546, "Modular Graph"); 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); menu->add("Help/About", 0, about_callback);
auto *sidebar = new Fl_Group(12,126,192,578); auto *sidebar = new Fl_Group(12,126,192,578);
sidebar_=sidebar;
sidebar->box(FL_BORDER_BOX); sidebar->box(FL_BORDER_BOX);
auto *create_title = new Fl_Box(24,140,168,24,"CREATE TRACKS"); auto *create_title = new Fl_Box(24,140,168,24,"CREATE TRACKS");
create_title->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); 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<double>(settings_.refresh_ms)/1000,timer_callback,this); Fl::add_timeout(static_cast<double>(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<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_->callback([](Fl_Widget *,void *c){static_cast<MainWindow *>(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<Fl_Menu_Item *>(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<MainWindow *>(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<MainWindow *>(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<Fl_Menu_Item *>(menu_->find_item("View/Detach mixer"));
if (detached) item->set(); else item->clear();
if (detached) mixer_window_->show();
redraw();
}
void MainWindow::draw() void MainWindow::draw()
{ {
// FLTK may reload the platform scheme when a window first becomes visible. // FLTK may reload the platform scheme when a window first becomes visible.
@ -472,6 +571,9 @@ void MainWindow::draw()
MainWindow::~MainWindow() MainWindow::~MainWindow()
{ {
Fl::remove_timeout(timer_callback, this); 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 // Normal window/menu close already synchronized callbacks. This fallback
// covers exceptional event-loop exit. A failed close must retain its C // covers exceptional event-loop exit. A failed close must retain its C
// callback context until process exit, rather than free active audio state. // 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); Fl::remove_timeout(timer_callback, this);
if (piano_window_) if (piano_window_)
piano_window_->hide(); piano_window_->hide();
if (transport_window_) transport_window_->hide();
if (mixer_window_) mixer_window_->hide();
hide(); hide();
return true; return true;
} }
@ -833,7 +937,7 @@ bool MainWindow::close_window()
* @param event FLTK event code, such as FL_SHORTCUT. * @param event FLTK event code, such as FL_SHORTCUT.
* @return 1 when Space was consumed; otherwise the Fl_Double_Window result. * @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_Input_ *>(Fl::focus()) && if (event == FL_SHORTCUT && dynamic_cast<Fl_Input_ *>(Fl::focus()) &&
(Fl::event_key() == ' ' || (Fl::event_state(FL_CTRL) && (Fl::event_key() == ' ' || (Fl::event_state(FL_CTRL) &&
@ -845,6 +949,13 @@ int MainWindow::handle(int event)
start_engine(); start_engine();
return 1; 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); return Fl_Double_Window::handle(event);
} }
@ -900,6 +1011,7 @@ void MainWindow::view_callback(Fl_Widget *widget, void *view)
auto *window = static_cast<MainWindow *>(widget->window()); auto *window = static_cast<MainWindow *>(widget->window());
window->tabs_->value(static_cast<Fl_Widget *>(view)); window->tabs_->value(static_cast<Fl_Widget *>(view));
window->tabs_->redraw(); 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, std::snprintf(info, sizeof(info), "%zu tracks / %zu clips / %llu bars", tracks, clips,
static_cast<unsigned long long>((end + 3839) / 3840)); static_cast<unsigned long long>((end + 3839) / 3840));
set_label(session_info_, info); 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_; bool selection_changed = target != inspected_track_;
inspected_track_ = UINT64_MAX; inspected_track_ = UINT64_MAX;
for (size_t i = 0; i < tracks; ++i) { for (size_t i = 0; i < tracks; ++i) {

View file

@ -16,6 +16,7 @@ class MixerWidget;
class PianoRollWidget; class PianoRollWidget;
class SequencerWidget; class SequencerWidget;
class Fl_Box; class Fl_Box;
class Fl_Group;
class Fl_Button; class Fl_Button;
class Fl_Tabs; class Fl_Tabs;
class Fl_Menu_Bar; class Fl_Menu_Bar;
@ -33,6 +34,13 @@ class MainWindow : public Fl_Double_Window {
xtc_result create_track(bool midi); xtc_result create_track(bool midi);
xtc_result set_tempo(unsigned int milli_bpm); xtc_result set_tempo(unsigned int milli_bpm);
void show_commands(); 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(); void fit_song();
const xtc_settings &settings() const { return settings_; } const xtc_settings &settings() const { return settings_; }
xtc_result apply_preferences(const xtc_settings &settings); xtc_result apply_preferences(const xtc_settings &settings);
@ -189,6 +197,10 @@ class MainWindow : public Fl_Double_Window {
xtc_result return_to_start(); xtc_result return_to_start();
private: private:
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;
bool transport_detached_=false, mixer_detached_=false;
xtc_settings settings_{}; xtc_settings settings_{};
std::string configuration_; std::string configuration_;
Fl_Check_Button *loop_button_=nullptr; Fl_Check_Button *loop_button_=nullptr;

View file

@ -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<CommandPalette *>(context)->handle(FL_KEYDOWN);Fl::e_keysym=old;},&palette); Fl::add_timeout(.05,[](void *context){int old=Fl::e_keysym;Fl::e_keysym=FL_Escape;static_cast<CommandPalette *>(context)->handle(FL_KEYDOWN);Fl::e_keysym=old;},&palette);
CHECK(palette.run().empty()); 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<Fl_Value_Input *>(find(&window,"BPM"));
auto *loop=dynamic_cast<Fl_Check_Button *>(find(&window,"Loop"));
auto *tabs=dynamic_cast<Fl_Tabs *>(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; if (failures != 0) return 1;
std::printf("GUI controls/errors/views/timers/cleanup passed: %u opens, %u closes.\n", opens, closes); std::printf("GUI controls/errors/views/timers/cleanup passed: %u opens, %u closes.\n", opens, closes);
return 0; return 0;