Detached mixer and transport from main window.
This commit is contained in:
parent
02e27a33a2
commit
cdea091b37
9 changed files with 219 additions and 4 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
|
|
@ -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 |
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 2.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 74 KiB |
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue