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

View file

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

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>
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<MainWindow *>(c)->return_to_start();
}, 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_->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<MainWindow *>(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<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->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<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()
{
// 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_Input_ *>(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<MainWindow *>(widget->window());
window->tabs_->value(static_cast<Fl_Widget *>(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<unsigned long long>((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) {

View file

@ -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<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_{};
std::string configuration_;
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);
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;
std::printf("GUI controls/errors/views/timers/cleanup passed: %u opens, %u closes.\n", opens, closes);
return 0;