From d800a6392ee830c2d432e9da9c7c6d9364f1c432 Mon Sep 17 00:00:00 2001 From: hanez Date: Tue, 27 Oct 2026 18:04:39 +0100 Subject: [PATCH] Phase 33: add a flat modern DAW workspace and track creation workflow --- docs/BUILD.md | 5 + docs/RELEASE_NOTES.md | 7 + docs/ROADMAP.md | 29 ++++ docs/USER_MANUAL.md | 7 + docs/WORKSPACE.md | 57 +++++++ examples/projects/README.md | 5 + examples/projects/modern-session.xtc | 236 +++++++++++++++++++++++++++ src/gui/graph_widget.cpp | 9 +- src/gui/main.cpp | 4 - src/gui/main_window.cpp | 181 +++++++++++++++----- src/gui/main_window.hpp | 9 + src/gui/mixer_widget.cpp | 7 +- src/gui/piano_roll.cpp | 3 +- src/gui/plugin_dialog.cpp | 3 +- src/gui/sequencer_widget.cpp | 140 ++++++++++------ src/gui/theme.hpp | 70 ++++++++ tests/integration/test_gui.cpp | 50 +++++- tests/integration/test_install.py | 2 + tests/integration/test_workflow.cpp | 4 +- 19 files changed, 712 insertions(+), 116 deletions(-) create mode 100644 docs/WORKSPACE.md create mode 100644 examples/projects/modern-session.xtc create mode 100644 src/gui/theme.hpp diff --git a/docs/BUILD.md b/docs/BUILD.md index 8fb6cb5..2313c99 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -279,3 +279,8 @@ VST2 compatibility uses original ABI declarations. `make test-modern-plugins` runs original shared-library fixtures without a server; `make test-gui-modern-plugins` adds GUI/browser/undo/reopen tests under Xvfb. The fixtures also run in `make test` and therefore in sanitizer/release builds. Notices are installed with LICENSE. + +Phase 33 uses a header-only FLTK theme and existing drawing primitives; no new +GUI dependency or generated asset is required. `make test-gui-instruments` also +exercises sidebar track creation, rollback, undo/reopen and minimum/large layouts. +See WORKSPACE.md for the default size and manual visual verification. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index f04e980..89ffe08 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -61,3 +61,10 @@ layouts remain unsupported. No third-party modern plugin has been qualified. Phase 32 adds CLAP/VST2/VST3 mono instruments bound to MIDI tracks, sample-offset notes, a saved live target, track mixing, GUI assignment/undo and v12 persistence. See PLUGIN_HOSTING.md for compatibility and controller/state limits. + +Phase 33 adds a flat DAW workspace, shared dark theme, track-creation sidebar, +read-only musical transport/track inspector, colored track clips and real MIDI +previews. New MIDI/audio track commands are undoable and prepare candidates +before adoption. The default window is 1280 × 800, minimum 1220 × 720. +No dependency or project-format change. See WORKSPACE.md and the new +`modern-session.xtc` example; existing engine/plugin limitations still apply. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ea119cb..2d22fce 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -931,6 +931,35 @@ installation, reproducible source releases and relocatable binary packages pass. --- +## Phase 33 — Flat Modern DAW Workspace + +### Goal +Give xtc a clear, modern DAW presentation for creating tracks and arranging +music, with simple flat controls and no 3D styling or proprietary assets. + +### Tasks and acceptance criteria +- [x] Introduce one shared dark palette, typography, flat controls and focus/selection accents. +- [x] Make track creation, import, instrument loading and MIDI editing visible in a persistent sidebar. +- [x] Create/select MIDI tracks with clips and audio tracks transactionally, with undo and failure rollback. +- [x] Organize transport, musical position/BPM, main editors, inspector and status in a resizable workspace. +- [x] Draw colored track headers, clear clip selection, bar/beat grids and actual MIDI note previews. +- [x] Preserve existing editing, mixer/automation, graph, plugin, recording and keyboard behavior. +- [x] Add an original multi-track example and document the workflow and retained limitations. +- [x] Verify GUI actions, minimum/large layouts, core regressions and release packaging. + +### Implementation status +Implemented in the FLTK GUI without new dependencies, engine changes or a project +format change. Default workspace 1280 × 800; supported minimum 1220 × 720. +Track creation is a single undoable prepared-model edit. The transport and +sidebar inspector are read-only displays; existing editors retain their controls. +Original four-track example visually checked; GUI tests cover creation, +selection, rollback, undo/reopen and minimum/large window sizes. This phase +modernizes presentation and workflow; it does not claim commercial-DAW feature +parity or add docking, scanning or mixer channel-strip functionality. +See WORKSPACE.md for the workflow, tests and limits. + +--- + ## 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/USER_MANUAL.md b/docs/USER_MANUAL.md index b26ef94..ab19bd5 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -199,3 +199,10 @@ Do not unload/replace modules during playback; candidate plans are prepared off the callback and old instances are reclaimed on the control thread. Missing references remain inspectable without loading; failed GUI preparation retains the current project. Save a backup for applications limited to v11 or earlier. + +## Phase 33 workspace + +The flat workspace puts New MIDI/audio track, WAV import and instrument loading +in a persistent sidebar, with a musical transport display and selected-track +overview. See [WORKSPACE.md](WORKSPACE.md) for shortcuts and workflow. Open +`modern-session.xtc` in the installed examples for an original four-track arrangement. diff --git a/docs/WORKSPACE.md b/docs/WORKSPACE.md new file mode 100644 index 0000000..2435c88 --- /dev/null +++ b/docs/WORKSPACE.md @@ -0,0 +1,57 @@ +# Flat DAW workspace + +Phase 33 presents the existing xtc editors in a consistent flat workspace. It uses +FLTK vector drawing and system fonts: no images, shadows, bevels, 3D rendering, +external theme packages or new runtime dependencies. + +The default window is 1280 × 800; the supported minimum is 1220 × 720. The main +editor grows with the window. Use the scrollbars for longer arrangements and +larger track counts. The sidebar stays available across Sequencer, Mixer and +Modular Graph views. Ctrl+1/2/3 switches those views; Ctrl+4 opens the piano roll. + +## Create and arrange + +- **New MIDI track** (Ctrl+Shift+M) creates a named track and a one-bar MIDI clip + at the cursor. The new clip and mixer track are selected together. **Edit MIDI + notes** opens its piano roll. Creation is one undo step. +- **New audio track** (Ctrl+Shift+A) creates an empty named track. **Import WAV** + imports into the selected track, or creates one when none is selected. +- **Load instrument plugin** opens the instrument browser for the selected + sequencer track. Unassigned MIDI tracks retain the built-in sine instrument. +- Existing Add track/region/MIDI clip, duplication, trimming, split, rename and + delete commands remain available. F2 renames the selection. Drag clips to move + them and their edges to trim them; click the ruler to seek while stopped. + +Creating a track uses candidate preparation, so module, capacity or allocation +failures retain the current project. As with other project replacements, the +engine stops and the transport returns to the beginning. Track creation is +unavailable while recording. No track type restriction is added to the model: +ordinary tracks still accept the existing audio and MIDI workflows. + +Track headers show a stable color, name, order and mute/solo state. Clip colors +follow track identity through reordering. MIDI previews show real note starts, +durations and relative pitches; waveforms continue to show loaded audio data. +The selected clip has a light outline. A teal playhead and bar grid make musical +time visible. The transport display reads **bar : beat : tick** (4/4, 960 ticks +per beat); BPM follows the project tempo. These displays are read-only. + +The sidebar inspector follows the sequencer selection, showing track name, +gain/pan, mute/solo and assigned instrument. Use the Mixer for controls, routing, +meters and automation; the sidebar is an overview, not another mixer. Status and +errors remain visible below the editor. Existing engine connection, transport, +recording and keyboard safety behavior is retained. + +## Example and verification + +Open `examples/projects/modern-session.xtc` for an original four-track, four-bar +MIDI arrangement with 16 clips and 128 notes. It needs no media or plugins. It is +also installed with the project examples. Connect JACK playback ports before +listening. Work on a copy if the installed examples are read-only. + +GUI tests exercise sidebar creation, selected clip/editor access, undo/redo, +failed preparation, save/reopen and layout at 1220 × 720 and 1600 × 1000. The +running example was also inspected at 1280 × 800. Existing GUI plugin, live JACK +and workflow tests cover the retained commands. A modern presentation does not +add commercial-DAW features: detachable/resizable docks, mixer channel strips, +automatic plugin scanning, custom plugin editors and new audio/MIDI features +remain outside this phase. Existing plugin and engine limits still apply. diff --git a/examples/projects/README.md b/examples/projects/README.md index 080304a..469cb16 100644 --- a/examples/projects/README.md +++ b/examples/projects/README.md @@ -5,3 +5,8 @@ repository v10 fixture. It contains one A4 MIDI note, a gain node and an output. Open it in xtc and Play after connecting JACK output ports to hear it. It needs no media or plugins. `xtc-check --validate midi-demo.xtc` works without JACK. Save a working copy in your own folder; installed examples may be read-only. + +`modern-session.xtc` is an original MIT-licensed four-track, four-bar arrangement +for the Phase 33 workspace. Its 16 MIDI clips contain 128 notes for built-in sine +voices. It needs no plugins or media. Open it to explore colored tracks, MIDI +previews, clip selection and the piano roll. Save a copy before editing. diff --git a/examples/projects/modern-session.xtc b/examples/projects/modern-session.xtc new file mode 100644 index 0000000..af09968 --- /dev/null +++ b/examples/projects/modern-session.xtc @@ -0,0 +1,236 @@ +XTC_PROJECT 12 +APP 0 1 0 +TEMPO 120000 +IDS 4 16 +GRAPH 1 2 2 1 +NODE 0 6 00000000 +NODE 1 4 00000000 +EDGE 0 1 +TRACKS 4 +TRACK 0 4b657973 +MIX 3f800000 00000000 0 0 +ROUTE 0 18446744073709551615 +TRACK 1 42617373 +MIX 3f800000 00000000 0 0 +ROUTE 0 18446744073709551615 +TRACK 2 417270656767696f +MIX 3f800000 00000000 0 0 +ROUTE 0 18446744073709551615 +TRACK 3 4d656c6f6479 +MIX 3f800000 00000000 0 0 +ROUTE 0 18446744073709551615 +CLIPS 16 +CLIP 0 0 0 3840 0 3840 4b657973 +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +CLIP 1 0 3840 3840 0 3840 4b657973 +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +CLIP 2 0 7680 3840 0 3840 4b657973 +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +CLIP 3 0 11520 3840 0 3840 4b657973 +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +CLIP 4 1 0 3840 0 3840 42617373 +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +CLIP 5 1 3840 3840 0 3840 42617373 +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +CLIP 6 1 7680 3840 0 3840 42617373 +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +CLIP 7 1 11520 3840 0 3840 42617373 +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +CLIP 8 2 0 3840 0 3840 417270656767696f +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +CLIP 9 2 3840 3840 0 3840 417270656767696f +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +CLIP 10 2 7680 3840 0 3840 417270656767696f +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +CLIP 11 2 11520 3840 0 3840 417270656767696f +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +CLIP 12 3 0 3840 0 3840 4d656c6f6479 +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +CLIP 13 3 3840 3840 0 3840 4d656c6f6479 +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +CLIP 14 3 7680 3840 0 3840 4d656c6f6479 +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +CLIP 15 3 11520 3840 0 3840 4d656c6f6479 +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +NOTES 128 128 +NOTE 0 0 0 360 60 72 0 +NOTE 1 0 480 360 64 72 0 +NOTE 2 0 960 360 67 72 0 +NOTE 3 0 1440 360 64 72 0 +NOTE 4 0 1920 360 62 72 0 +NOTE 5 0 2400 360 65 72 0 +NOTE 6 0 2880 360 69 72 0 +NOTE 7 0 3360 360 65 72 0 +NOTE 8 1 0 360 60 72 0 +NOTE 9 1 480 360 64 72 0 +NOTE 10 1 960 360 67 72 0 +NOTE 11 1 1440 360 64 72 0 +NOTE 12 1 1920 360 62 72 0 +NOTE 13 1 2400 360 65 72 0 +NOTE 14 1 2880 360 69 72 0 +NOTE 15 1 3360 360 65 72 0 +NOTE 16 2 0 360 60 72 0 +NOTE 17 2 480 360 64 72 0 +NOTE 18 2 960 360 67 72 0 +NOTE 19 2 1440 360 64 72 0 +NOTE 20 2 1920 360 62 72 0 +NOTE 21 2 2400 360 65 72 0 +NOTE 22 2 2880 360 69 72 0 +NOTE 23 2 3360 360 65 72 0 +NOTE 24 3 0 360 60 72 0 +NOTE 25 3 480 360 64 72 0 +NOTE 26 3 960 360 67 72 0 +NOTE 27 3 1440 360 64 72 0 +NOTE 28 3 1920 360 62 72 0 +NOTE 29 3 2400 360 65 72 0 +NOTE 30 3 2880 360 69 72 0 +NOTE 31 3 3360 360 65 72 0 +NOTE 32 4 0 420 36 88 0 +NOTE 33 4 480 420 36 88 0 +NOTE 34 4 960 420 43 88 0 +NOTE 35 4 1440 420 43 88 0 +NOTE 36 4 1920 420 38 88 0 +NOTE 37 4 2400 420 38 88 0 +NOTE 38 4 2880 420 45 88 0 +NOTE 39 4 3360 420 45 88 0 +NOTE 40 5 0 420 36 88 0 +NOTE 41 5 480 420 36 88 0 +NOTE 42 5 960 420 43 88 0 +NOTE 43 5 1440 420 43 88 0 +NOTE 44 5 1920 420 38 88 0 +NOTE 45 5 2400 420 38 88 0 +NOTE 46 5 2880 420 45 88 0 +NOTE 47 5 3360 420 45 88 0 +NOTE 48 6 0 420 36 88 0 +NOTE 49 6 480 420 36 88 0 +NOTE 50 6 960 420 43 88 0 +NOTE 51 6 1440 420 43 88 0 +NOTE 52 6 1920 420 38 88 0 +NOTE 53 6 2400 420 38 88 0 +NOTE 54 6 2880 420 45 88 0 +NOTE 55 6 3360 420 45 88 0 +NOTE 56 7 0 420 36 88 0 +NOTE 57 7 480 420 36 88 0 +NOTE 58 7 960 420 43 88 0 +NOTE 59 7 1440 420 43 88 0 +NOTE 60 7 1920 420 38 88 0 +NOTE 61 7 2400 420 38 88 0 +NOTE 62 7 2880 420 45 88 0 +NOTE 63 7 3360 420 45 88 0 +NOTE 64 8 0 360 72 72 0 +NOTE 65 8 480 360 76 72 0 +NOTE 66 8 960 360 79 72 0 +NOTE 67 8 1440 360 76 72 0 +NOTE 68 8 1920 360 74 72 0 +NOTE 69 8 2400 360 77 72 0 +NOTE 70 8 2880 360 81 72 0 +NOTE 71 8 3360 360 77 72 0 +NOTE 72 9 0 360 72 72 0 +NOTE 73 9 480 360 76 72 0 +NOTE 74 9 960 360 79 72 0 +NOTE 75 9 1440 360 76 72 0 +NOTE 76 9 1920 360 74 72 0 +NOTE 77 9 2400 360 77 72 0 +NOTE 78 9 2880 360 81 72 0 +NOTE 79 9 3360 360 77 72 0 +NOTE 80 10 0 360 72 72 0 +NOTE 81 10 480 360 76 72 0 +NOTE 82 10 960 360 79 72 0 +NOTE 83 10 1440 360 76 72 0 +NOTE 84 10 1920 360 74 72 0 +NOTE 85 10 2400 360 77 72 0 +NOTE 86 10 2880 360 81 72 0 +NOTE 87 10 3360 360 77 72 0 +NOTE 88 11 0 360 72 72 0 +NOTE 89 11 480 360 76 72 0 +NOTE 90 11 960 360 79 72 0 +NOTE 91 11 1440 360 76 72 0 +NOTE 92 11 1920 360 74 72 0 +NOTE 93 11 2400 360 77 72 0 +NOTE 94 11 2880 360 81 72 0 +NOTE 95 11 3360 360 77 72 0 +NOTE 96 12 0 360 79 72 0 +NOTE 97 12 480 360 76 72 0 +NOTE 98 12 960 360 74 72 0 +NOTE 99 12 1440 360 72 72 0 +NOTE 100 12 1920 360 77 72 0 +NOTE 101 12 2400 360 74 72 0 +NOTE 102 12 2880 360 72 72 0 +NOTE 103 12 3360 360 69 72 0 +NOTE 104 13 0 360 79 72 0 +NOTE 105 13 480 360 76 72 0 +NOTE 106 13 960 360 74 72 0 +NOTE 107 13 1440 360 72 72 0 +NOTE 108 13 1920 360 77 72 0 +NOTE 109 13 2400 360 74 72 0 +NOTE 110 13 2880 360 72 72 0 +NOTE 111 13 3360 360 69 72 0 +NOTE 112 14 0 360 79 72 0 +NOTE 113 14 480 360 76 72 0 +NOTE 114 14 960 360 74 72 0 +NOTE 115 14 1440 360 72 72 0 +NOTE 116 14 1920 360 77 72 0 +NOTE 117 14 2400 360 74 72 0 +NOTE 118 14 2880 360 72 72 0 +NOTE 119 14 3360 360 69 72 0 +NOTE 120 15 0 360 79 72 0 +NOTE 121 15 480 360 76 72 0 +NOTE 122 15 960 360 74 72 0 +NOTE 123 15 1440 360 72 72 0 +NOTE 124 15 1920 360 77 72 0 +NOTE 125 15 2400 360 74 72 0 +NOTE 126 15 2880 360 72 72 0 +NOTE 127 15 3360 360 69 72 0 +AUTOMATION 0 +CONTROL_MAPS 16 +MAP 0 7 10 64 +MAP 1 7 10 64 +MAP 2 7 10 64 +MAP 3 7 10 64 +MAP 4 7 10 64 +MAP 5 7 10 64 +MAP 6 7 10 64 +MAP 7 7 10 64 +MAP 8 7 10 64 +MAP 9 7 10 64 +MAP 10 7 10 64 +MAP 11 7 10 64 +MAP 12 7 10 64 +MAP 13 7 10 64 +MAP 14 7 10 64 +MAP 15 7 10 64 +TRACK_INSTRUMENTS 0 +LIVE_INSTRUMENT 18446744073709551615 +END diff --git a/src/gui/graph_widget.cpp b/src/gui/graph_widget.cpp index 4182b65..6368d12 100644 --- a/src/gui/graph_widget.cpp +++ b/src/gui/graph_widget.cpp @@ -1,3 +1,4 @@ +#include "theme.hpp" #include "graph_widget.hpp" #include "xtc/xtc_plugin.h" #include @@ -366,7 +367,7 @@ void GraphWidget::draw() selected_node_ == edge.source_node || selected_node_ == edge.destination_node; xtc_routing_info route{}; bool audible_path = routing(edge.destination_node, route) && route.reaches_output; - fl_color(selected ? FL_YELLOW : audible_path ? fl_rgb_color(100, 200, 190) : fl_rgb_color(110, 115, 125)); + fl_color(selected ? xtc_ui::accent() : audible_path ? fl_rgb_color(100, 200, 190) : fl_rgb_color(110, 115, 125)); fl_line_style(FL_SOLID, selected ? 3 : 2); fl_line(x() + sx, y() + sy, x() + dx, y() + dy); double length = std::hypot(dx-sx, dy-sy); @@ -388,7 +389,7 @@ void GraphWidget::draw() : node.type == XTC_NODE_OSCILLATOR || node.type == XTC_NODE_AUDIO || node.type == XTC_NODE_INSTRUMENT ? fl_rgb_color(35, 80, 75) : fl_rgb_color(49, 65, 85)); fl_rectf(left, top, node_width, node_height); - fl_color(selected_node_ == node.id ? FL_YELLOW : fl_rgb_color(115, 130, 145)); + fl_color(selected_node_ == node.id ? xtc_ui::accent() : fl_rgb_color(115, 130, 145)); fl_rect(left, top, node_width, node_height); fl_color(FL_WHITE); fl_font(FL_HELVETICA_BOLD, 12); @@ -415,7 +416,7 @@ void GraphWidget::draw() fl_pop_clip(); xtc_routing_info route{}; if (routing(node.id, route) && !route.reaches_output) { - fl_color(FL_YELLOW); + fl_color(xtc_ui::accent()); fl_draw("! no output path", left + 10, top + 72); } for (size_t port = 0U; port < 2U; ++port) { @@ -431,7 +432,7 @@ void GraphWidget::draw() if (wiring_) { int px, py; if (port_position(wire_node_, wire_port_, px, py)) { - fl_color(FL_YELLOW); + fl_color(xtc_ui::accent()); fl_line(x() + px, y() + py, x() + mouse_x_, y() + mouse_y_); } } diff --git a/src/gui/main.cpp b/src/gui/main.cpp index c5f10a7..78c1787 100644 --- a/src/gui/main.cpp +++ b/src/gui/main.cpp @@ -39,10 +39,6 @@ int main(int argc, char **argv) argv[1] = nullptr; } try { - Fl::scheme("flat"); - Fl::background(34, 36, 40); - Fl::background2(24, 26, 30); - Fl::foreground(230, 232, 236); MainWindow window; if (project_path) { xtc_result result = window.open_project(project_path); diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 596aa7c..54b2743 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -4,6 +4,7 @@ #include "piano_roll.hpp" #include "mixer_widget.hpp" #include "plugin_dialog.hpp" +#include "theme.hpp" #include #include "xtc/xtc_version.h" @@ -60,8 +61,9 @@ class BusyOperation { * discarding unsaved changes. */ MainWindow::MainWindow(bool prompt_unsaved) - : Fl_Double_Window(1000, 680, XTC_PROJECT_NAME " — modular audio workstation"), prompt_unsaved_(prompt_unsaved) + : Fl_Double_Window(1280, 800, XTC_PROJECT_NAME " — modular audio workstation"), prompt_unsaved_(prompt_unsaved) { + xtc_ui::initialize(); xtc_project *initial = nullptr; xtc_result result = xtc_project_create(&initial); if (result != XTC_OK) @@ -73,17 +75,17 @@ MainWindow::MainWindow(bool prompt_unsaved) throw std::runtime_error(xtc_result_string(result)); history_.reset(history); begin(); - auto *menu = new Fl_Menu_Bar(0, 0, 1000, 28); + auto *menu = new Fl_Menu_Bar(0, 0, 1280, 28); menu_ = menu; - menu->add("Edit/Undo", FL_CTRL + 'z', history_callback, this); - menu->add("Edit/Redo", FL_CTRL + FL_SHIFT + 'z', history_callback, this); - menu->add("Edit/Redo alternate", FL_CTRL + 'y', history_callback, this); menu->add("File/New", FL_CTRL + 'n', file_callback, this); menu->add("File/Open", FL_CTRL + 'o', file_callback, this); menu->add("File/Save", FL_CTRL + 's', file_callback, this); menu->add("File/Save As", FL_CTRL + FL_SHIFT + 's', file_callback, this); menu->add("File/Import WAV", FL_CTRL + 'i', audio_file_callback, this); menu->add("File/Relink selected audio", 0, audio_file_callback, this); + menu->add("Edit/Undo", FL_CTRL + 'z', history_callback, this); + menu->add("Edit/Redo", FL_CTRL + FL_SHIFT + 'z', history_callback, this); + menu->add("Edit/Redo alternate", FL_CTRL + 'y', history_callback, this); menu->add("Audio/MIDI controller mapping", 0, control_map_callback, this); menu->add("Audio/Panic", FL_F + 9, panic_callback, this); menu->add("View/Piano roll", FL_CTRL + '4', piano_callback, this); @@ -99,6 +101,7 @@ MainWindow::MainWindow(bool prompt_unsaved) menu->add("Edit/Selected plugin automation", 0, plugin_callback, this); menu->add("Edit/Selected plugin control", 0, plugin_callback, this); menu->add("File/Quit", FL_CTRL + 'q', close_callback, this); + menu->add("Audio/Start engine", FL_F + 5, play_callback, this); menu->add("Audio/Stop engine", FL_F + 8, stop_callback, this); menu->add("Audio/Reconnect", FL_F + 6, reconnect_callback, this); @@ -135,35 +138,35 @@ MainWindow::MainWindow(bool prompt_unsaved) (void)static_cast(c)->return_to_start(); }, this); home_->tooltip("Stop and return to the project beginning (Home). Unavailable while recording."); - indicator_ = new Fl_Box(668, 40, 320, 30, "Audio: disconnected"); + indicator_ = new Fl_Box(1000, 40, 268, 30, "Audio: disconnected"); indicator_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); indicator_->labelfont(FL_BOLD); indicator_->labelsize(12); - auto *hint = new Fl_Box(12, 76, 976, 34, - "Start: Add track, Add MIDI clip, then Piano roll to draw notes; or Import WAV (Ctrl+I).\n" - "Play with F5. Connect JACK output/output_right to playback ports to hear audio."); + transport_position_ = new Fl_Box(680,40,190,30,"001 : 1 : 000"); + transport_position_->tooltip("Musical position: bar : beat : tick (4/4, 960 ticks per beat)"); + tempo_display_ = new Fl_Box(880,40,110,30,"120.0 BPM"); + auto *hint = new Fl_Box(12,82,1256,28,"xtc / ARRANGEMENT Create tracks, arrange clips and shape your sound."); hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); - hint->labelsize(12); - tabs_ = new Fl_Tabs(8, 114, 984, 470); + tabs_ = new Fl_Tabs(216, 126, 1056, 578); tabs_->begin(); - auto *sequencer = new Fl_Group(8, 140, 984, 444, "Sequencer"); + auto *sequencer = new Fl_Group(216, 158, 1056, 546, "Sequencer"); sequencer->begin(); - auto *add_track = new Fl_Button(20, 150, 100, 26, "Add track"); - auto *add_region = new Fl_Button(128, 150, 110, 26, "Add region"); - auto *delete_region = new Fl_Button(246, 150, 130, 26, "Delete region/track"); - auto *zoom_in = new Fl_Button(384, 150, 80, 26, "Zoom +"); - auto *zoom_out = new Fl_Button(472, 150, 80, 26, "Zoom -"); - auto *add_midi = new Fl_Button(560, 150, 110, 26, "Add MIDI clip"); - auto *piano = new Fl_Button(678, 150, 100, 26, "Piano roll"); + auto *add_track = new Fl_Button(228, 168, 100, 26, "Add track"); + auto *add_region = new Fl_Button(336, 168, 110, 26, "Add region"); + auto *delete_region = new Fl_Button(454, 168, 130, 26, "Delete region/track"); + auto *zoom_in = new Fl_Button(592, 168, 80, 26, "Zoom +"); + auto *zoom_out = new Fl_Button(680, 168, 80, 26, "Zoom -"); + auto *add_midi = new Fl_Button(768, 168, 110, 26, "Add MIDI clip"); + auto *piano = new Fl_Button(886, 168, 100, 26, "Piano roll"); piano->callback(piano_callback, this); auto *timeline_hint = - new Fl_Box(20, 548, 956, 28, + new Fl_Box(228, 666, 1032, 28, "Drag clips to move, edges to trim. Click the ruler while stopped to seek; Home returns to start."); timeline_hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); timeline_hint->labelsize(12); - auto *timeline_scroll = new Fl_Scroll(20, 188, 956, 350); - sequencer_ = new SequencerWidget(20, 188, project_.get(), seek, this); + auto *timeline_scroll = new Fl_Scroll(228, 206, 1032, 452); + sequencer_ = new SequencerWidget(228, 206, project_.get(), seek, this); timeline_scroll->end(); sequencer_->before_edit = before_arrangement_edit; menu->add("Edit/Duplicate clip",FL_CTRL+'d',[](Fl_Widget *, void *c) { (void)static_cast(c)->duplicate_selection(); },sequencer_); @@ -214,9 +217,9 @@ MainWindow::MainWindow(bool prompt_unsaved) sequencer_); sequencer->resizable(timeline_scroll); sequencer->end(); - auto *mixer = new Fl_Group(8, 140, 984, 444, "Mixer"); + auto *mixer = new Fl_Group(216, 158, 1056, 546, "Mixer"); mixer->begin(); - mixer_ = new MixerWidget(20, 150, 956, 420, project_.get(), before_arrangement_edit, this); + mixer_ = new MixerWidget(228, 168, 1032, 522, project_.get(), before_arrangement_edit, this); mixer_->callback( [](Fl_Widget *, void *c) { static_cast(c)->mark_dirty(); @@ -224,58 +227,58 @@ MainWindow::MainWindow(bool prompt_unsaved) this); mixer->resizable(mixer_); mixer->end(); - auto *graph = new Fl_Group(8, 140, 984, 444, "Modular Graph"); + auto *graph = new Fl_Group(216, 158, 1056, 546, "Modular Graph"); graph->begin(); - graph_state_ = new Fl_Box(20, 182, 956, 24, "Graph: offline"); + graph_state_ = new Fl_Box(228, 200, 956, 24, "Graph: offline"); graph_state_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); graph_state_->labelsize(12); auto *feedback = - new Fl_Box(20, 548, 956, 28, + new Fl_Box(228, 666, 1032, 28, "Add nodes. Drag output to input to connect; click a node or wire then Delete. Scroll to pan."); feedback->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE | FL_ALIGN_WRAP); feedback->labelsize(12); - auto *scroll = new Fl_Scroll(20, 212, 956, 328); - graph_widget_ = new GraphWidget(20, 212, 2000, 4000, xtc_project_graph(project_.get()), edit_graph, this, feedback); + auto *scroll = new Fl_Scroll(228, 230, 1032, 430); + graph_widget_ = new GraphWidget(228, 230, 2000, 4000, xtc_project_graph(project_.get()), edit_graph, this, feedback); scroll->end(); menu->add("View/Arrange graph by signal flow", 0, [](Fl_Widget *, void *c) { (void)static_cast(c)->auto_layout(); }, graph_widget_); menu->add("Edit/Insert unity gain in selected wire", 0, [](Fl_Widget *, void *c) { (void)static_cast(c)->insert_gain(); }, graph_widget_); - auto *oscillator = new Fl_Button(20, 150, 130, 26, "Add oscillator"); + auto *oscillator = new Fl_Button(228, 168, 130, 26, "Add oscillator"); oscillator->callback( [](Fl_Widget *, void *context) { (void)static_cast(context)->add_node(XTC_NODE_OSCILLATOR); }, graph_widget_); - auto *gain = new Fl_Button(158, 150, 110, 26, "Add gain"); + auto *gain = new Fl_Button(366, 168, 110, 26, "Add gain"); gain->callback( [](Fl_Widget *, void *context) { (void)static_cast(context)->add_node(XTC_NODE_GAIN); }, graph_widget_); - auto *meter = new Fl_Button(276, 150, 110, 26, "Add meter"); + auto *meter = new Fl_Button(484, 168, 110, 26, "Add meter"); meter->callback( [](Fl_Widget *, void *context) { (void)static_cast(context)->add_node(XTC_NODE_METER); }, graph_widget_); - auto *output = new Fl_Button(394, 150, 110, 26, "Add output"); + auto *output = new Fl_Button(602, 168, 110, 26, "Add output"); output->callback( [](Fl_Widget *, void *context) { (void)static_cast(context)->add_node(XTC_NODE_OUTPUT); }, graph_widget_); - auto *audio = new Fl_Button(650, 150, 130, 26, "Add audio source"); + auto *audio = new Fl_Button(858, 168, 130, 26, "Add audio source"); audio->callback( [](Fl_Widget *, void *context) { (void)static_cast(context)->add_node(XTC_NODE_AUDIO); }, graph_widget_); - auto *instrument = new Fl_Button(788, 150, 130, 26, "Add instrument"); + auto *instrument = new Fl_Button(996, 168, 130, 26, "Add instrument"); instrument->callback( [](Fl_Widget *, void *c) { (void)static_cast(c)->add_node(XTC_NODE_INSTRUMENT); }, graph_widget_); - auto *remove = new Fl_Button(512, 150, 130, 26, "Delete selection"); + auto *remove = new Fl_Button(720, 168, 130, 26, "Delete selection"); remove->callback( [](Fl_Widget *, void *context) { (void)static_cast(context)->delete_selected(); @@ -290,21 +293,63 @@ MainWindow::MainWindow(bool prompt_unsaved) menu->add("View/Modular Graph", FL_CTRL + '3', view_callback, graph); menu->add("Help/About", 0, about_callback); - status_ = new Fl_Box(12, 594, 976, 26, "JACK: not connected"); + auto *sidebar = new Fl_Group(12,126,192,578); + 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); + auto *midi_track = new Fl_Button(24,176,168,34,"New MIDI track"); + midi_track->tooltip("Create a track and one-bar MIDI clip at the cursor; undo as one edit."); + midi_track->callback([](Fl_Widget *, void *c) { auto *w = static_cast(c); + xtc_result r = w->create_track(true); if (r != XTC_OK) w->report_error("Create MIDI track",r); },this); + auto *audio_track = new Fl_Button(24,218,168,34,"New audio track"); + audio_track->tooltip("Create an empty audio track, ready for WAV import or recording."); + audio_track->callback([](Fl_Widget *, void *c) { auto *w = static_cast(c); + xtc_result r = w->create_track(false); if (r != XTC_OK) w->report_error("Create audio track",r); },this); + menu->add("Track/New MIDI track",FL_CTRL+FL_SHIFT+'m',midi_track->callback(),this); + menu->add("Track/New audio track",FL_CTRL+FL_SHIFT+'a',audio_track->callback(),this); + auto *import = new Fl_Button(24,260,168,34,"Import WAV"); + import->callback(audio_file_callback,this); + auto *load = new Fl_Button(24,302,168,34,"Load instrument plugin"); + load->callback(instrument_callback,this); + auto *editor = new Fl_Button(24,344,168,34,"Edit MIDI notes"); + editor->callback(piano_callback,this); + auto *selection_title = new Fl_Box(24,404,168,24,"SELECTED TRACK"); + selection_title->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); + selection_info_ = new Fl_Box(24,438,168,170,"Select a track to inspect it."); + selection_info_->align(FL_ALIGN_TOP_LEFT | FL_ALIGN_INSIDE | FL_ALIGN_WRAP); + auto *tip = new Fl_Box(24,620,168,64,"Ctrl+1 Arrange\nCtrl+2 Mixer\nCtrl+3 Routing"); + tip->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); + sidebar->end(); + status_ = new Fl_Box(12,714,1256,26,"JACK: not connected"); status_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); - status_->labelsize(12); - error_ = new Fl_Box(12, 624, 976, 44, ""); + error_ = new Fl_Box(12,744,1256,44,""); error_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE | FL_ALIGN_WRAP); - error_->labelcolor(fl_rgb_color(255, 155, 120)); - error_->labelsize(12); + xtc_ui::style(this); + color(xtc_ui::background()); + create_title->labelcolor(xtc_ui::muted()); selection_title->labelcolor(xtc_ui::muted()); + tip->labelcolor(xtc_ui::muted()); hint->labelcolor(xtc_ui::muted()); + transport_position_->box(xtc_ui::flat); transport_position_->color(xtc_ui::background()); + transport_position_->labelfont(FL_COURIER_BOLD); transport_position_->labelsize(18); + tempo_display_->labelcolor(xtc_ui::muted()); + midi_track->color(fl_rgb_color(45,91,86)); + play_->labelcolor(xtc_ui::accent()); record_->labelcolor(fl_rgb_color(245,132,142)); + error_->labelcolor(fl_rgb_color(255,166,139)); end(); resizable(tabs_); - size_range(800, 540); + size_range(1220, 720); callback(close_callback, this); open_backend(); Fl::add_timeout(0.1, timer_callback, this); } +void MainWindow::draw() +{ + // FLTK may reload the platform scheme when a window first becomes visible. + // Keep built-in arrows, popups and choice fields flat as well as our boxes. + xtc_ui::configure_boxes(); + Fl_Double_Window::draw(); +} + /** * @brief Removes the status timer and releases backend and recording state. * @@ -554,6 +599,7 @@ void MainWindow::update_history_controls() } void MainWindow::refresh_status() { + refresh_workspace(); update_history_controls(); if (mixer_ && !ready_) { mixer_->peaks(0, 0); mixer_->track_peaks(0, 0); } bool can_play = false, can_record = false; @@ -1246,7 +1292,8 @@ void MainWindow::audio_file_callback(Fl_Widget *widget, void *context) { auto *window = static_cast(context); auto *menu = dynamic_cast(widget); - const char *action = menu ? menu->mvalue()->label() : "Record WAV"; + const char *action = menu ? menu->mvalue()->label() : + (widget && widget->label() && !std::strcmp(widget->label(),"Import WAV") ? "Import WAV" : "Record WAV"); bool recording = std::strcmp(action, "Record WAV") == 0; bool relink = std::strcmp(action, "Relink selected audio") == 0; if (window->recorder_) { @@ -1316,6 +1363,7 @@ xtc_result MainWindow::open_piano_roll() this); scroll->end(); piano_window_->end(); + xtc_ui::style(piano_window_.get()); piano_window_->resizable(scroll); scroll->scroll_to(0, 60 * 16); } else @@ -1428,6 +1476,49 @@ void MainWindow::history_callback(Fl_Widget *widget, void *context) window->report_error("Undo/redo failed", r); } +xtc_result MainWindow::create_track(bool midi) +{ + if (recorder_) return XTC_ERR_INVALID_STATE; + const uint64_t position = sequencer_->position(); + xtc_project *candidate = nullptr; + uint64_t track = UINT64_MAX, clip = UINT64_MAX; + xtc_result r = xtc_project_clone(project_.get(),&candidate); + if (r == XTC_OK) r = xtc_project_add_track(candidate,midi ? "MIDI track" : "Audio track",&track); + if (r == XTC_OK && midi) r = xtc_project_add_midi_clip(candidate,track,position,3840,&clip); + if (r == XTC_OK) r = xtc_history_reserve(history_.get()); + if (r == XTC_OK) r = install_project(candidate,false); + if (r != XTC_OK) xtc_project_destroy(candidate); + else { + sequencer_->select(track,clip); + mixer_->select_track(track); tabs_->value(tabs_->child(0)); mark_dirty(); + } + return r; +} +void MainWindow::refresh_workspace() +{ + char position[64], tempo[40], info[512]; + uint64_t tick = sequencer_->position(); + std::snprintf(position,sizeof(position),"%03llu : %u : %03u", + static_cast(tick/3840+1),static_cast((tick/960)%4+1), + static_cast(tick%960)); + set_label(transport_position_,position); + std::snprintf(tempo,sizeof(tempo),"%.1f BPM",xtc_project_tempo(project_.get())/1000.0); + set_label(tempo_display_,tempo); + size_t tracks = 0, clips = 0; xtc_project_get_counts(project_.get(),&tracks,&clips); + for (size_t i = 0; i < tracks; ++i) { + xtc_track_info track{}; xtc_project_get_track(project_.get(),i,&track); + if (track.id != sequencer_->selected_track()) continue; + xtc_plugin_spec spec{}; + bool plugin = xtc_project_get_instrument(project_.get(),track.id,&spec) == XTC_OK; + std::snprintf(info,sizeof(info),"%s\n\n%s\nGain %.2f | Pan %.2f\n%s%s\n\n%s", + track.name,track.is_bus ? "BUS" : "TRACK",static_cast(track.gain),static_cast(track.pan), + track.mute ? "Muted " : "",track.solo ? "Solo" : "",plugin ? spec.label : "Built-in MIDI sine"); + set_label(selection_info_,info); return; + } + std::snprintf(info,sizeof(info),"No track selected\n\n%zu tracks / buses\n%zu clips\n\nCreate a MIDI track to start drawing notes, or import a WAV file.",tracks,clips); + set_label(selection_info_,info); +} + xtc_result MainWindow::assign_instrument(uint64_t track, const char *path, unsigned int index) { if (recorder_) return XTC_ERR_INVALID_STATE; @@ -1456,8 +1547,8 @@ xtc_result MainWindow::select_live_instrument(uint64_t track) void MainWindow::instrument_callback(Fl_Widget *widget, void *context) { auto *w = static_cast(context); - auto *menu = static_cast(widget); - const char *label = menu->mvalue()->label(); + auto *menu = dynamic_cast(widget); + const char *label = menu ? menu->mvalue()->label() : "Load instrument plugin"; uint64_t track = w->sequencer_->selected_track(); xtc_result r; if (std::strcmp(label,"Use built-in instrument") == 0) r = w->assign_instrument(track,nullptr,0); diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index 85e9947..0f79d9d 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -21,6 +21,10 @@ class Fl_Menu_Bar; class MainWindow : public Fl_Double_Window { public: + /* One undoable candidate edit. MIDI also adds a one-bar clip at the cursor. + * Preparation stops/resets transport; recording and failed candidates retain + * the current document. No engine/model changes happen in audio callbacks. */ + xtc_result create_track(bool midi); /* Control-thread plugin commands, transactional model adoption. */ xtc_result assign_instrument(uint64_t track, const char *path, unsigned int index); xtc_result select_live_instrument(uint64_t track); @@ -171,6 +175,11 @@ class MainWindow : public Fl_Double_Window { xtc_result return_to_start(); private: + void draw() override; + void refresh_workspace(); + Fl_Box *transport_position_ = nullptr; + Fl_Box *tempo_display_ = nullptr; + Fl_Box *selection_info_ = nullptr; MixerWidget *mixer_ = nullptr; Fl_Menu_Bar *menu_ = nullptr; void update_history_controls(); diff --git a/src/gui/mixer_widget.cpp b/src/gui/mixer_widget.cpp index 2c8b878..f391d8a 100644 --- a/src/gui/mixer_widget.cpp +++ b/src/gui/mixer_widget.cpp @@ -1,3 +1,4 @@ +#include "theme.hpp" #include "mixer_widget.hpp" #include "xtc/xtc_control.h" #include @@ -95,7 +96,7 @@ class AutomationLane : public Fl_Widget { static_cast(static_cast(tick - a.tick) / static_cast(it->tick - a.tick)) * (it->value - a.value); }; - fl_color(fl_rgb_color(105, 200, 150)); + fl_color(xtc_ui::accent()); if (!points.empty()) for (int px = 1; px < w(); ++px) { uint64_t a = @@ -182,13 +183,13 @@ MixerWidget::MixerWidget(int x, int y, int w, int h, xtc_project *p, BeforeEdit tracks_ = new Fl_Choice(x + 70, y + 8, 240, 26, "Track"); tracks_->callback(select, this); gain_ = new Fl_Value_Slider(x + 70, y + 48, 320, 28, "Gain"); - gain_->type(FL_HOR_NICE_SLIDER); + gain_->type(FL_HOR_FILL_SLIDER); gain_->bounds(0, 4); gain_->step(0.01); gain_->when(FL_WHEN_RELEASE); gain_->callback(control, this); pan_ = new Fl_Value_Slider(x + 480, y + 48, 320, 28, "Pan"); - pan_->type(FL_HOR_NICE_SLIDER); + pan_->type(FL_HOR_SLIDER); pan_->bounds(-1, 1); pan_->step(0.01); pan_->when(FL_WHEN_RELEASE); diff --git a/src/gui/piano_roll.cpp b/src/gui/piano_roll.cpp index e477d78..1a203c3 100644 --- a/src/gui/piano_roll.cpp +++ b/src/gui/piano_roll.cpp @@ -1,3 +1,4 @@ +#include "theme.hpp" #include "piano_roll.hpp" #include #include @@ -210,7 +211,7 @@ void PianoRollWidget::draw() int a = std::max(cx, left), b = std::min(cx + cw, right); if (a >= b || py + row_height < cy || py > cy + ch) continue; - fl_color(all_ || n.id == selected_ ? fl_rgb_color(230, 190, 80) + fl_color(all_ || n.id == selected_ ? xtc_ui::accent() : fl_rgb_color(50, static_cast(70 + n.velocity), 110)); fl_rectf(a, py + 1, b - a, row_height - 3); } diff --git a/src/gui/plugin_dialog.cpp b/src/gui/plugin_dialog.cpp index b44f19a..9421c05 100644 --- a/src/gui/plugin_dialog.cpp +++ b/src/gui/plugin_dialog.cpp @@ -1,3 +1,4 @@ +#include "theme.hpp" #include "plugin_dialog.hpp" #include #include @@ -19,7 +20,7 @@ struct Dialog : Fl_Double_Window { Dialog(int width, int height, const char *title) : Fl_Double_Window(width,height,title) { set_modal(); } static void accept(Fl_Widget *, void *data) { auto *d = static_cast(data); d->accepted = true; d->hide(); } static void cancel(Fl_Widget *, void *data) { static_cast(data)->hide(); } - bool run() { callback(cancel,this); end(); show(); while (shown()) Fl::wait(); return accepted; } + bool run() { callback(cancel,this); end(); xtc_ui::style(this); show(); while (shown()) Fl::wait(); return accepted; } }; } bool xtc_choose_plugin(const char *path, double rate, unsigned int &index, bool instrument) diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index c54fc31..b5782dd 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -1,4 +1,5 @@ #include "sequencer_widget.hpp" +#include "theme.hpp" #include "xtc/xtc_midi.h" #include #include @@ -71,7 +72,7 @@ void SequencerWidget::set_project(xtc_project *project, bool reset) */ uint64_t SequencerWidget::tick(int px) const { - return std::min(XTC_PROJECT_MAX_TICK, static_cast(std::max(0, px - x() - 120)) * 960U / + return std::min(XTC_PROJECT_MAX_TICK, static_cast(std::max(0, px - x() - 180)) * 960U / static_cast(scale_) / grid_ * grid_); } @@ -88,7 +89,7 @@ void SequencerWidget::extent() xtc_project_get_clip(project_, i, &c); end = std::max(end, c.start_tick + c.length_tick + 3840U); } - size(static_cast(end * static_cast(scale_) / 960U + 140U), + size(static_cast(end * static_cast(scale_) / 960U + 200U), std::max(400, static_cast(tracks) * 56 + 100)); if (parent()) parent()->redraw(); @@ -223,62 +224,95 @@ void SequencerWidget::zoom(int direction) */ void SequencerWidget::draw() { - fl_push_clip(x(), y(), w(), h()); - fl_color(fl_rgb_color(35, 38, 43)); - fl_rectf(x(), y(), w(), h()); + fl_push_clip(x(),y(),w(),h()); int cx, cy, cw, ch; - fl_clip_box(x(), y(), w(), h(), cx, cy, cw, ch); - fl_font(FL_HELVETICA, 12); - int first = std::max(0, (cx - x() - 120) / (scale_ * 4)); - int last = (cx + cw - x() - 120) / (scale_ * 4) + 1; - for (int bar = first; bar <= last; ++bar) { - int px = x() + 120 + bar * scale_ * 4; - fl_color(fl_rgb_color(65, 68, 74)); - fl_line(px, y(), px, y() + h()); - char label[32]; - std::snprintf(label, sizeof(label), "%d", bar + 1); - fl_color(FL_WHITE); - fl_draw(label, px + 4, y() + 19); - } - char tempo[32]; - std::snprintf(tempo, sizeof(tempo), "%.3g BPM | 4/4", xtc_project_tempo(project_) / 1000.0); - fl_color(FL_WHITE); - fl_draw(tempo, x() + 4, y() + 19); - size_t tracks, clips; - xtc_project_get_counts(project_, &tracks, &clips); + fl_clip_box(x(),y(),w(),h(),cx,cy,cw,ch); + fl_color(xtc_ui::background()); fl_rectf(cx,cy,cw,ch); + size_t tracks = 0, clips = 0, notes = 0; + xtc_project_get_counts(project_,&tracks,&clips); xtc_project_note_count(project_,¬es); for (size_t i = 0; i < tracks; ++i) { - xtc_track_info t{}; - xtc_project_get_track(project_, i, &t); - int py = y() + 28 + static_cast(i) * 56; - fl_color(t.id == track_ ? fl_rgb_color(70, 90, 110) : fl_rgb_color(48, 51, 57)); - fl_rectf(x(), py, 118, 55); - fl_color(FL_WHITE); - fl_draw(t.name, x() + 6, py + 32); + int py = y()+28+static_cast(i)*56; + if (py+56 < cy || py > cy+ch) continue; + fl_color(i%2 ? fl_rgb_color(30,34,40) : fl_rgb_color(33,37,43)); + fl_rectf(x()+180,py,w()-180,55); + } + fl_font(FL_HELVETICA,11); + int first = std::max(0,(cx-x()-180)/scale_); + int last = (cx+cw-x()-180)/scale_+1; + for (int beat = first; beat <= last; ++beat) { + int px = x()+180+beat*scale_; + fl_color(beat%4 ? fl_rgb_color(43,48,56) : fl_rgb_color(65,72,83)); + fl_line(px,y()+28,px,y()+h()); + if (beat%4 == 0) { + char label[32]; std::snprintf(label,sizeof(label),"%d",beat/4+1); + fl_color(xtc_ui::muted()); fl_draw(label,px+6,y()+19); + } + } + fl_color(xtc_ui::muted()); fl_draw("TRACKS / 4:4",x()+12,y()+19); + for (size_t i = 0; i < tracks; ++i) { + xtc_track_info t{}; xtc_project_get_track(project_,i,&t); + int py = y()+28+static_cast(i)*56; + if (py+56 < cy || py > cy+ch) continue; + Fl_Color color = xtc_ui::track(t.id); + fl_color(t.id == track_ ? fl_rgb_color(49,62,70) : xtc_ui::panel()); + fl_rectf(x(),py,178,55); + fl_color(color); fl_rectf(x(),py,3,55); + fl_push_clip(x()+9,py,162,55); + fl_color(xtc_ui::text()); fl_font(FL_HELVETICA_BOLD,12); + fl_draw(t.name,x()+12,py+23); + char detail[64]; + std::snprintf(detail,sizeof(detail),"%02zu %s%s%s",i+1,t.is_bus ? "BUS" : "TRACK", + t.mute ? " MUTE" : "",t.solo ? " SOLO" : ""); + fl_font(FL_HELVETICA,10); fl_color(xtc_ui::muted()); fl_draw(detail,x()+12,py+42); + fl_pop_clip(); for (size_t j = 0; j < clips; ++j) { - xtc_clip_info c{}; - xtc_project_get_clip(project_, j, &c); - if (c.track_id != t.id) - continue; - int left = x() + 120 + static_cast(c.start_tick * static_cast(scale_) / 960U); - int right = left + std::max(4, static_cast(c.length_tick * static_cast(scale_) / 960U)); - int a = std::max(left, cx), b = std::min(right, cx + cw); - if (b <= a || py + 50 < cy || py > cy + ch) - continue; - fl_color(c.id == clip_ ? fl_rgb_color(100, 170, 205) : fl_rgb_color(65, 120, 150)); - fl_rectf(a, py + 5, b - a, 44); - fl_push_clip(a, py + 5, b - a, 44); - draw_waveform(c, left, py); - fl_color(FL_WHITE); - char label[96]; - std::snprintf(label,sizeof(label),"%s: %s",c.midi ? "MIDI" : c.asset_path[0] ? "Audio" : "Region",c.name); - fl_draw(label,std::max(a,left+6),py+31); + xtc_clip_info c{}; xtc_project_get_clip(project_,j,&c); + if (c.track_id != t.id) continue; + int left = x()+180+static_cast(c.start_tick*static_cast(scale_)/960U); + int right = left+std::max(4,static_cast(c.length_tick*static_cast(scale_)/960U)); + int a = std::max(left,cx), b = std::min(right,cx+cw); + if (b <= a) continue; + fl_color(fl_color_average(color,xtc_ui::background(),c.id == clip_ ? .75f : .45f)); + fl_rectf(a,py+5,b-a,46); + fl_color(c.id == clip_ ? xtc_ui::text() : color); fl_rect(a,py+5,b-a,46); + fl_push_clip(a+1,py+6,std::max(0,b-a-2),44); + if (c.midi) { + /* Real note positions, pitch and duration; no placeholder waveform. */ + unsigned int low = 127, high = 0; + for (size_t n = 0; n < notes; ++n) { + xtc_note_info note{}; xtc_project_get_note(project_,n,¬e); + if (note.clip_id == c.id) { low = std::min(low,note.pitch); high = std::max(high,note.pitch); } + } + unsigned int range = high >= low ? std::max(12U,high-low) : 12U; + fl_color(fl_color_average(color,FL_WHITE,.55f)); + for (size_t n = 0; n < notes; ++n) { + xtc_note_info note{}; xtc_project_get_note(project_,n,¬e); + if (note.clip_id != c.id) continue; + uint64_t begin = std::max(note.start_tick,c.offset_tick); + uint64_t end = std::min(note.start_tick+note.length_tick,c.offset_tick+c.length_tick); + if (begin >= end) continue; + int nx = left+static_cast((begin-c.offset_tick)*static_cast(scale_)/960U); + int nw = std::max(2,static_cast((end-begin)*static_cast(scale_)/960U)); + fl_rectf(nx,py+26+static_cast((high-note.pitch)*18/range),nw,2); + } + } else draw_waveform(c,left,py); + fl_color(fl_color_average(color,xtc_ui::background(),.35f)); fl_rectf(a+1,py+6,std::max(0,b-a-2),15); + fl_font(FL_HELVETICA,11); fl_color(xtc_ui::text()); + fl_draw(c.name,left+7,py+18); fl_pop_clip(); } } - int playhead = x() + 120 + static_cast(cursor_ * static_cast(scale_) / 960U); - if (playhead >= cx && playhead <= cx + cw) { - fl_color(FL_RED); - fl_line(playhead, y(), playhead, y() + h()); + if (!tracks) { + fl_font(FL_HELVETICA_BOLD,18); fl_color(xtc_ui::text()); + fl_draw("Your next track starts here",x()+220,y()+105); + fl_font(FL_HELVETICA,13); fl_color(xtc_ui::muted()); + fl_draw("Create a MIDI track or import audio from the panel on the left.",x()+220,y()+135); + fl_draw("Arrange clips here. Open the piano roll to draw and edit MIDI notes.",x()+220,y()+161); + } + int playhead = x()+180+static_cast(cursor_*static_cast(scale_)/960U); + if (playhead >= cx && playhead <= cx+cw) { + fl_color(xtc_ui::accent()); fl_line(playhead,y(),playhead,y()+h()); + fl_polygon(playhead-4,y(),playhead+4,y(),playhead,y()+6); } fl_pop_clip(); } @@ -316,7 +350,7 @@ int SequencerWidget::handle(int event) for (size_t j = clips; j > 0; --j) { xtc_clip_info c{}; xtc_project_get_clip(project_, j - 1, &c); - int left = x() + 120 + static_cast(c.start_tick * static_cast(scale_) / 960U); + int left = x() + 180 + static_cast(c.start_tick * static_cast(scale_) / 960U); int right = left + std::max(4, static_cast(c.length_tick * static_cast(scale_) / 960U)); if (c.track_id == track_ && Fl::event_x() >= left && Fl::event_x() < right) { clip_ = c.id; diff --git a/src/gui/theme.hpp b/src/gui/theme.hpp new file mode 100644 index 0000000..345dd7c --- /dev/null +++ b/src/gui/theme.hpp @@ -0,0 +1,70 @@ +#ifndef XTC_GUI_THEME_HPP +#define XTC_GUI_THEME_HPP + +#include +#include +#include +#include +#include +#include +#include + +/* Shared flat palette. No gradients, shadows, textures or external assets. */ +namespace xtc_ui { +constexpr Fl_Boxtype flat = static_cast(FL_FREE_BOXTYPE); +constexpr Fl_Boxtype pressed = static_cast(FL_FREE_BOXTYPE+1); +inline Fl_Color background() { return fl_rgb_color(25,28,33); } +inline Fl_Color panel() { return fl_rgb_color(34,38,44); } +inline Fl_Color border() { return fl_rgb_color(58,64,73); } +inline Fl_Color text() { return fl_rgb_color(231,235,240); } +inline Fl_Color muted() { return fl_rgb_color(163,174,188); } +inline Fl_Color accent() { return fl_rgb_color(92,211,184); } +inline Fl_Color track(uint64_t id) { + static const unsigned char colors[][3] = {{74,155,184},{111,143,206},{160,123,191}, + {74,163,139},{191,145,78},{183,113,139}}; + const auto &c = colors[id % 6]; return fl_rgb_color(c[0],c[1],c[2]); +} +inline void flat_box(int x, int y, int w, int h, Fl_Color color) { + fl_color(color); fl_rectf(x,y,w,h); + fl_color(border()); fl_rect(x,y,w,h); +} +inline void pressed_box(int x, int y, int w, int h, Fl_Color color) { + flat_box(x,y,w,h,fl_darker(color)); +} +inline void configure_boxes() { + Fl::set_boxtype(flat,flat_box,1,1,2,2); + Fl::set_boxtype(pressed,pressed_box,1,1,2,2); + Fl::set_boxtype(FL_UP_BOX,flat_box,1,1,2,2); + Fl::set_boxtype(FL_DOWN_BOX,pressed_box,1,1,2,2); + Fl::set_boxtype(FL_THIN_UP_BOX,flat_box,1,1,2,2); + Fl::set_boxtype(FL_THIN_DOWN_BOX,pressed_box,1,1,2,2); + Fl::set_boxtype(FL_ENGRAVED_BOX,flat_box,1,1,2,2); + Fl::set_boxtype(FL_BORDER_BOX,flat_box,1,1,2,2); +} +inline void initialize() { + static bool initialized = false; + if (initialized) return; + initialized = true; + Fl::background(34,38,44); Fl::background2(25,28,33); Fl::foreground(231,235,240); + Fl::set_color(FL_SELECTION_COLOR,53,116,111); + configure_boxes(); + Fl::scrollbar_size(12); +} +inline void style(Fl_Widget *widget) { + widget->labelfont(FL_HELVETICA); widget->labelsize(12); widget->labelcolor(text()); + widget->color(panel()); widget->selection_color(fl_rgb_color(53,116,111)); + if (widget->box() != FL_NO_BOX && widget->box() != FL_FLAT_BOX) widget->box(flat); + if (auto *button = dynamic_cast(widget)) { + button->box(flat); button->down_box(pressed); + button->color(fl_rgb_color(45,51,59)); + } + if (auto *group = dynamic_cast(widget)) { + for (int i = 0; i < group->children(); ++i) style(group->child(i)); + if (auto *scroll = dynamic_cast(group)) { + scroll->scrollbar.box(flat); scroll->hscrollbar.box(flat); + scroll->scrollbar.slider(FL_FLAT_BOX); scroll->hscrollbar.slider(FL_FLAT_BOX); + } + } +} +} +#endif diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index 5cc8b7f..115b920 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include /* Fake only the public C backend boundary. Real FLTK widgets, callbacks and @@ -315,8 +316,8 @@ int main(int argc, char **argv) CHECK(xtc_project_get_counts(sequence->project(), &track_count, &clip_count) == XTC_OK && track_count == 1 && clip_count == 1); xtc_clip_info region{}; CHECK(xtc_project_get_clip(sequence->project(), 0, ®ion) == XTC_OK); - gesture(sequence, 150, 50, 144, 0); // Move by three beats. - gesture(sequence, 265, 50, 48, 0); // Trim left edge by one beat. + gesture(sequence, 210, 50, 144, 0); // Move by three beats. + gesture(sequence, 325, 50, 48, 0); // Trim left edge by one beat. CHECK(xtc_project_get_clip(sequence->project(), 0, ®ion) == XTC_OK && region.start_tick == 3840 && region.length_tick == 2880 && region.offset_tick == 960); int width = sequence->w(); @@ -373,7 +374,7 @@ int main(int argc, char **argv) xtc_track_info t{}; xtc_project_get_track(sequence->project(), audio_row, &t); if (t.id == audio_clip.track_id) break; } - gesture(sequence, 121 + static_cast(audio_clip.start_tick*48/960), 50 + static_cast(audio_row)*56, 0, 0); + gesture(sequence, 181 + static_cast(audio_clip.start_tick*48/960), 50 + static_cast(audio_row)*56, 0, 0); CHECK(sequence->selected_clip() == audio_clip.id); CHECK(sequence->audio_properties(0.5f, 1, 1) == XTC_OK); sequence->position(audio_clip.start_tick+1); @@ -848,6 +849,49 @@ int main(int argc, char **argv) unlink("/tmp/xtc-gui-instrument.xtc"); window.do_callback(); } } + // Flat workspace creation is one history edit, and failed preparation keeps + // the current model. Exercise real sidebar buttons rather than mock actions. + reset(); + { + MainWindow window(false); window.show(); + auto *sequence = timeline(&window); + CHECK(sequence != nullptr); + auto *midi = find(&window,"New MIDI track"); + auto *audio = find(&window,"New audio track"); + CHECK(midi && audio && find(&window,"Import WAV") && find(&window,"Edit MIDI notes")); + press(midi); + size_t tracks = 0, clips = 0; + xtc_project_get_counts(sequence->project(),&tracks,&clips); + CHECK(tracks == 1 && clips == 1); + uint64_t track = sequence->selected_track(), clip = sequence->selected_clip(); + xtc_clip_info info{}; CHECK(xtc_project_find_clip(sequence->project(),clip,&info) == XTC_OK && info.midi); + CHECK(window.mixer()->selected_track() == track); + CHECK(window.open_piano_roll() == XTC_OK); + CHECK(window.undo(false) == XTC_OK); + xtc_project_get_counts(sequence->project(),&tracks,&clips); CHECK(tracks == 0 && clips == 0); + CHECK(window.undo(true) == XTC_OK); + CHECK(sequence->select(track,clip)); pump(); + CHECK(contains(&window,"MIDI track")); + press(audio); + xtc_project_get_counts(sequence->project(),&tracks,&clips); CHECK(tracks == 2 && clips == 1); + const xtc_project *before = sequence->project(); + reserve_result = XTC_ERR_OUT_OF_MEMORY; + CHECK(window.create_track(true) == XTC_ERR_OUT_OF_MEMORY && sequence->project() == before); + reserve_result = XTC_OK; submit_result = XTC_ERR_INVALID_STATE; + CHECK(window.create_track(false) == XTC_ERR_INVALID_STATE && sequence->project() == before); + submit_result = XTC_OK; + // Exercise the supported minimum and a larger workspace with real layout. + for (auto size : {std::pair{1220,720},{1600,1000}}) { + window.resize(window.x(),window.y(),size.first,size.second); pump(); + CHECK(midi->x() >= 0 && midi->x()+midi->w() <= sequence->parent()->x()); + CHECK(sequence->parent()->x()+sequence->parent()->w() <= window.w()); + CHECK(find(&window,"Play")->visible_r() && midi->visible_r()); + } + CHECK(window.save_project("/tmp/xtc-workspace.xtc") == XTC_OK); + CHECK(window.new_project() == XTC_OK && window.open_project("/tmp/xtc-workspace.xtc") == XTC_OK); + xtc_project_get_counts(sequence->project(),&tracks,&clips); CHECK(tracks == 2 && clips == 1); + unlink("/tmp/xtc-workspace.xtc"); window.do_callback(); + } if (failures != 0) return 1; std::printf("GUI controls/errors/views/timers/cleanup passed: %u opens, %u closes.\n", opens, closes); return 0; diff --git a/tests/integration/test_install.py b/tests/integration/test_install.py index acd71d8..9bd55d2 100644 --- a/tests/integration/test_install.py +++ b/tests/integration/test_install.py @@ -53,6 +53,8 @@ def main(): example = resources / 'xtc/examples/midi-demo.xtc' run([str(binaries / 'xtc-check'), '--inspect', str(example)]) assert (resources / 'xtc/examples/README.md').is_file() + run([str(binaries / 'xtc-check'), '--inspect', str(resources / 'xtc/examples/modern-session.xtc')]) + assert (docs / 'WORKSPACE.md').is_file() diagnose = subprocess.check_output([str(binaries / 'xtc-check'), '--diagnose'], text=True) assert 'Project formats: 1..12' in diagnose and 'JACK compiled:' in diagnose # Replacing a file symlink must not overwrite an unrelated target. diff --git a/tests/integration/test_workflow.cpp b/tests/integration/test_workflow.cpp index 2c63720..396f749 100644 --- a/tests/integration/test_workflow.cpp +++ b/tests/integration/test_workflow.cpp @@ -110,7 +110,7 @@ int main(int argc, char **argv) CHECK(window.piano_roll()); CHECK(window.piano_roll()->add_note(0, 960, 60) == XTC_OK); CHECK(window.piano_roll()->add_note(960, 960, 64) == XTC_OK); - gesture(*timeline, 150, 50, 96); // Move the clip two beats using its body. + gesture(*timeline, 210, 50, 96); // Move the clip two beats using its body. CHECK(xtc_project_get_clip(timeline->project(), 0, &arranged) == XTC_OK); CHECK(arranged.start_tick == 1920 && arranged.length_tick == 3840); CHECK(window.import_audio(wave.c_str()) == XTC_OK); @@ -137,7 +137,7 @@ int main(int argc, char **argv) size_t actual_nodes, actual_edges; CHECK(xtc_graph_get_counts(xtc_project_graph(timeline->project()), &actual_nodes, &actual_edges) == XTC_OK); CHECK(actual_nodes == nodes && actual_edges == edges); - gesture(*timeline, 216, 10, 0); // Ruler seeks two beats while stopped. + gesture(*timeline, 276, 10, 0); // Ruler seeks two beats while stopped. CHECK(timeline->position() == 1920); press(window, "Play"); pump(); CHECK(!button(window, "Play")->active() && button(window, "Pause")->active());