From cd2f87a8b38a5f7e0d709b0c382a72d530f4ff0c Mon Sep 17 00:00:00 2001 From: hanez Date: Fri, 6 Nov 2026 23:17:49 +0100 Subject: [PATCH] Added an alternative GUI based on JUCE; I like FLTK more... --- .gitignore | 1 + CMakeLists.txt | 2 + README.md | 5 +- cmake/Install.cmake | 1 + cmake/InstallDriver.cmake.in | 3 + cmake/JuceGui.cmake | 91 + docs/ARCHITECTURE.md | 16 +- docs/BUILD.md | 19 + docs/GUI.md | 6 +- docs/JUCE.md | 7 +- docs/LICENSES.md | 8 + docs/ROADMAP.md | 41 + examples/juce/CMakeLists.txt | 4 +- screenshots/ALL.md | 9 + screenshots/README.md | 17 + screenshots/juce-workstation.png | Bin 0 -> 93069 bytes src/gui/JUCE/README.md | 74 + src/gui/JUCE/command_palette.hpp | 100 + src/gui/JUCE/graph_widget.cpp | 645 +++++ src/gui/JUCE/graph_widget.hpp | 212 ++ src/gui/JUCE/insert_meter.hpp | 45 + src/gui/JUCE/main.cpp | 59 + src/gui/JUCE/main_window.cpp | 2856 ++++++++++++++++++++ src/gui/JUCE/main_window.hpp | 557 ++++ src/gui/JUCE/mixer_widget.cpp | 856 ++++++ src/gui/JUCE/mixer_widget.hpp | 202 ++ src/gui/JUCE/piano_roll.cpp | 455 ++++ src/gui/JUCE/piano_roll.hpp | 150 + src/gui/JUCE/plugin_dialog.cpp | 102 + src/gui/JUCE/plugin_dialog.hpp | 9 + src/gui/JUCE/plugin_library.hpp | 90 + src/gui/JUCE/preferences_dialog.hpp | 103 + src/gui/JUCE/sample_editor.cpp | 78 + src/gui/JUCE/sample_editor.hpp | 44 + src/gui/JUCE/sequencer_clip_widget.cpp | 78 + src/gui/JUCE/sequencer_clip_widget.hpp | 17 + src/gui/JUCE/sequencer_commands.cpp | 412 +++ src/gui/JUCE/sequencer_group_scope.hpp | 12 + src/gui/JUCE/sequencer_instruments.cpp | 63 + src/gui/JUCE/sequencer_ruler_widget.cpp | 66 + src/gui/JUCE/sequencer_ruler_widget.hpp | 13 + src/gui/JUCE/sequencer_track_widget.cpp | 146 + src/gui/JUCE/sequencer_track_widget.hpp | 41 + src/gui/JUCE/sequencer_velocity_widget.cpp | 188 ++ src/gui/JUCE/sequencer_velocity_widget.hpp | 26 + src/gui/JUCE/sequencer_waveforms.cpp | 59 + src/gui/JUCE/sequencer_widget.cpp | 382 +++ src/gui/JUCE/sequencer_widget.hpp | 260 ++ src/gui/JUCE/snap.hpp | 19 + src/gui/JUCE/theme.hpp | 65 + src/gui/JUCE/track_inserts.hpp | 206 ++ src/gui/JUCE/ui.cpp | 1234 +++++++++ src/gui/JUCE/ui.hpp | 647 +++++ src/gui/JUCE/velocity_lane.hpp | 73 + tests/integration/README.md | 26 + tests/integration/test_juce_playback.cpp | 240 ++ tools/install.py | 5 +- tools/install_layout.py | 1 + 58 files changed, 11136 insertions(+), 10 deletions(-) create mode 100644 cmake/JuceGui.cmake create mode 100644 screenshots/juce-workstation.png create mode 100644 src/gui/JUCE/README.md create mode 100644 src/gui/JUCE/command_palette.hpp create mode 100644 src/gui/JUCE/graph_widget.cpp create mode 100644 src/gui/JUCE/graph_widget.hpp create mode 100644 src/gui/JUCE/insert_meter.hpp create mode 100644 src/gui/JUCE/main.cpp create mode 100644 src/gui/JUCE/main_window.cpp create mode 100644 src/gui/JUCE/main_window.hpp create mode 100644 src/gui/JUCE/mixer_widget.cpp create mode 100644 src/gui/JUCE/mixer_widget.hpp create mode 100644 src/gui/JUCE/piano_roll.cpp create mode 100644 src/gui/JUCE/piano_roll.hpp create mode 100644 src/gui/JUCE/plugin_dialog.cpp create mode 100644 src/gui/JUCE/plugin_dialog.hpp create mode 100644 src/gui/JUCE/plugin_library.hpp create mode 100644 src/gui/JUCE/preferences_dialog.hpp create mode 100644 src/gui/JUCE/sample_editor.cpp create mode 100644 src/gui/JUCE/sample_editor.hpp create mode 100644 src/gui/JUCE/sequencer_clip_widget.cpp create mode 100644 src/gui/JUCE/sequencer_clip_widget.hpp create mode 100644 src/gui/JUCE/sequencer_commands.cpp create mode 100644 src/gui/JUCE/sequencer_group_scope.hpp create mode 100644 src/gui/JUCE/sequencer_instruments.cpp create mode 100644 src/gui/JUCE/sequencer_ruler_widget.cpp create mode 100644 src/gui/JUCE/sequencer_ruler_widget.hpp create mode 100644 src/gui/JUCE/sequencer_track_widget.cpp create mode 100644 src/gui/JUCE/sequencer_track_widget.hpp create mode 100644 src/gui/JUCE/sequencer_velocity_widget.cpp create mode 100644 src/gui/JUCE/sequencer_velocity_widget.hpp create mode 100644 src/gui/JUCE/sequencer_waveforms.cpp create mode 100644 src/gui/JUCE/sequencer_widget.cpp create mode 100644 src/gui/JUCE/sequencer_widget.hpp create mode 100644 src/gui/JUCE/snap.hpp create mode 100644 src/gui/JUCE/theme.hpp create mode 100644 src/gui/JUCE/track_inserts.hpp create mode 100644 src/gui/JUCE/ui.cpp create mode 100644 src/gui/JUCE/ui.hpp create mode 100644 src/gui/JUCE/velocity_lane.hpp create mode 100644 tests/integration/test_juce_playback.cpp diff --git a/.gitignore b/.gitignore index fecdb38..cac3fab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Build products /build/ +/build-juce-gui/ /cmake-build-*/ /CMakeUserPresets.json /*.o diff --git a/CMakeLists.txt b/CMakeLists.txt index 5e9be23..411f6cf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,6 +13,7 @@ find_package(Python3 3.12 REQUIRED COMPONENTS Interpreter) find_package(PkgConfig QUIET) option(XTC_WITH_JACK "Discover the optional JACK backend" ON) option(XTC_WITH_LADSPA "Enable LADSPA when its system header is available" ON) +option(XTC_BUILD_JUCE_GUI "Build the optional JUCE workstation frontend" OFF) option(XTC_BUILD_GUI "Build the FLTK application when FLTK is available" ON) option(XTC_ENABLE_LIVE_TESTS "Register tests requiring an existing JACK server" OFF) option(XTC_BUILD_JUCE "Build optional externally supplied JUCE examples" OFF) @@ -122,6 +123,7 @@ foreach(preview quality builtin) endforeach() include(cmake/Tests.cmake) include(cmake/Gui.cmake) +include(cmake/JuceGui.cmake) include(cmake/Developer.cmake) include(cmake/Install.cmake) if(XTC_BUILD_STEINBERG) diff --git a/README.md b/README.md index f9e199d..4b4c1a3 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ - **Modular engine first:** sequencer, instruments, effects, mixers and I/O are nodes in one processing graph. - **C core:** project model, graph, DSP and audio engine are implemented in C. +- **Optional JUCE GUI:** build `xtc-juce` with `-DXTC_BUILD_JUCE_GUI=ON -DXTC_JUCE_ROOT=/path/to/JUCE`. Keep or disable FLTK independently with `XTC_BUILD_GUI`. See [the JUCE frontend](src/gui/JUCE/README.md) for build/run instructions and SDK licensing. - **FLTK/C++ GUI:** C++ is confined to the graphical interface and calls the core through a C API. The sequencer coordinates nested ruler, track, header, clip and velocity widgets; see [GUI ownership](docs/ARCHITECTURE.md#sequencer-widget-ownership). - **Linux first:** keep backend-specific details behind an interface. - **Small increments:** build and test each milestone before adding features. @@ -87,7 +88,7 @@ The JACK backend has passed software-loopback testing; physical-device playback ## Build -Requirements: CMake 3.25+, a C99 compiler, Python 3.12+ and a build backend such as Ninja or Unix Makefiles. The optional GUI needs C++17 and FLTK; JACK and LADSPA development files enable their optional integrations. +Requirements: CMake 3.25+, a C99 compiler, Python 3.12+ and a build backend such as Ninja or Unix Makefiles. The default optional GUI needs C++17 and FLTK; a separately selectable JUCE frontend uses an external JUCE SDK; JACK and LADSPA development files enable their optional integrations. ```sh cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug @@ -107,7 +108,7 @@ make clean # clean products; keep the CMake cache Override `BUILD_DIR`, `BUILD_TYPE` or `JOBS` as needed, for example `make BUILD_DIR=build/release BUILD_TYPE=Release JOBS=8`. Pass additional options with `CMAKE_ARGS` and `CTEST_ARGS`. CMake remains the authoritative build system. -This builds `libxtc.a`, the headless `xtc-check`, tests, examples and the FLTK application when available. Live JACK tests are opt-in. Use `-DXTC_BUILD_GUI=OFF` for the C-only build, or the `debug`, `release`, `minimal` and `sanitize` presets for separate configurations. See [docs/BUILD.md](docs/BUILD.md) for options, focused tests, installation and IDE setup. [Phase 108](docs/ROADMAP.md#phase-108--complete-cmake-build-migration) records the CMake migration. +This builds `libxtc.a`, the headless `xtc-check`, tests, examples and the FLTK application when available. Live JACK tests are opt-in. Use `-DXTC_BUILD_GUI=OFF -DXTC_BUILD_JUCE_GUI=OFF` for the C-only build, or the `debug`, `release`, `minimal` and `sanitize` presets for separate configurations. See [docs/BUILD.md](docs/BUILD.md) for options, focused tests, installation and IDE setup. [Phase 108](docs/ROADMAP.md#phase-108--complete-cmake-build-migration) records the CMake migration. ## Run diff --git a/cmake/Install.cmake b/cmake/Install.cmake index f206289..4520d7f 100644 --- a/cmake/Install.cmake +++ b/cmake/Install.cmake @@ -1,5 +1,6 @@ # Retain the audited owned-file inventory and symlink-safe staging implementation. set(XTC_INSTALL_BUILD_DIR "${CMAKE_CURRENT_BINARY_DIR}" CACHE PATH "Directory containing built install inputs") +option(XTC_INSTALL_JUCE_GUI "Include the optional JUCE workstation in installation" ${XTC_BUILD_JUCE_GUI}) option(XTC_INSTALL_GUI "Include the GUI in installation" ${XTC_GUI_AVAILABLE}) set(XTC_INSTALL_LICENSEDIR "${CMAKE_INSTALL_DATAROOTDIR}/licenses/xtc" CACHE STRING "License install directory") set(XTC_INSTALL_EXAMPLESDIR "${CMAKE_INSTALL_DATAROOTDIR}/xtc/examples" CACHE STRING "Example install directory") diff --git a/cmake/InstallDriver.cmake.in b/cmake/InstallDriver.cmake.in index 0d98e1c..5021dbe 100644 --- a/cmake/InstallDriver.cmake.in +++ b/cmake/InstallDriver.cmake.in @@ -22,6 +22,9 @@ else() if("@XTC_INSTALL_GUI@") list(APPEND extra --gui) endif() + if("@XTC_INSTALL_JUCE_GUI@") + list(APPEND extra --juce-gui) + endif() set(jack) if("@JACK_FOUND@" AND "@XTC_WITH_JACK@") set(jack jack) diff --git a/cmake/JuceGui.cmake b/cmake/JuceGui.cmake new file mode 100644 index 0000000..dd016c1 --- /dev/null +++ b/cmake/JuceGui.cmake @@ -0,0 +1,91 @@ +if(NOT XTC_BUILD_JUCE_GUI) + return() +endif() +enable_language(CXX) +set(XTC_JUCE_ROOT "" CACHE PATH "External JUCE source checkout") +if(NOT TARGET juce::juce_gui_extra) + if(NOT EXISTS "${XTC_JUCE_ROOT}/modules/juce_gui_extra/juce_gui_extra.h") + message(FATAL_ERROR "XTC_BUILD_JUCE_GUI requires XTC_JUCE_ROOT pointing to an external JUCE SDK checkout") + endif() + set(JUCE_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + set(JUCE_BUILD_EXTRAS OFF CACHE BOOL "" FORCE) + add_subdirectory("${XTC_JUCE_ROOT}" juce-gui-sdk EXCLUDE_FROM_ALL) +endif() +message(STATUS "Optional JUCE frontend: JUCE is AGPLv3/commercial; review the SDK licence before distributing") +set(xtc_juce_sources main_window.cpp graph_widget.cpp sequencer_widget.cpp + sequencer_commands.cpp sequencer_track_widget.cpp sequencer_clip_widget.cpp + sequencer_velocity_widget.cpp sequencer_ruler_widget.cpp sequencer_instruments.cpp + sequencer_waveforms.cpp piano_roll.cpp mixer_widget.cpp plugin_dialog.cpp sample_editor.cpp ui.cpp) +list(TRANSFORM xtc_juce_sources PREPEND "${PROJECT_SOURCE_DIR}/src/gui/JUCE/") +add_library(xtc-juce-gui STATIC ${xtc_juce_sources}) +xtc_warnings(xtc-juce-gui) +target_compile_definitions(xtc-juce-gui PUBLIC JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0 JUCE_MODAL_LOOPS_PERMITTED=1 JUCE_GLOBAL_MODULE_SETTINGS_INCLUDED=1 JUCE_STANDALONE_APPLICATION=1 + "$<$:DEBUG=1;_DEBUG=1>" "$<$>:_NDEBUG=1>") +target_link_libraries(xtc-juce-gui PUBLIC xtc-core PRIVATE juce::juce_gui_extra) +target_include_directories(xtc-juce-gui SYSTEM PUBLIC "${XTC_JUCE_ROOT}/modules") +juce_add_gui_app(xtc-juce PRODUCT_NAME "xtc JUCE" COMPANY_NAME xtc BUNDLE_ID org.xtc.workstation) +set_target_properties(xtc-juce PROPERTIES OUTPUT_NAME xtc-juce RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}") +target_sources(xtc-juce PRIVATE src/gui/JUCE/main.cpp) +target_link_libraries(xtc-juce PRIVATE xtc-juce-gui) +xtc_warnings(xtc-juce) +add_custom_target(gui-juce DEPENDS xtc-juce) +if(BUILD_TESTING) + find_program(XTC_JUCE_XVFB_RUN xvfb-run) + # Exercise the same C boundary, editing workflows and fault injection as FLTK. + # Adapt toolkit names/initialisation and show the inspector before native focus. + file(READ "${PROJECT_SOURCE_DIR}/tests/integration/test_gui.cpp" juce_test) + string(REGEX REPLACE "#include ]+>" "#include \"ui.hpp\"" juce_test "${juce_test}") + string(REPLACE "../../src/gui/" "${PROJECT_SOURCE_DIR}/src/gui/JUCE/" juce_test "${juce_test}") + string(REPLACE "Fl_" "Ui" juce_test "${juce_test}") + string(REPLACE "Fl::" "Ui::" juce_test "${juce_test}") + string(REPLACE "FL_" "UI_" juce_test "${juce_test}") + string(REPLACE "int main(int argc, char **argv)\n{" "int main(int argc, char **argv)\n{\n UiInitialiser initialise;" juce_test "${juce_test}") + # JUCE requires a visible component before native keyboard focus is requested. + string(REPLACE "if(gain){Ui::focus(&gain->input);" "if(gain){window.show_track_inspector(true);Ui::focus(&gain->input);" juce_test "${juce_test}") + # Native TextEditor commits on blur; unwind that additional edit before + # the shared test checks undo of its earlier explicit gain edit. + string(REPLACE "Ui::focus(nullptr);}" "Ui::focus(nullptr);pump();xtc_track_info blurred{};xtc_project_get_track(seq->project(),0,&blurred);if(blurred.gain==.123f)CHECK(window.undo()==XTC_OK);}" juce_test "${juce_test}") + file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/test_juce_gui.cpp" CONTENT "${juce_test}") + add_executable(test_juce_gui "${CMAKE_CURRENT_BINARY_DIR}/test_juce_gui.cpp") + target_include_directories(test_juce_gui PRIVATE "${PROJECT_SOURCE_DIR}/src/gui/JUCE") + target_link_libraries(test_juce_gui PRIVATE xtc-juce-gui) + xtc_wrap(test_juce_gui xtc_history_reserve) + xtc_warnings(test_juce_gui) + if(XTC_JUCE_XVFB_RUN) + foreach(pair "editing|" "sequencer|SEQUENCER_COMPONENTS" "midi|MIDI_WORKFLOWS" "samples|SAMPLE_EDITOR" "creative|CREATIVE" "next-daw|NEXT_DAW") + string(REPLACE "|" ";" pair "${pair}") + list(GET pair 0 name) + list(GET pair 1 env) + add_test(NAME juce-gui-${name} COMMAND ${XTC_JUCE_XVFB_RUN} -a -s "-screen 0 1440x960x24" $) + set_tests_properties(juce-gui-${name} PROPERTIES WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" LABELS "gui;juce" RUN_SERIAL TRUE) + if(env) + set_property(TEST juce-gui-${name} PROPERTY ENVIRONMENT "XTC_TEST_${env}_ONLY=1") + endif() + endforeach() + endif() +endif() +if(BUILD_TESTING AND JACK_FOUND AND XTC_WITH_JACK) + add_executable(test_juce_playback tests/integration/test_juce_playback.cpp) + target_link_libraries(test_juce_playback PRIVATE xtc-juce-gui) + xtc_warnings(test_juce_playback) + if(XTC_ENABLE_LIVE_TESTS AND XTC_JUCE_XVFB_RUN) + add_test(NAME juce-playback COMMAND ${XTC_JUCE_XVFB_RUN} -a -s "-screen 0 1440x960x24" $) + set_tests_properties(juce-playback PROPERTIES WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" LABELS "live;juce" RUN_SERIAL TRUE) + endif() +endif() +if(BUILD_TESTING AND XTC_JUCE_XVFB_RUN) + function(xtc_juce_gui_variant name) + add_test(NAME juce-gui-${name} COMMAND ${XTC_JUCE_XVFB_RUN} -a -s "-screen 0 1440x960x24" $) + set_tests_properties(juce-gui-${name} PROPERTIES WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" LABELS "gui;juce" RUN_SERIAL TRUE ENVIRONMENT "${ARGN}") + endfunction() + xtc_juce_gui_variant(modern-editing "XTC_TEST_MODERN_EDITING_ONLY=1") + xtc_juce_gui_variant(midi-labels "XTC_TEST_MIDI_LABELS_ONLY=1") + set_property(TEST juce-gui-next-daw APPEND PROPERTY ENVIRONMENT XTC_TEST_MODERN_EDITING_ONLY=1) + xtc_juce_gui_variant(modern-plugins "XTC_TEST_CLAP=$" "XTC_TEST_VST2=$" "XTC_TEST_VST3=$") + xtc_juce_gui_variant(instruments "XTC_TEST_SYNTH_CLAP=$" "XTC_TEST_SYNTH_VST2=$" "XTC_TEST_SYNTH_VST3=$") + if(XTC_LADSPA_AVAILABLE) + xtc_juce_gui_variant(plugins "XTC_TEST_PLUGIN=$") + xtc_juce_gui_variant(track-fx "XTC_TEST_TRACK_FX_ONLY=1" "XTC_TEST_PLUGIN=$" "XTC_TEST_CLAP=$" "XTC_TEST_VST2=$" "XTC_TEST_VST3=$") + endif() +endif() +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/tests/integration/test_gui.cpp") diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a7e3d3c..d020866 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -11,7 +11,8 @@ the graph editor is an advanced view of the same project. ## Layers -1. **FLTK/C++ GUI:** input, drawing, windows, view state. +1. **C++ GUI:** input, drawing, windows, view state. FLTK is the default; + `src/gui/JUCE` is an independently optional JUCE frontend using the same C API. 2. **C core:** project model, commands, parameters, persistence, validation. 3. **C engine:** prepared processing graph, DSP, transport timing, event delivery. @@ -218,3 +219,16 @@ files; unsupported native saves preserve files. See NATIVE_API.md and decision 0008. Release tooling lives outside the runtime, with source manifests, deterministic exports and staged installation/consumer checks. See RELEASING.md for reproducibility scope. + +## JUCE frontend ownership + +The optional `xtc-juce` application ports the complete retained GUI command +structure under `src/gui/JUCE`, without linking FLTK. `ui.*` supplies JUCE +components, native controls, graphics, event dispatch, timers, scrolling and +dialogs while preserving the original C-facing command boundaries. The master +sequencer owns JUCE ruler/track groups; tracks own header, instrument, clip and +velocity components. Group ownership, borrowed indexes, immutable copied C +metadata and deferred deletion follow the same rules as the FLTK frontend. +JUCE renders overlays after child components and routes callbacks on the message +thread. The audio backend and callbacks remain in C. See the +[JUCE source map](../src/gui/JUCE/README.md#components-and-maintenance). diff --git a/docs/BUILD.md b/docs/BUILD.md index a982f76..3142795 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -79,6 +79,7 @@ lists, dependencies, SDK and packaging rules in CMake. | Cache option | Default | Meaning | | --- | --- | --- | | `BUILD_TESTING` | `ON` | Build/register the test suite | +| `XTC_BUILD_JUCE_GUI` | `OFF` | Build the JUCE workstation frontend (`xtc-juce`), independently of FLTK; requires `XTC_JUCE_ROOT` | | `XTC_BUILD_GUI` | `ON` | Discover FLTK and build the application when available | | `XTC_WITH_JACK` | `ON` | Discover/build the JACK/PipeWire backend | | `XTC_WITH_LADSPA` | `ON` | Enable LADSPA when the system header exists | @@ -191,3 +192,21 @@ Clean products with `cmake --build build --target clean`; to discard the cache, remove only the selected build directory. Never configure in the source tree. Historical validation entries in ROADMAP describe the build used at that time; Phase 108 records the CMake migration and its verified results. + +## Optional JUCE workstation frontend + +See [src/gui/JUCE/README.md](../src/gui/JUCE/README.md) for the component map and +licence requirements. The SDK is supplied by the user; no download occurs. + +```sh +cmake -S . -B build/juce-gui -DCMAKE_BUILD_TYPE=Debug \ + -DXTC_BUILD_GUI=OFF -DXTC_BUILD_JUCE_GUI=ON -DXTC_JUCE_ROOT=/path/to/JUCE +cmake --build build/juce-gui --parallel 4 --target xtc-juce test_juce_gui +./build/juce-gui/xtc-juce examples/projects/playback-test.xtc +ctest --test-dir build/juce-gui -R juce-gui --output-on-failure -j1 +``` + +`XTC_BUILD_GUI=ON` builds both frontends. `XTC_BUILD_JUCE` continues to mean +JUCE plugin/processor examples and can be enabled independently or alongside +the workstation frontend. A missing SDK is a configuration error only when a +JUCE option is enabled. The existing default FLTK build is unchanged. diff --git a/docs/GUI.md b/docs/GUI.md index 21b098b..51db44e 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -1,6 +1,10 @@ # GUI Guidelines -Use FLTK/C++ for windows, menus, dialogs, and the event loop. Keep all audio +The default frontend uses FLTK/C++ for windows, menus, dialogs, and the event loop. +An optional JUCE port is available under `src/gui/JUCE`; build `xtc-juce` with +`XTC_BUILD_JUCE_GUI=ON` and an external `XTC_JUCE_ROOT`. Both frontends can be +built together and use the same project files and C API. See +[JUCE build and component details](../src/gui/JUCE/README.md). Keep all audio engine and project-domain logic in C. Main views: diff --git a/docs/JUCE.md b/docs/JUCE.md index 9e8b76a..8d0b4c7 100644 --- a/docs/JUCE.md +++ b/docs/JUCE.md @@ -1,7 +1,8 @@ -# JUCE integration (Phase 107) +# JUCE integration -JUCE is used by optional, runnable examples alongside xtc's C99 engine and FLTK -application. It is an external SDK dependency, not vendored, fetched, linked by +JUCE supplies an [optional workstation frontend](../src/gui/JUCE/README.md) +(`XTC_BUILD_JUCE_GUI`) as well as runnable plugin examples (`XTC_BUILD_JUCE`) +alongside xtc's C99 engine and FLTK application. The two options are independent. It is an external SDK dependency, not vendored, fetched, linked by normal builds or automatically installed. The integration uses: | JUCE capability | Implemented use | diff --git a/docs/LICENSES.md b/docs/LICENSES.md index a683894..b9b5875 100644 --- a/docs/LICENSES.md +++ b/docs/LICENSES.md @@ -79,3 +79,11 @@ repository code; no Steinberg VST2 SDK or third-party plugin is bundled. Hosting an installed binary is distinct from SDK, trademark and plugin redistribution rights. Review those separately for any proposed distribution. Source archives retain upstream notices; binary installs also ship both header licenses. + +## Optional JUCE workstation frontend + +`XTC_BUILD_JUCE_GUI` links the user-supplied JUCE SDK under its AGPLv3 or +commercial licence. It is off by default and separate from the MIT C core and +FLTK frontend. No SDK source is copied into this repository or downloaded by +normal configuration. Review the supplied SDK's licence and its graphics/text +dependencies when distributing `xtc-juce`. See [the frontend guide](../src/gui/JUCE/README.md). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2da2677..23e0067 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -3739,6 +3739,47 @@ Play/Stop/Space/stereo check also passed. See the integration-test guide for com --- +## Phase 113 — Optional JUCE workstation frontend + +### Tasks and acceptance criteria + +- [x] Preserve the FLTK sources and default build; add an independently optional + `XTC_BUILD_JUCE_GUI` workstation under `src/gui/JUCE`. +- [x] Require a user-supplied SDK through `XTC_JUCE_ROOT`, with an explicit error + when enabled without the SDK and no SDK discovery/download in normal builds. +- [x] Port the main window, sequencer/master and child views, graph, mixer, + automation, piano roll, velocity editing, sample editor, plugin dialogs, + insert chains, preferences, searchable commands and support widgets. +- [x] Use JUCE components, controls, graphics, native events, dialogs and timers; + keep project/history/DSP/backend/plugin APIs in C and preserve `.xtc` files. +- [x] Allow JUCE-only, FLTK-only, both and core-only configurations; share the + externally supplied SDK with the independently optional plugin examples. +- [x] Include the JUCE binary in the owned install/uninstall inventory when selected. +- [x] Pass the retained GUI regression workflows against JUCE, including failure + rollback, child ownership, gestures, preferences and MIDI device fallback. +- [x] Verify native JUCE transport events and finite stereo playback with the + bundled test song and missing saved MIDI devices. +- [x] Validate the original FLTK build/tests and document final build results. + +### Implementation notes + +The frontend retains the original C-facing command structure and filenames. +`ui.*` maps the retained coordinates and callback conventions to JUCE, with no +FLTK headers or runtime dependency. Native controls may look different. Both +frontends continue to use the existing JACK/PipeWire backend. See the +[frontend source and build guide](../src/gui/JUCE/README.md). +JUCE is an optional external AGPLv3/commercial SDK; normal builds do not link it. +Qualification is for Linux, rather than a new cross-platform audio backend. +The original 57 FLTK/core tests, core-only build, missing-SDK diagnostic, shared +SDK configuration and staged install/uninstall checks passed. The frontend was +built with warnings enabled against external JUCE 8.0.15. Native Play/Stop/Space, +expanded track rendering and finite stereo playback passed with missing saved +MIDI devices. All 12 JUCE GUI workflows passed, with focused reruns after the +native focus/blur adjustments. The [workstation capture](../screenshots/juce-workstation.png) +comes from this real-backend regression, with no physical routes changed. + +--- + ## Recommended Execution Order Reorder the phases when the audit reveals a blocking dependency. Do not mark a diff --git a/examples/juce/CMakeLists.txt b/examples/juce/CMakeLists.txt index 6bd1342..a3974e7 100644 --- a/examples/juce/CMakeLists.txt +++ b/examples/juce/CMakeLists.txt @@ -11,7 +11,9 @@ endif() message(STATUS "JUCE is AGPLv3/commercial: review the external SDK LICENSE.md before distributing these binaries") set(JUCE_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) set(JUCE_BUILD_EXTRAS OFF CACHE BOOL "" FORCE) -add_subdirectory("${XTC_JUCE_ROOT}" juce-sdk EXCLUDE_FROM_ALL) +if(NOT TARGET juce::juce_audio_utils) + add_subdirectory("${XTC_JUCE_ROOT}" juce-sdk EXCLUDE_FROM_ALL) +endif() get_filename_component(XTC_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) if(NOT TARGET xtc-core) set(XTC_BUILD_GUI OFF CACHE BOOL "" FORCE) diff --git a/screenshots/ALL.md b/screenshots/ALL.md index e7fee09..702987f 100644 --- a/screenshots/ALL.md +++ b/screenshots/ALL.md @@ -5,6 +5,15 @@ See [capture notes and reproduction instructions](README.md) for image provenanc --- +## The optional JUCE workstation + +![JUCE workstation playing the bundled test project](juce-workstation.png) + +The JUCE frontend plays the bundled test song with an expanded velocity lane; +missing saved MIDI devices appear as a warning while stereo playback continues. + +--- + ## The xtc workspace at a glance ![The xtc workspace at a glance](overview.png) diff --git a/screenshots/README.md b/screenshots/README.md index a224ddf..4b39f4b 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -32,6 +32,23 @@ APIs and GUI history/preparation paths. Original projects, media and user configuration are not overwritten. Black margins are the virtual desktop; floating views show the actual detached windows together. +## Optional JUCE workstation (Phase 113) + +![JUCE workstation playing the bundled test project](juce-workstation.png) + +Rendered directly from the actual JUCE component hierarchy, with the first +track expanded and playback running. The test deliberately configures missing +MIDI devices and captures finite stereo output through private JACK probe ports. +It preserves user preferences and physical routes. A running JACK-compatible +server is required. After building the optional frontend and tests: + +```sh +XTC_JUCE_SCREENSHOT="$PWD/screenshots/juce-workstation.png" \ + xvfb-run -a -s '-screen 0 1440x960x24' ./build/juce-gui/test_juce_playback +``` + +See the [JUCE frontend guide](../src/gui/JUCE/README.md) for SDK and build options. + ## Continuous audio velocity editing (Phase 110) ![Audio velocity envelope beneath a waveform track](audio-velocity-expanded.png) diff --git a/screenshots/juce-workstation.png b/screenshots/juce-workstation.png new file mode 100644 index 0000000000000000000000000000000000000000..2b62f5d169aa3bb16f3bd1d6ddede643eae85f2c GIT binary patch literal 93069 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 Lj7Y(UuRs0|gw#c= literal 0 HcmV?d00001 diff --git a/src/gui/JUCE/README.md b/src/gui/JUCE/README.md new file mode 100644 index 0000000..c4656b7 --- /dev/null +++ b/src/gui/JUCE/README.md @@ -0,0 +1,74 @@ +# JUCE workstation frontend + +This directory ports the workstation GUI to JUCE. The sibling FLTK sources are +retained. Both frontends call the same C99 project, history, DSP, plugin and JACK +APIs and open the same `.xtc` files. No FLTK headers or library are required by +this frontend. + +## Build and run + +Supply your own JUCE SDK source checkout. CMake does not download the SDK: + +```sh +cmake -S . -B build/juce-gui -DCMAKE_BUILD_TYPE=Debug \ + -DXTC_BUILD_JUCE_GUI=ON -DXTC_BUILD_GUI=OFF \ + -DXTC_JUCE_ROOT=/absolute/path/to/JUCE +cmake --build build/juce-gui --parallel 4 --target xtc-juce +./build/juce-gui/xtc-juce examples/projects/playback-test.xtc +``` + +Leave `XTC_BUILD_GUI=ON` to build both `xtc` (FLTK) and `xtc-juce`. Set both GUI +options off for a core-only build. `XTC_BUILD_JUCE` independently enables the +Soft Keys plugin examples; it is not required for this application. Both JUCE +options can share one SDK checkout in the same build. + +`cmake --install build/juce-gui` includes `xtc-juce` through the existing owned +file inventory; set `XTC_INSTALL_JUCE_GUI=OFF` to exclude it. `uninstall` removes +the same owned binary. Standard binary packages remain core/FLTK packages. + +The qualified platform is Linux with JUCE 8.0.15 and the existing JACK/PipeWire +backend. This port does not replace JACK with JUCE AudioDeviceManager or change +the C engine's platform support. Playback needs a running JACK-compatible server; +MIDI devices are optional. Route the stereo outputs to speakers to listen. + +JUCE is **AGPLv3/commercial**, supplied externally. Review its SDK licence before +redistributing linked binaries. The SDK increases compile time and requires its +normal Linux graphics/windowing development dependencies. Normal FLTK and core +builds neither discover nor link JUCE. + +## Components and maintenance + +The matching filenames preserve the existing command and model boundaries: + +| Files | Responsibility | +| --- | --- | +| `main.cpp`, `main_window.*` | Application, menus, transport, project lifecycle, history and backend control | +| `sequencer_widget.*` | Master selection, coordinates, commands and view reconciliation | +| `sequencer_{track,clip,velocity,ruler}_widget.*` | Actual JUCE child components for tracks, headers, clips, velocity and ruler | +| `sequencer_commands.cpp`, `sequencer_instruments.cpp`, `sequencer_waveforms.cpp` | Editing commands, instrument choices and waveform caches | +| `graph_widget.*`, `mixer_widget.*`, `piano_roll.*`, `sample_editor.*` | Graph editing, mixer/automation, note editing and destructive sample-copy editing | +| `preferences_dialog.hpp`, `plugin_dialog.*`, `plugin_library.hpp`, `track_inserts.hpp`, `command_palette.hpp` | Preferences, plugin discovery/controls, insert chains and searchable commands | +| `theme.hpp`, `snap.hpp`, `velocity_lane.hpp`, `insert_meter.hpp` | Appearance, snapping, controller lane and meters | +| `ui.*` | JUCE component adapter: ownership, retained coordinates, drawing, events, controls, menus, dialogs, scrolling and timers | + +`UiWidget` derives from `juce::Component`; the sequencer hierarchy is a JUCE +component hierarchy. Groups own their workstation children; JUCE also tracks +native child controls. Explicit removal detaches without deleting. Deferred +removal uses a JUCE safe pointer and the message queue. JUCE text editors, +buttons, sliders, list boxes, scrollbars, popup menus and file choosers provide +the platform interaction. Custom canvases draw with `juce::Graphics`; overlays +paint after child components. The rotary pan control uses JUCE's rotary slider +and double-click reset. GUI operations and timers stay on the message thread; +audio callbacks continue to run only C code. + +This is a retained-command port rather than a redesign of the workstation. +When changing a GUI workflow, update both frontend implementations. The common +GUI regression source is compiled against each toolkit, preventing separate +test suites from drifting. The JUCE variant shows the track inspector before +requesting native keyboard focus, since hidden components cannot receive it, and +accounts for JUCE text editors committing an additional undoable edit on blur. The JUCE adapter intentionally retains the existing +coordinate and callback conventions; it does not emulate or link the FLTK +runtime. Frontend-specific appearance and native dialogs differ. + +See [build and validation guidance](../../../docs/BUILD.md) and +[GUI architecture](../../../docs/ARCHITECTURE.md). diff --git a/src/gui/JUCE/command_palette.hpp b/src/gui/JUCE/command_palette.hpp new file mode 100644 index 0000000..d4c1c27 --- /dev/null +++ b/src/gui/JUCE/command_palette.hpp @@ -0,0 +1,100 @@ +#ifndef XTC_COMMAND_PALETTE_HPP +#define XTC_COMMAND_PALETTE_HPP +#include "theme.hpp" +#include "ui.hpp" +#include +#include +#include +#include + +/* UI command search only; execute the chosen action after this modal is gone. */ +class CommandPalette : public UiDouble_Window +{ + std::vector commands_, visible_; + UiInput *search_; + UiHold_Browser *list_; + std::string selected_; + static std::string lower(std::string value) + { + for (auto &c : value) + c = static_cast(std::tolower(static_cast(c))); + return value; + } + void filter() + { + list_->clear(); + visible_.clear(); + const auto query = lower(search_->value()); + for (const auto &command : commands_) + if (lower(command).find(query) != std::string::npos) { + visible_.push_back(command); + list_->add(command.c_str()); + } + if (!visible_.empty()) + list_->select(1); + } + void accept() + { + int i = list_->value(); + if (i > 0 && static_cast(i) <= visible_.size()) { + selected_ = visible_[static_cast(i - 1)]; + hide(); + } + } + + public: + explicit CommandPalette(std::vector commands) + : UiDouble_Window(700, 440, "xtc — Commands"), commands_(std::move(commands)) + { + set_modal(); + begin(); + search_ = new UiInput(136, 18, 544, 32, "Search commands"); + search_->when(UI_WHEN_CHANGED); + search_->callback([](UiWidget *, void *p) { static_cast(p)->filter(); }, this); + list_ = new UiHold_Browser(20, 64, 660, 302); + list_->format_char(0); + list_->callback( + [](UiWidget *, void *p) { + if (Ui::event_clicks()) + static_cast(p)->accept(); + }, + this); + auto *cancel = new UiButton(450, 390, 100, 30, "Cancel"); + cancel->callback([](UiWidget *, void *p) { static_cast(p)->hide(); }, this); + auto *run = new UiButton(564, 390, 116, 30, "Run command"); + run->callback([](UiWidget *, void *p) { static_cast(p)->accept(); }, this); + callback([](UiWidget *, void *p) { static_cast(p)->hide(); }, this); + end(); + xtc_ui::style(this); + filter(); + } + int handle(int event) override + { + if (event == UI_KEYDOWN) { + int key = Ui::event_key(); + if (key == UI_Enter || key == UI_KP_Enter) { + accept(); + return 1; + } + if (key == UI_Escape) { + hide(); + return 1; + } + if ((key == UI_Up || key == UI_Down) && !visible_.empty()) { + list_->select( + std::clamp(list_->value() + (key == UI_Up ? -1 : 1), 1, static_cast(visible_.size()))); + return 1; + } + } + return UiDouble_Window::handle(event); + } + std::string run() + { + show(); + search_->take_focus(); + while (shown()) + Ui::wait(); + return selected_; + } +}; +#endif diff --git a/src/gui/JUCE/graph_widget.cpp b/src/gui/JUCE/graph_widget.cpp new file mode 100644 index 0000000..8642d6b --- /dev/null +++ b/src/gui/JUCE/graph_widget.cpp @@ -0,0 +1,645 @@ +#include "theme.hpp" +#include "graph_widget.hpp" +#include "xtc/xtc_plugin.h" +#include "ui.hpp" +#include +#include +#include + +namespace { +constexpr int node_width = 150, node_height = 74; + +/** + * @brief Returns the display label for a node type. + * + * @param type Node type to describe. + * @return Borrowed static string; never NULL, falling back to "Unknown". + */ +const char *node_name(xtc_node_type type) +{ + switch (type) { + case XTC_NODE_OSCILLATOR: + return "Oscillator"; + case XTC_NODE_GAIN: + return "Gain / bus"; + case XTC_NODE_METER: + return "Meter"; + case XTC_NODE_PLUGIN: + return "Effect plugin"; + case XTC_NODE_NATIVE: + return "Native processor"; + case XTC_NODE_INSTRUMENT: + return "MIDI mix"; + case XTC_NODE_AUDIO: + return "Audio mix"; + case XTC_NODE_OUTPUT: + return "Output sum"; + } + return "Unknown"; +} +} // namespace + +/** + * @brief Constructs the widget and initializes its cached layout from the graph. + * + * The graph, handler, context, and feedback box are stored without taking + * ownership. Layout population is delegated to set_graph(). + * + * @param left Left coordinate in JUCE screen space. + * @param top Top coordinate in JUCE screen space. + * @param width Widget width in pixels. + * @param height Widget height in pixels. + * @param graph Borrowed graph snapshot; must outlive the widget's use of it. + * @param handler Edit callback invoked for every graph mutation. + * @param context Opaque pointer passed back to @p handler; may be NULL. + * @param feedback_box Borrowed label used to report edit results. + */ +GraphWidget::GraphWidget(int left, int top, int width, int height, const xtc_graph *graph, EditHandler handler, + void *context, UiBox *feedback_box) + : UiWidget(left, top, width, height), graph_(graph), handler_(handler), context_(context), feedback_(feedback_box) +{ + set_graph(graph); +} + +/** + * @brief Replaces the graph snapshot and rebuilds cached node positions. + * + * Existing positions are preserved by node ID. New nodes get grid slots that do + * not overlap previously known nodes. The selection is dropped when the selected + * node or wire is no longer present. The widget does not take ownership of the + * graph. + * + * @param graph Borrowed graph that must outlive the widget's use of it. + */ +void GraphWidget::set_graph(const xtc_graph *graph) +{ + graph_ = graph; + routing_count_ = 0; + (void)xtc_graph_analyze(graph_, routing_.data(), routing_.size(), &routing_count_); + size_t nodes = 0U, edges = 0U; + if (xtc_graph_get_counts(graph_, &nodes, &edges) != XTC_OK) { + count_ = 0U; + redraw(); + return; + } + auto old = positions_; + size_t old_count = count_; + count_ = 0U; + for (size_t i = 0U; i < nodes && i < positions_.size(); ++i) { + xtc_node_info node{}; + if (xtc_graph_get_node(graph_, i, &node) != XTC_OK) + continue; + Position position; + position.id = node.id; + bool found = false; + for (size_t j = 0U; j < old_count; ++j) + if (old[j].id == node.id) { + position = old[j]; + found = true; + break; + } + if (!found) { + for (size_t slot = 0U; slot <= positions_.size(); ++slot) { + position.left = 40 + static_cast(slot % 8U) * 210; + position.top = 40 + static_cast(slot / 8U) * 115; + bool occupied = false; + for (size_t j = 0U; j < old_count; ++j) + if (std::abs(old[j].left - position.left) < node_width + 12 && + std::abs(old[j].top - position.top) < node_height + 12) + occupied = true; + for (size_t j = 0U; j < count_; ++j) + if (std::abs(positions_[j].left - position.left) < node_width + 12 && + std::abs(positions_[j].top - position.top) < node_height + 12) + occupied = true; + if (!occupied) + break; + } + } + positions_[count_++] = position; + } + int left, top; + if (!node_position(selected_node_, left, top)) + selected_node_ = SIZE_MAX; + if (edge_selected_ && !select_connection(selected_edge_.source_node, selected_edge_.destination_node)) + edge_selected_ = false; + grow_canvas(); + redraw(); +} + +/** + * @brief Looks up the cached position of a node. + * + * @param id Stable node identifier. + * @param left Receives the left position in widget-local coordinates. + * @param top Receives the top position in widget-local coordinates. + * @return True when the node has a cached position, false otherwise. + */ +bool GraphWidget::node_position(size_t id, int &left, int &top) const +{ + for (size_t i = 0U; i < count_; ++i) + if (positions_[i].id == id) { + left = positions_[i].left; + top = positions_[i].top; + return true; + } + return false; +} + +/** + * @brief Moves a node within the widget, clamped to the visible bounds. + * + * The node keeps a 12-pixel margin from the widget edges. + * + * @param id Stable node identifier. + * @param left Requested left position in widget-local coordinates. + * @param top Requested top position in widget-local coordinates. + * @return True when the node has a cached position, false otherwise. + */ +bool GraphWidget::move_node(size_t id, int left, int top) +{ + for (size_t i = 0U; i < count_; ++i) + if (positions_[i].id == id) { + positions_[i].left = std::max(12, std::min(left, w() - node_width - 12)); + positions_[i].top = std::max(12, std::min(top, h() - node_height - 12)); + redraw(); + return true; + } + return false; +} + +/** + * @brief Selects a node by ID and clears any wire selection. + * + * @param id Stable node identifier. + * @return True when the node has a cached position, false otherwise. + */ +bool GraphWidget::select_node(size_t id) +{ + int left, top; + if (!node_position(id, left, top)) + return false; + selected_node_ = id; + edge_selected_ = false; + describe_selection(); + redraw(); + return true; +} + +/** + * @brief Selects a wire between two nodes and clears the node selection. + * + * @param source Source node identifier. + * @param destination Destination node identifier. + * @return True when a matching connection exists, false otherwise. + */ +bool GraphWidget::select_connection(size_t source, size_t destination) +{ + size_t nodes, edges; + if (xtc_graph_get_counts(graph_, &nodes, &edges) != XTC_OK) + return false; + for (size_t i = 0U; i < edges; ++i) { + xtc_connection_info edge{}; + if (xtc_graph_get_connection(graph_, i, &edge) == XTC_OK && edge.source_node == source && + edge.destination_node == destination) { + selected_edge_ = edge; + edge_selected_ = true; + selected_node_ = SIZE_MAX; + describe_selection(); + redraw(); + return true; + } + } + return false; +} + +/** + * @brief Shows the outcome of an edit in the feedback label. + * + * @param result Result returned by the edit handler. + * @param success Text shown when @p result is XTC_OK; must be valid then and is ignored otherwise. + */ +void GraphWidget::feedback(xtc_result result, const char *success) +{ + char text[256]; + if (result == XTC_OK) + std::snprintf(text, sizeof(text), "%s", success); + else if (result == XTC_ERR_CAPACITY) + std::snprintf( + text, sizeof(text), + "Graph unchanged: capacity exceeded (node limit or pending edit). Retry after the next audio block."); + else if (result == XTC_ERR_GRAPH_CYCLE) + std::snprintf(text, sizeof(text), "Graph unchanged: %s. Feedback loops are unsupported.", xtc_result_string(result)); + else + std::snprintf(text, sizeof(text), "Graph unchanged: %s. Connect an audio output (right) to an input (left); duplicate wires are rejected.", xtc_result_string(result)); + feedback_->copy_label(text); +} + +/** + * @brief Requests creation of a node through the edit handler. + * + * Selects the new node when the edit succeeds. + * + * @param type Kind of node to add. + * @return Result reported by the edit handler. + */ +xtc_result GraphWidget::add_node(xtc_node_type type) +{ + xtc_graph_edit edit{}; + edit.kind = XTC_EDIT_ADD_NODE; + edit.data.add.type = type; + edit.data.add.value = type == XTC_NODE_OSCILLATOR ? 440.0f : type == XTC_NODE_GAIN ? 0.1f : 0.0f; + size_t added = SIZE_MAX; + xtc_result result = handler_(context_, edit, &added); + if (result == XTC_OK) + select_node(added); + feedback(result, "Node added. Drag its title to move; drag an output port to an input to connect."); + return result; +} + +/** + * @brief Requests a connection between two ports through the edit handler. + * + * @param connection Connection describing the source and destination node/port pairs. + * @return Result reported by the edit handler. + */ +xtc_result GraphWidget::connect_ports(const xtc_connection_info &connection) +{ + xtc_graph_edit edit{}; + edit.kind = XTC_EDIT_CONNECT; + edit.data.connection = connection; + xtc_result result = handler_(context_, edit, nullptr); + feedback(result, "Connection added. Click a wire and press Delete to disconnect."); + return result; +} + +/** + * @brief Requests removal of the selected wire or node through the edit handler. + * + * @return Result reported by the edit handler, or XTC_ERR_INVALID_STATE when + * nothing is selected. + */ +xtc_result GraphWidget::delete_selected() +{ + xtc_graph_edit edit{}; + if (edge_selected_) { + edit.kind = XTC_EDIT_DISCONNECT; + edit.data.connection = selected_edge_; + } else if (selected_node_ != SIZE_MAX) { + edit.kind = XTC_EDIT_REMOVE_NODE; + edit.data.node_id = selected_node_; + } else { + feedback_->copy_label("Select a node or wire first."); + return XTC_ERR_INVALID_STATE; + } + xtc_result result = handler_(context_, edit, nullptr); + feedback(result, "Selection deleted. Unwired inputs use external audio; select a node to inspect routing."); + return result; +} + +/** + * @brief Computes the widget-local center of a node port. + * + * @param id Stable node identifier. + * @param port Port index; 0 selects the input and 1 the output. + * @param left Receives the port center x coordinate. + * @param top Receives the port center y coordinate. + * @return True when the node and port exist, false otherwise. + */ +bool GraphWidget::port_position(size_t id, size_t port, int &left, int &top) const +{ + xtc_port_info info{}; + if (xtc_graph_get_port(graph_, id, port, &info) != XTC_OK || !node_position(id, left, top)) + return false; + left += info.direction == XTC_PORT_OUTPUT ? node_width : 0; + top += 48; + return true; +} + +/** + * @brief Finds the port nearest to a widget-local point. + * + * Searches nodes in reverse draw order and accepts points within 9 pixels of a + * port center. + * + * @param left Widget-local x coordinate to test. + * @param top Widget-local y coordinate to test. + * @param node Receives the matching node ID when true is returned. + * @param port Receives the matching port index (0 input, 1 output) when true is returned. + * @return True when a port is within range, false otherwise. + */ +bool GraphWidget::hit_port(int left, int top, size_t &node, size_t &port) const +{ + for (size_t i = count_; i > 0U; --i) + for (size_t p = 0U; p < 2U; ++p) { + int px, py; + if (port_position(positions_[i - 1U].id, p, px, py) && std::abs(px - left) <= 9 && + std::abs(py - top) <= 9) { + node = positions_[i - 1U].id; + port = p; + return true; + } + } + return false; +} + +/** + * @brief Draws the background, wires, nodes, ports, and any pending wire. + */ +void GraphWidget::draw() +{ + ui_push_clip(x(), y(), w(), h()); + ui_color(ui_rgb_color(25, 28, 33)); + ui_rectf(x(), y(), w(), h()); + size_t nodes = 0U, edges = 0U; + (void)xtc_graph_get_counts(graph_, &nodes, &edges); + for (size_t i = 0U; i < edges; ++i) { + xtc_connection_info edge{}; + int sx, sy, dx, dy; + if (xtc_graph_get_connection(graph_, i, &edge) != XTC_OK || !port_position(edge.source_node, 1U, sx, sy) || + !port_position(edge.destination_node, 0U, dx, dy)) + continue; + bool selected = (edge_selected_ && selected_edge_.source_node == edge.source_node && + selected_edge_.destination_node == edge.destination_node) || + 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; + ui_color(selected ? xtc_ui::accent() : audible_path ? ui_rgb_color(100, 200, 190) : ui_rgb_color(110, 115, 125)); + ui_line_style(UI_SOLID, selected ? 3 : 2); + ui_line(x() + sx, y() + sy, x() + dx, y() + dy); + double length = std::hypot(dx-sx, dy-sy); + if (length > 20) { + double ux = (dx-sx)/length, uy = (dy-sy)/length; + int ax = x() + (sx+dx)/2, ay = y() + (sy+dy)/2; + ui_line(ax, ay, ax-static_cast(ux*9-uy*5), ay-static_cast(uy*9+ux*5)); + ui_line(ax, ay, ax-static_cast(ux*9+uy*5), ay-static_cast(uy*9-ux*5)); + } + } + ui_line_style(0); + for (size_t i = 0U; i < count_; ++i) { + xtc_node_info node{}; + if (xtc_graph_get_node(graph_, i, &node) != XTC_OK) + continue; + int left = x() + positions_[i].left, top = y() + positions_[i].top; + ui_color(node.type == XTC_NODE_OUTPUT ? ui_rgb_color(100, 50, 55) + : node.type == XTC_NODE_GAIN ? ui_rgb_color(75, 55, 95) + : node.type == XTC_NODE_OSCILLATOR || node.type == XTC_NODE_AUDIO || node.type == XTC_NODE_INSTRUMENT + ? ui_rgb_color(35, 80, 75) : ui_rgb_color(49, 65, 85)); + ui_rectf(left, top, node_width, node_height); + ui_color(selected_node_ == node.id ? xtc_ui::accent() : ui_rgb_color(115, 130, 145)); + ui_rect(left, top, node_width, node_height); + ui_color(UI_WHITE); + ui_font(UI_HELVETICA_BOLD, 12); + char text[96]; + xtc_plugin_spec title_plugin{}; + if(node.type==XTC_NODE_PLUGIN && xtc_graph_get_plugin(graph_,node.id,&title_plugin)==XTC_OK) + std::snprintf(text,sizeof(text),"%s effect #%zu",xtc_plugin_format_name(title_plugin.format),node.id); + else std::snprintf(text, sizeof(text), "%s #%zu", node_name(node.type), node.id); + ui_push_clip(left+6, top+2, node_width-12, 40); + ui_draw(text, left + 10, top + 20); + ui_font(UI_HELVETICA, 12); + if (node.type == XTC_NODE_OSCILLATOR) + std::snprintf(text, sizeof(text), "%.0f Hz", static_cast(node.value)); + else if (node.type == XTC_NODE_GAIN) + std::snprintf(text, sizeof(text), "x %.2f", static_cast(node.value)); + else if (node.type == XTC_NODE_PLUGIN) { + xtc_plugin_spec plugin{}; + if (xtc_graph_get_plugin(graph_,node.id,&plugin) == XTC_OK) + std::snprintf(text,sizeof(text),"%.20s",plugin.label); + } else + std::snprintf(text, sizeof(text), "%s", + node.type == XTC_NODE_METER ? "Peak pass-through" + : node.type == XTC_NODE_INSTRUMENT ? "MIDI sine source" + : node.type == XTC_NODE_AUDIO ? "Timeline mix source" + : "Summed engine output"); + ui_draw(text, left + 10, top + 37); + ui_pop_clip(); + xtc_routing_info route{}; + if (routing(node.id, route) && !route.reaches_output) { + ui_color(xtc_ui::accent()); + ui_draw("! no output path", left + 10, top + 72); + } + for (size_t port = 0U; port < 2U; ++port) { + int px, py; + if (!port_position(node.id, port, px, py)) + continue; + ui_color(ui_rgb_color(110, 220, 185)); + ui_pie(x() + px - 5, y() + py - 5, 10, 10, 0, 360); + ui_color(UI_WHITE); + ui_draw(port == 0U ? (route.external_input ? "ext in" : "sum in") : "audio out", left + (port == 0U ? 10 : 90), top + 56); + } + } + if (wiring_) { + int px, py; + if (port_position(wire_node_, wire_port_, px, py)) { + ui_color(xtc_ui::accent()); + ui_line(x() + px, y() + py, x() + mouse_x_, y() + mouse_y_); + } + } + ui_pop_clip(); +} + +/** + * @brief Handles mouse and keyboard events for selection, dragging, and wiring. + * + * @param event JUCE event code. + * @return 1 when the event was consumed, 0 for unsupported left-button input, + * or the result of UiWidget::handle() for unhandled events. + */ +bool GraphWidget::select_at(int left, int top) +{ + for (size_t i = count_; i > 0; --i) { + const auto &pos = positions_[i-1]; + if (left >= pos.left && left <= pos.left+node_width && top >= pos.top && top <= pos.top+node_height) + return select_node(pos.id); + } + size_t nodes = 0, edges = 0; + xtc_graph_get_counts(graph_,&nodes,&edges); + for (size_t i = 0; i < edges; ++i) { + xtc_connection_info edge{}; int sx, sy, dx, dy; + xtc_graph_get_connection(graph_,i,&edge); + if (!port_position(edge.source_node,1,sx,sy) || !port_position(edge.destination_node,0,dx,dy)) continue; + double vx = dx-sx, vy = dy-sy, length = vx*vx+vy*vy; + double t = length == 0 ? 0 : std::clamp(((left-sx)*vx+(top-sy)*vy)/length,0.0,1.0); + if (std::hypot(left-sx-t*vx,top-sy-t*vy) <= 6) return select_connection(edge.source_node,edge.destination_node); + } + selected_node_ = SIZE_MAX; edge_selected_ = false; describe_selection(); redraw(); return false; +} + +int GraphWidget::handle(int event) +{ + const int left = Ui::event_x() - x(), top = Ui::event_y() - y(); + switch (event) { + case UI_FOCUS: + case UI_UNFOCUS: + return 1; + case UI_PUSH: { + if (Ui::event_button() == UI_RIGHT_MOUSE) { + take_focus(); select_at(left,top); + UiMenu_Item items[5]{}; + items[0].text = "Inspect selection"; + items[1].text = "Arrange by signal flow (L)"; + items[2].text = "Insert unity gain in selected wire (I)"; + items[3].text = "Delete selection"; + const auto *picked = items->popup(Ui::event_x(), Ui::event_y()); + if (picked == items) describe_selection(); + else if (picked == items+1) auto_layout(); + else if (picked == items+2) insert_gain(); + else if (picked == items+3) delete_selected(); + return 1; + } + if (Ui::event_button() != UI_LEFT_MOUSE) return 0; + take_focus(); + mouse_x_ = left; + mouse_y_ = top; + dragging_ = false; + wiring_ = false; + if (hit_port(left, top, wire_node_, wire_port_)) { + wiring_ = true; + redraw(); + return 1; + } + for (size_t i = count_; i > 0U; --i) { + const Position &pos = positions_[i - 1U]; + if (left >= pos.left && left <= pos.left + node_width && top >= pos.top && top <= pos.top + node_height) { + select_node(pos.id); + dragging_ = true; + drag_x_ = left - pos.left; + drag_y_ = top - pos.top; + return 1; + } + } + size_t nodes, edges; + (void)xtc_graph_get_counts(graph_, &nodes, &edges); + for (size_t i = 0U; i < edges; ++i) { + xtc_connection_info edge{}; + int sx, sy, dx, dy; + (void)xtc_graph_get_connection(graph_, i, &edge); + if (!port_position(edge.source_node, 1U, sx, sy) || !port_position(edge.destination_node, 0U, dx, dy)) + continue; + double vx = dx - sx, vy = dy - sy; + double length = vx * vx + vy * vy; + double t = + length == 0.0 ? 0.0 : std::max(0.0, std::min(1.0, ((left - sx) * vx + (top - sy) * vy) / length)); + if (std::hypot(left - sx - t * vx, top - sy - t * vy) <= 6.0) { + select_connection(edge.source_node, edge.destination_node); + return 1; + } + } + selected_node_ = SIZE_MAX; + edge_selected_ = false; + redraw(); + return 1; + } + case UI_DRAG: + mouse_x_ = left; + mouse_y_ = top; + if (dragging_) + move_node(selected_node_, left - drag_x_, top - drag_y_); + if (wiring_) + redraw(); + return 1; + case UI_RELEASE: + dragging_ = false; + if (wiring_) { + size_t node, port; + wiring_ = false; + if (hit_port(left, top, node, port)) + connect_ports({wire_node_, wire_port_, node, port}); + else + feedback_->copy_label("Connection cancelled. Drag an output port to an input port."); + redraw(); + } + return 1; + case UI_KEYDOWN: + if (Ui::event_key() == 'l' && !Ui::event_state(UI_CTRL | UI_ALT)) { auto_layout(); return 1; } + if (Ui::event_key() == 'i' && !Ui::event_state(UI_CTRL | UI_ALT)) { insert_gain(); return 1; } + if ((Ui::event_key() == UI_Left || Ui::event_key() == UI_Right) && count_) { + size_t selected = 0; + for (size_t i = 0; i < count_; ++i) if (positions_[i].id == selected_node_) { + selected = Ui::event_key() == UI_Left ? (i+count_-1)%count_ : (i+1)%count_; break; + } + select_node(positions_[selected].id); + return 1; + } + if (Ui::event_key() == UI_Delete || Ui::event_key() == UI_BackSpace) { + delete_selected(); + return 1; + } + if (Ui::event_key() == UI_Escape) { + wiring_ = dragging_ = false; + redraw(); + return 1; + } + break; + default: + break; + } + return UiWidget::handle(event); +} + +bool GraphWidget::routing(size_t id, xtc_routing_info &info) const +{ + for (size_t i = 0; i < routing_count_; ++i) if (routing_[i].id == id) { info = routing_[i]; return true; } + return false; +} + +void GraphWidget::grow_canvas() +{ + int width = 2000, height = 4000; + for (size_t i = 0; i < count_; ++i) { + width = std::max(width, positions_[i].left + node_width + 40); + height = std::max(height, positions_[i].top + node_height + 40); + } + size(width, height); + if (parent()) parent()->redraw(); +} + +bool GraphWidget::auto_layout() +{ + if (routing_count_ != count_) return false; + std::array rows{}; + for (size_t i = 0; i < count_; ++i) { + xtc_routing_info info{}; + if (!routing(positions_[i].id, info) || info.level >= rows.size()) return false; + positions_[i].left = 40 + static_cast(info.level) * 210; + positions_[i].top = 40 + static_cast(rows[info.level]++) * 115; + } + wiring_ = dragging_ = false; + grow_canvas(); + feedback_->copy_label("Arranged left to right by signal flow. Layout is session-only; no audio or routing changes."); + redraw(); + return true; +} + +xtc_result GraphWidget::insert_gain() +{ + if (!edge_selected_) { + feedback_->copy_label("Select a wire first, then insert a unity gain/bus node."); + return XTC_ERR_INVALID_STATE; + } + xtc_graph_edit edit{}; + edit.kind = XTC_EDIT_INSERT_GAIN; + edit.data.connection = selected_edge_; + size_t added = SIZE_MAX; + xtc_result result = handler_(context_, edit, &added); + if (result == XTC_OK) select_node(added); + feedback(result, "Unity gain inserted into wire as one edit. Inputs sum; fan-out copies the signal without normalization."); + return result; +} + +void GraphWidget::describe_selection() +{ + char text[320]; + if (edge_selected_) { + std::snprintf(text, sizeof(text), "Wire: #%zu audio out -> #%zu sum in. Delete disconnects; I inserts a unity gain. Destination sums its inputs.", + selected_edge_.source_node, selected_edge_.destination_node); + } else { + xtc_routing_info info{}; + if (!routing(selected_node_, info)) { feedback_->copy_label("Select a node or wire to inspect routing."); return; } + std::snprintf(text, sizeof(text), "Node #%zu: %zu incoming, %zu outgoing | %s | %s. Ports are audio lanes; stereo uses paired independent processing.", + info.id, info.inputs, info.outputs, + info.reaches_output ? "path to engine output" : "NO output path", + info.external_input ? "unwired input uses external audio" : "wired sum or source"); + } + feedback_->copy_label(text); +} diff --git a/src/gui/JUCE/graph_widget.hpp b/src/gui/JUCE/graph_widget.hpp new file mode 100644 index 0000000..aefa6a8 --- /dev/null +++ b/src/gui/JUCE/graph_widget.hpp @@ -0,0 +1,212 @@ +#ifndef XTC_GRAPH_WIDGET_HPP +#define XTC_GRAPH_WIDGET_HPP +#include "ui.hpp" +#include +#include "xtc/xtc_engine.h" + +class UiBox; + +/* GUI positions/selection only. Domain snapshots and edits stay in the C API. */ +class GraphWidget : public UiWidget { + public: + /** + * @brief Applies a graph edit using an opaque context and optional added-node output. + * + * The handler receives the context, edit command, and output identifier in that order. + * @return XTC_OK on success, or an edit error. + */ + using EditHandler = xtc_result (*)(void *, const xtc_graph_edit &, size_t *); + /** + * @brief Constructs a graph widget bound to a graph and an edit callback. + * + * The widget stores the graph, handler, context, and feedback box without + * taking ownership of any of them. + * + * @param x Left coordinate in JUCE screen space. + * @param y Top coordinate in JUCE screen space. + * @param width Widget width in pixels. + * @param height Widget height in pixels. + * @param graph Borrowed graph snapshot; must outlive the widget's use of it. + * @param handler Edit callback invoked for every graph mutation. + * @param context Opaque pointer passed back to @p handler; may be NULL. + * @param feedback Borrowed label used to report edit results; must outlive the widget. + */ + GraphWidget(int x, int y, int width, int height, const xtc_graph *graph, EditHandler handler, void *context, + UiBox *feedback); + + /** + * @brief Clears cached node positions, selection, and interaction state. + * + * Does not change the borrowed graph pointer or the graph's own layout. + */ + void reset_layout() + { + count_ = 0; + selected_node_ = SIZE_MAX; + edge_selected_ = dragging_ = wiring_ = false; + } + + /** + * @brief Replaces the graph snapshot and rebuilds cached node positions. + * + * Existing positions are preserved by node ID; new nodes are placed on a grid + * that avoids overlapping previously known nodes. Selections that no longer + * exist are cleared. The widget does not take ownership of @p graph. + * + * @param graph Borrowed graph that must outlive the widget's use of it. + */ + void set_graph(const xtc_graph *graph); + + /** + * @brief Returns the graph snapshot currently displayed. + * + * The returned pointer is borrowed and valid for GUI thread use only. + * + * @return Borrowed graph pointer; may be NULL when none was supplied. + */ + size_t selected_node() const { return selected_node_; } + + const xtc_graph *graph() const + { + return graph_; + } // Borrowed, GUI thread only. + + /** + * @brief Requests creation of a node through the edit handler. + * + * Selects the new node when the edit succeeds. + * + * @param type Kind of node to add. + * @return Result reported by the edit handler. + */ + xtc_result add_node(xtc_node_type type); + xtc_result insert_gain(); + bool selected_connection(xtc_connection_info &out) const { if (!edge_selected_) return false; out = selected_edge_; return true; } + bool auto_layout(); // Presentation only, no engine publication or undo step. + bool routing(size_t id, xtc_routing_info &info) const; + + + /** + * @brief Requests a connection between two ports through the edit handler. + * + * @param connection Connection describing the source and destination node/port pairs. + * @return Result reported by the edit handler. + */ + xtc_result connect_ports(const xtc_connection_info &connection); + + /** + * @brief Requests removal of the selected wire or node through the edit handler. + * + * @return Result reported by the edit handler, or XTC_ERR_INVALID_STATE when + * nothing is selected. + */ + xtc_result delete_selected(); + + /** + * @brief Selects a node by ID and clears any wire selection. + * + * @param id Stable node identifier. + * @return True when the node has a cached position, false otherwise. + */ + bool select_node(size_t id); + bool select_at(int left, int top); // Canvas-local coordinates; selection only. + + /** + * @brief Selects a wire between two nodes and clears the node selection. + * + * @param source Source node identifier. + * @param destination Destination node identifier. + * @return True when a matching connection exists, false otherwise. + */ + bool select_connection(size_t source, size_t destination); + + /** + * @brief Moves a node within the widget, clamped to the visible bounds. + * + * @param id Stable node identifier. + * @param left Requested left position in widget-local coordinates. + * @param top Requested top position in widget-local coordinates. + * @return True when the node has a cached position, false otherwise. + */ + bool move_node(size_t id, int left, int top); + + /** + * @brief Looks up the cached position of a node. + * + * @param id Stable node identifier. + * @param left Receives the left position in widget-local coordinates. + * @param top Receives the top position in widget-local coordinates. + * @return True when the node has a cached position, false otherwise. + */ + bool node_position(size_t id, int &left, int &top) const; + + /** + * @brief Handles mouse and keyboard events for selection, dragging, and wiring. + * + * @param event JUCE event code. + * @return 1 when the event was consumed, 0 for unsupported left-button input, + * or the result of UiWidget::handle() for unhandled events. + */ + int handle(int event) override; + + protected: + /** + * @brief Draws the background, wires, nodes, ports, and any pending wire. + */ + void draw() override; + + private: + struct Position { + size_t id = SIZE_MAX; + int left = 0, top = 0; + }; + + std::array positions_{}; + size_t count_ = 0U, routing_count_ = 0U; + std::array routing_{}; + void describe_selection(); + void grow_canvas(); + const xtc_graph *graph_; + EditHandler handler_; + void *context_; + UiBox *feedback_; + size_t selected_node_ = SIZE_MAX; + xtc_connection_info selected_edge_{}; + bool edge_selected_ = false; + bool dragging_ = false, wiring_ = false; + size_t wire_node_ = SIZE_MAX, wire_port_ = 0U; + int drag_x_ = 0, drag_y_ = 0, mouse_x_ = 0, mouse_y_ = 0; + /** + * @brief Shows the outcome of an edit in the feedback label. + * + * @param result Result returned by the edit handler. + * @param success Text shown when @p result is XTC_OK; must be valid then and is ignored otherwise. + */ + void feedback(xtc_result result, const char *success); + + /** + * @brief Finds the port nearest to a widget-local point. + * + * Searches nodes in reverse draw order and accepts points within 9 pixels of a + * port center. + * + * @param left Widget-local x coordinate to test. + * @param top Widget-local y coordinate to test. + * @param node Receives the matching node ID when true is returned. + * @param port Receives the matching port index (0 input, 1 output) when true is returned. + * @return True when a port is within range, false otherwise. + */ + bool hit_port(int left, int top, size_t &node, size_t &port) const; + + /** + * @brief Computes the widget-local center of a node port. + * + * @param id Stable node identifier. + * @param port Port index; 0 selects the input and 1 the output. + * @param left Receives the port center x coordinate. + * @param top Receives the port center y coordinate. + * @return True when the node and port exist, false otherwise. + */ + bool port_position(size_t id, size_t port, int &left, int &top) const; +}; +#endif diff --git a/src/gui/JUCE/insert_meter.hpp b/src/gui/JUCE/insert_meter.hpp new file mode 100644 index 0000000..6148376 --- /dev/null +++ b/src/gui/JUCE/insert_meter.hpp @@ -0,0 +1,45 @@ +#ifndef XTC_INSERT_METER_HPP +#define XTC_INSERT_METER_HPP +#include "xtc/xtc_backend.h" +#include "xtc/xtc_mixer.h" +#include "theme.hpp" +#include +#include +#include +/* UI-only display fed by the existing single backend snapshot reader. */ +class InsertMeter : public UiButton { + xtc_peak_meter left_{-90,0},right_{-90,0}; + uint64_t track_=UINT64_MAX; + public: + InsertMeter(int x,int y,int w,int h):UiButton(x,y,w,h) { + tooltip("Post-track-fader, pre-bus stereo sample peaks. Click to clear the sticky clip indicator."); + callback([](UiWidget *w,void *){static_cast(w)->clear_clips();}); + } + void clear_clips(){left_.clipped=right_.clipped=0;redraw();} + float left_db() const {return left_.db;} + float right_db() const {return right_.db;} + bool clipped() const {return left_.clipped || right_.clipped;} + void feed(const xtc_meter_snapshot &snapshot,uint64_t track,bool active,double seconds) { + if(track_!=track){track_=track;xtc_peak_meter_reset(&left_);xtc_peak_meter_reset(&right_);} + bool found=false;float l=0,r=0; + for(size_t i=0;i=0 ? l : 0,seconds); + (void)xtc_peak_meter_update(&right_,std::isfinite(r) && r>=0 ? r : 0,seconds); + } + redraw(); + } + void draw() override { + draw_box();char text[120];std::snprintf(text,sizeof(text),"Post-fader L %.1f R %.1f dBFS%s",static_cast(left_.db),static_cast(right_.db),clipped() ? " CLIP" : ""); + ui_font(UI_HELVETICA,11);ui_color(clipped() ? ui_rgb_color(245,132,142) : xtc_ui::muted());ui_draw(text,x()+8,y()+12); + int width=std::max(0,(w()-24)/2); + for(unsigned int side=0;side<2;++side) { + int left=x()+8+static_cast(side)*(width+8);float db=side ? right_.db : left_.db; + ui_color(xtc_ui::background());ui_rectf(left,y()+h()-7,width,4); + ui_color(clipped() ? ui_rgb_color(245,132,142) : xtc_ui::accent()); + ui_rectf(left,y()+h()-7,static_cast(static_cast(width)*std::min(1.f,std::max(0.f,(db+90)/90))),4); + } + } +}; +#endif diff --git a/src/gui/JUCE/main.cpp b/src/gui/JUCE/main.cpp new file mode 100644 index 0000000..695b2f7 --- /dev/null +++ b/src/gui/JUCE/main.cpp @@ -0,0 +1,59 @@ +#include "ui.hpp" +#include +#include +#include +#include "main_window.hpp" +#include "xtc/xtc_version.h" +#include "xtc/xtc.h" +#include + +/** + * @brief Runs the JUCE application and reports command-line or startup failures. + * + * @param argc Number of command-line arguments including the program name. + * @param argv Command-line argument vector. + * @return EXIT_SUCCESS after the event loop exits, or EXIT_FAILURE for invalid usage or startup failure. + */ +int main(int argc, char **argv) +{ + if (argc == 2 && !std::strcmp(argv[1], "--version")) { + std::printf("%s %s\n", XTC_PROJECT_NAME, XTC_VERSION_STRING); + return EXIT_SUCCESS; + } + if (argc == 2 && !std::strcmp(argv[1], "--diagnose")) { + unsigned int features = xtc_build_features(); + std::printf("xtc %s; project formats 1..%u; JACK %s; LADSPA %s; CLAP/VST2/VST3 mono effects/instruments compiled\nFor limits and recovery commands run xtc-check --diagnose/--help.\n", + XTC_VERSION_STRING,XTC_PROJECT_FORMAT_VERSION,features & XTC_FEATURE_JACK ? "compiled" : "disabled", + features & XTC_FEATURE_LADSPA ? "compiled" : "disabled"); + return EXIT_SUCCESS; + } + if (argc == 2 && !std::strcmp(argv[1], "--help")) { + std::puts( + "Usage: xtc-juce [PROJECT]\n--version, --diagnose and --help work without a display or audio server."); + return EXIT_SUCCESS; + } + const char *project_path = nullptr; + if (argc == 2 && argv[1][0] != '-') { + project_path = argv[1]; + argc = 1; + argv[1] = nullptr; + } + try { + UiInitialiser initialise; + MainWindow window; + if (project_path) { + xtc_result result = window.open_project(project_path); + if (result != XTC_OK) { + std::fprintf(stderr, "Cannot open project: %s\n", xtc_result_string(result)); + return EXIT_FAILURE; + } + } + window.show(argc, argv); + return Ui::run(); + } catch (const std::exception &error) { + std::fprintf(stderr, "GUI startup/runtime failure: %s\n", error.what()); + } catch (...) { + std::fputs("Unexpected GUI startup/runtime failure.\n", stderr); + } + return EXIT_FAILURE; +} diff --git a/src/gui/JUCE/main_window.cpp b/src/gui/JUCE/main_window.cpp new file mode 100644 index 0000000..5e7f9e6 --- /dev/null +++ b/src/gui/JUCE/main_window.cpp @@ -0,0 +1,2856 @@ +#include "main_window.hpp" +#include "xtc/xtc_midi_file.h" +#include "graph_widget.hpp" +#include "sequencer_widget.hpp" +#include "piano_roll.hpp" +#include "mixer_widget.hpp" +#include "plugin_dialog.hpp" +#include "sample_editor.hpp" +#include "theme.hpp" +#include "snap.hpp" +#include "velocity_lane.hpp" +#include "preferences_dialog.hpp" +#include "command_palette.hpp" +#include "ui.hpp" +#include "plugin_library.hpp" +#include "track_inserts.hpp" +#include "xtc/xtc_version.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +class PanelWindow : public UiDouble_Window { + MainWindow *owner_; + public: + PanelWindow(int width, int height, const char *title, MainWindow *owner) + : UiDouble_Window(width,height,title), owner_(owner) {} + void resize(int x,int y,int width,int height) override { + UiDouble_Window::resize(x,y,width,height); + if(owner_ && owner_->transport_window()==this)owner_->layout_transport(); + } + int handle(int event) override { + if (event == UI_SHORTCUT && owner_->panel_shortcut(event)) return 1; + return UiDouble_Window::handle(event); + } +}; +void restore_panel(UiWindow *window,int x,int y,int width,int height,int min_width,int min_height) +{ + int sx,sy,sw,sh;Ui::screen_work_area(sx,sy,sw,sh,Ui::screen_num(x,y,width,height)); + width=std::min(std::max(width,min_width),sw);height=std::min(std::max(height,min_height),sh); + x=std::clamp(x,sx,sx+sw-width);y=std::clamp(y,sy,sy+sh-height); + window->resize(x,y,width,height); +} +bool editing_number(UiValue_Input *input) +{ + return Ui::focus() == input || Ui::focus() == &input->input || Ui::pushed() == input || + Ui::pushed() == &input->input; +} +/** + * @brief Updates a box label only when the text differs. + * + * Avoids redundant relayout and redraw when status text is refreshed + * repeatedly. The box takes a copy of the text. + * + * @param box Box whose label is updated; must not be NULL. + * @param text New label text; must not be NULL. + */ +void set_label(UiBox *box, const char *text) +{ + if (box->label() == nullptr || std::strcmp(box->label(), text) != 0) + box->copy_label(text); +} +/* Paint progress without dispatching input/reentering edit commands. */ +class BusyOperation { + UiWindow &window_; + public: + BusyOperation(UiWindow &window, UiBox *status, const char *message) : window_(window) { + set_label(status,message); window_.cursor(UI_CURSOR_WAIT); Ui::flush(); + } + ~BusyOperation() { window_.cursor(UI_CURSOR_DEFAULT); } +}; +} // namespace + +/** + * @brief Constructs the window, its widgets, and the initial project. + * + * Creates an empty project and undo history, opens the audio backend, and + * schedules the periodic status timer. Throws std::runtime_error when the + * initial project or history cannot be created. + * + * @param prompt_unsaved Whether open, new, and close actions ask before + * discarding unsaved changes. + */ +MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) + : UiDouble_Window(1280, 800, XTC_PROJECT_NAME " — modular audio workstation"), prompt_unsaved_(prompt_unsaved) +{ + xtc_ui::initialize(); + xtc_settings_defaults(&settings_); + char config_path[4096]; + if(configuration) configuration_=configuration; + else if(xtc_settings_path(config_path,sizeof(config_path))==XTC_OK) configuration_=config_path; + xtc_result config_result=configuration_.empty() ? XTC_ERR_NOT_FOUND : xtc_settings_load(&settings_,configuration_.c_str()); + + configuration_valid_=config_result==XTC_OK || config_result==XTC_ERR_NOT_FOUND; + xtc_project *initial = nullptr; + xtc_result result = xtc_project_create(&initial); + if (result != XTC_OK) + throw std::runtime_error(xtc_result_string(result)); + project_defaults(initial); + project_.reset(initial); + xtc_history *history = nullptr; + result = xtc_history_create(initial, &history); + if (result != XTC_OK) + throw std::runtime_error(xtc_result_string(result)); + history_.reset(history); + begin(); + auto *menu = new UiMenu_Bar(0, 0, 1280, 28); + menu_ = menu; + menu->add("File/New", UI_CTRL + 'n', file_callback, this); + menu->add("File/Open", UI_CTRL + 'o', file_callback, this); + menu->add("File/Save", UI_CTRL + 's', file_callback, this); + menu->add("File/Import MIDI...",0,[](UiWidget *,void *c){static_cast(c)->import_midi_dialog();},this); + menu->add("File/Export selected clip as MIDI...",0,[](UiWidget *,void *c){static_cast(c)->export_midi_dialog();},this); + menu->add("File/Export WAV",0,[](UiWidget *,void *context){static_cast(context)->export_audio();},this); + menu->add("File/Save As", UI_CTRL + UI_SHIFT + 's', file_callback, this); + menu->add("File/Import WAV", UI_CTRL + 'i', audio_file_callback, this); + menu->add("File/Relink selected audio", 0, audio_file_callback, this); + menu->add("Edit/Preferences",UI_CTRL+',',[](UiWidget *,void *c){static_cast(c)->show_preferences();},this); + menu->add("Edit/Undo", UI_CTRL + 'z', history_callback, this); + menu->add("Edit/Redo", UI_CTRL + UI_SHIFT + 'z', history_callback, this); + menu->add("Edit/Redo alternate", UI_CTRL + 'y', history_callback, this); + menu->add("Audio/MIDI controller mapping", 0, control_map_callback, this); + menu->add("Audio/Panic", UI_F + 9, panic_callback, this); + menu->add("Edit/Mixer comparison/Capture A",0,[](UiWidget *,void *c){auto *w=static_cast(c);auto r=w->mix_snapshot(0,true);if(r!=XTC_OK)w->report_error("Mixer capture failed",r);},this); + menu->add("Edit/Mixer comparison/Recall A",0,[](UiWidget *,void *c){auto *w=static_cast(c);auto r=w->mix_snapshot(0,false);if(r!=XTC_OK)w->report_error("Mixer recall failed",r);},this); + menu->add("Edit/Mixer comparison/Capture B",0,[](UiWidget *,void *c){auto *w=static_cast(c);auto r=w->mix_snapshot(1,true);if(r!=XTC_OK)w->report_error("Mixer capture failed",r);},this); + menu->add("Edit/Mixer comparison/Recall B",0,[](UiWidget *,void *c){auto *w=static_cast(c);auto r=w->mix_snapshot(1,false);if(r!=XTC_OK)w->report_error("Mixer recall failed",r);},this); + menu->add("View/Previous marker",0,[](UiWidget *,void *c){auto *w=static_cast(c);auto r=w->navigate_marker(-1);if(r!=XTC_OK)w->report_error("Marker navigation failed",r);},this); + menu->add("View/Next marker",0,[](UiWidget *,void *c){auto *w=static_cast(c);auto r=w->navigate_marker(1);if(r!=XTC_OK)w->report_error("Marker navigation failed",r);},this); + menu->add("View/Loop current section",0,[](UiWidget *,void *c){auto *w=static_cast(c);auto r=w->loop_section();if(r!=XTC_OK)w->report_error("Section loop unchanged",r);},this); + menu->add("File/Collect project media...",0,[](UiWidget *,void *c){auto *w=static_cast(c);const char *path=ui_input("New absolute project folder (must not exist)","");if(path){auto r=w->collect_project(path);if(r!=XTC_OK)w->report_error("Project collection failed",r);}},this); + menu->add("View/Commands",UI_CTRL+'p',[](UiWidget *,void *c){static_cast(c)->show_commands();},this); + menu->add("View/Fit song",UI_CTRL+'0',[](UiWidget *,void *c){static_cast(c)->fit_song();},this); + menu->add("View/Piano roll", UI_CTRL + '4', piano_callback, this); + menu->add("Audio/Record WAV", UI_SHIFT + UI_F + 5, audio_file_callback, this); + menu->add("Track/Load instrument plugin", 0, instrument_callback, this); + menu->add("Track/Use built-in instrument", 0, instrument_callback, this); + menu->add("Track/Live MIDI to selected instrument", 0, instrument_callback, this); + menu->add("Track/Live MIDI to built-in sine", 0, instrument_callback, this); + menu->add("Edit/Selected instrument controls",0,instrument_callback,this); + menu->add("Edit/Selected instrument automation",0,instrument_callback,this); + menu->add("File/Load sampler WAV",0,instrument_callback,this); + menu->add("File/Save instrument preset",0,instrument_callback,this); + menu->add("File/Load instrument preset",0,instrument_callback,this); + menu->add("Edit/Insert internal delay on wire", 0, plugin_callback, this); + menu->add("Edit/Insert internal reverb on wire", 0, plugin_callback, this); + menu->add("Edit/Insert stereo delay on wire",0,plugin_callback,this); + menu->add("Edit/Insert stereo reverb on wire",0,plugin_callback,this); + menu->add("File/Add stereo delay",0,plugin_callback,this); + menu->add("File/Add stereo reverb",0,plugin_callback,this); + menu->add("File/Add internal delay", 0, plugin_callback, this); + menu->add("File/Add internal reverb", 0, plugin_callback, this); + menu->add("File/Load plugin", 0, plugin_callback, this); + menu->add("Edit/Replace selected plugin", 0, plugin_callback, this); + menu->add("Edit/Insert plugin on selected wire", 0, plugin_callback, this); + menu->add("Edit/Reset selected plugin", 0, plugin_callback, this); + menu->add("Edit/Selected plugin automation", 0, plugin_callback, this); + menu->add("Edit/Selected plugin control", 0, plugin_callback, this); + menu->add("File/Quit", UI_CTRL + 'q', close_callback, this); + + menu->add("Audio/Start engine", UI_F + 5, play_callback, this); + menu->add("Audio/Stop engine", UI_F + 8, stop_callback, this); + menu->add("Audio/Reconnect", UI_F + 6, reconnect_callback, this); + + menu->add("Audio/Pause", UI_F + 7, [](UiWidget *, void *c) { + (void)static_cast(c)->pause_playback(); + }, this); + menu->add("Audio/Return to start", UI_Home, [](UiWidget *, void *c) { + (void)static_cast(c)->return_to_start(); + }, this); + + menu->add("View/Detach transport",0,[](UiWidget *,void *c) { + auto *w=static_cast(c); w->detach_transport(!w->transport_detached_); + },this,UI_MENU_TOGGLE); + menu->add("View/Detach mixer",0,[](UiWidget *,void *c) { + auto *w=static_cast(c); w->detach_mixer(!w->mixer_detached_); + },this,UI_MENU_TOGGLE); + menu->add("View/Dock all panels",0,[](UiWidget *,void *c) { + auto *w=static_cast(c); w->detach_transport(false); w->detach_mixer(false);w->detach_editor(0,false);w->detach_editor(1,false);w->detach_piano(false); + },this); + menu->add("View/Detach sequencer",0,[](UiWidget *,void *c){auto *w=static_cast(c);w->detach_editor(0,!w->editors_[0].detached);},this,UI_MENU_TOGGLE); + menu->add("View/Detach modular graph",0,[](UiWidget *,void *c){auto *w=static_cast(c);w->detach_editor(1,!w->editors_[1].detached);},this,UI_MENU_TOGGLE); + menu->add("View/Float piano roll",0,[](UiWidget *,void *c){auto *w=static_cast(c);w->detach_piano(!w->piano_detached_);},this,UI_MENU_TOGGLE|UI_MENU_VALUE); + menu->add("View/Reset workspace layout",0,[](UiWidget *,void *c){static_cast(c)->reset_layout();},this); + transport_group_=new UiGroup(0,28,1280,90); + transport_group_->begin(); + play_ = new UiButton(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."); + stop_ = new UiButton(100, 40, 80, 30, "Stop"); + stop_->callback(stop_callback, this); + stop_->tooltip("Stop playback at its current position; finalize recording (F8 or Space)."); + auto *reconnect = new UiButton(188, 40, 110, 30, "Reconnect"); + reconnect->callback(reconnect_callback, this); + reconnect->tooltip("Close and reopen JACK, leaving the engine stopped (F6)."); + record_ = new UiButton(306, 40, 90, 30, "Record"); + record_->callback(audio_file_callback, this); + record_->tooltip("Record JACK input to WAV; choose path, then Stop to finalize and add the take."); + auto *panic = new UiButton(404, 40, 70, 30, "Panic"); + panic->callback(panic_callback, this); + panic->tooltip("Silence held instrument notes (F9)."); + pause_ = new UiButton(482, 40, 70, 30, "Pause"); + pause_->callback([](UiWidget *, void *c) { + (void)static_cast(c)->pause_playback(); + }, this); + pause_->tooltip("Pause at the current sample position (F7). Play resumes; recording uses Stop."); + home_ = new UiButton(560, 40, 100, 30, "To start"); + home_->callback([](UiWidget *, void *c) { + (void)static_cast(c)->return_to_start(); + }, this); + home_->tooltip("Stop and return to the project beginning (Home). Unavailable while recording."); + indicator_ = new UiBox(1000, 40, 268, 30, "Audio: disconnected"); + indicator_->align(UI_ALIGN_LEFT | UI_ALIGN_INSIDE); + indicator_->labelfont(UI_BOLD); + indicator_->labelsize(12); + transport_position_ = new UiBox(680,40,190,30,"001 : 1 : 000"); + transport_position_->tooltip("Musical position: bar : beat : tick (4/4, 960 ticks per beat)"); + tempo_display_ = new UiValue_Input(880, 40, 78, 30, "BPM"); + tempo_display_->align(UI_ALIGN_RIGHT); + tempo_display_->bounds(20, 300); + tempo_display_->step(.1); + tempo_display_->when(UI_WHEN_RELEASE | UI_WHEN_ENTER_KEY); + tempo_display_->tooltip("Project tempo in BPM. Enter or release to apply; undoable. Stops playback."); + tempo_display_->callback( + [](UiWidget *, void *c) { + auto *w = static_cast(c); + double bpm = w->tempo_display_->value(); + xtc_result r = (!std::isfinite(bpm) || bpm < 20 || bpm > 300) + ? XTC_ERR_INVALID_ARGUMENT + : w->set_tempo(static_cast(std::lround(bpm * 1000))); + if (r != XTC_OK) + w->report_error("Tempo unchanged", r); + w->tempo_display_->value(xtc_project_tempo(w->project_.get()) / 1000.0); + w->refresh_status(); + }, + this); + auto *hint = new UiBox(12, 82, 160, 28, "xtc / SESSION"); + hint->align(UI_ALIGN_LEFT | UI_ALIGN_INSIDE); + session_info_ = new UiBox(180, 82, 280, 28); + session_info_->align(UI_ALIGN_LEFT | UI_ALIGN_INSIDE); + auto *commands = new UiButton(480, 82, 120, 28, "Commands"); + commands->tooltip("Search actions (Ctrl+P)"); + commands->callback([](UiWidget *, void *c) { static_cast(c)->show_commands(); }, this); + follow_ = new UiCheck_Button(620, 82, 154, 28, "Follow playhead"); + follow_->tooltip("Keep playback visible in the arrangement; pauses while dragging."); + follow_->callback( + [](UiWidget *widget, void *c) { + static_cast(c)->settings_.follow_playhead = static_cast(widget)->value(); + }, + this); + auto *fit = new UiButton(790, 82, 100, 28, "Fit song"); + fit->tooltip("Choose the largest supported zoom that fits the song (Ctrl+0)"); + fit->callback([](UiWidget *, void *c) { static_cast(c)->fit_song(); }, this); + auto *tap=new UiButton(905,82,80,28,"Tap"); + tap->tooltip("Tap tempo: averages up to four intervals, 20–300 BPM; pauses over three seconds restart."); + tap->callback([](UiWidget *,void *c){auto *w=static_cast(c);double now=std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count();auto r=w->tap_tempo(now);if(r!=XTC_OK && r!=XTC_ERR_NOT_FOUND && r!=XTC_ERR_INVALID_ARGUMENT)w->report_error("Tap tempo unchanged",r);},this); + loop_button_ = new UiCheck_Button(1000, 82, 100, 28, "Loop"); + loop_button_->callback([](UiWidget *widget,void *c){auto *w=static_cast(c); + xtc_result r=w->set_loop(static_cast(w->settings_.loop_start),static_cast(w->settings_.loop_end),static_cast(widget)->value()!=0); + if(r!=XTC_OK) w->report_error("Loop unchanged",r); + w->refresh_status();},this); + auto *range=new UiButton(1110,82,150,28,"Loop range..."); + range->tooltip("Shift-drag the sequencer ruler to select a loop, or enter start/end ticks here."); + range->callback([](UiWidget *,void *c){auto *w=static_cast(c);char initial[100]; + std::snprintf(initial,sizeof(initial),"%lld %lld",static_cast(w->settings_.loop_start),static_cast(w->settings_.loop_end)); + const char *text=ui_input("Loop start and end ticks (end excluded)",initial);if(!text)return; + unsigned long long start,end;char extra;xtc_result r=XTC_ERR_INVALID_ARGUMENT; + 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(); + for(int i=0;ichildren();++i) { + auto *item=transport_group_->child(i); + transport_items_.push_back({item,item->x(),item->y()-28,item->w(),item->h()}); + } + + tabs_ = new UiTabs(216, 126, 1056, 578); + tabs_->begin(); + auto *sequencer = new UiGroup(216, 158, 1056, 546, "Sequencer"); + sequencer->begin(); + editors_[0].owner=this;editors_[0].index=0;editors_[0].page=sequencer; + editors_[0].content=new UiGroup(216,158,1056,546); + editors_[0].content->begin(); + auto *add_track = new UiMenu_Button(228, 168, 100, 26, "Create"); + auto *add_region = new UiButton(336, 168, 110, 26, "Add region"); + auto *delete_region = new UiButton(454, 168, 130, 26, "Delete region/track"); + auto *zoom_in = new UiButton(592, 168, 80, 26, "Zoom +"); + auto *zoom_out = new UiButton(680, 168, 80, 26, "Zoom -"); + auto *add_midi = new UiButton(768, 168, 110, 26, "Add MIDI clip"); + auto *piano = new UiButton(886, 168, 100, 26, "Piano roll"); + piano->callback(piano_callback, this); + auto *timeline_hint = + new UiBox(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(UI_ALIGN_LEFT | UI_ALIGN_INSIDE); + timeline_hint->labelsize(12); + auto *timeline_scroll = new UiScroll(228, 206, 1032, 452); + timeline_scroll_=timeline_scroll; + sequencer_ = new SequencerWidget(228, 206, project_.get(), seek, this); + timeline_scroll->end(); + sequencer_->before_edit = before_arrangement_edit; + sequencer_->loop_select=[](void *c,uint64_t start,uint64_t end){auto *w=static_cast(c);return w->set_loop(start,end,w->settings_.loop_enabled!=0);}; + sequencer_->open_clip = [](void *c) {auto *w=static_cast(c);xtc_clip_info clip{}; + if(xtc_project_find_clip(w->project_.get(),w->sequencer_->selected_clip(),&clip)==XTC_OK && !clip.midi){auto r=w->open_sample_editor();if(r!=XTC_OK)w->report_error("Sample editor unavailable",r);}else piano_callback(nullptr,c);}; + sequencer_->instrument_select = [](void *c, uint64_t track, const xtc_plugin_spec *spec, bool browse) { + auto *w = static_cast(c); + w->sequencer_->select(track); + if (browse) { instrument_callback(nullptr,c); return; } + xtc_result r = w->assign_instrument(track,spec ? spec->path : nullptr,spec ? spec->index : 0); + if (r != XTC_OK) w->report_error("Track instrument unchanged",r); + }; + // Keep this in the editor group rather than the scrollable timeline. + auto *parent = UiGroup::current(); UiGroup::current(editors_[0].content); + snap_ = new UiChoice(1040,168,210,26,"Snap"); + for (size_t i = 0; i < xtc_ui::snap_count; ++i) { int item = snap_->add("Snap"); snap_->replace(item,xtc_ui::snap_labels[i]); } + snap_->value(xtc_ui::snap_index(sequencer_->grid())); + snap_->callback([](UiWidget *widget, void *c) { + auto *choice = static_cast(widget); int value = choice->value(); + if (value >= 0 && static_cast(value) < xtc_ui::snap_count) + (void)static_cast(c)->sequencer_->grid(xtc_ui::snap_ticks[value]); + },this); + UiGroup::current(parent); + menu->add("Edit/Nudge selection left",0,[](UiWidget *,void *c){static_cast(c)->nudge_selection(-1);},sequencer_); + menu->add("Edit/Nudge selection right",0,[](UiWidget *,void *c){static_cast(c)->nudge_selection(1);},sequencer_); + menu->add("View/Sample editor",0,[](UiWidget *,void *c){auto *w=static_cast(c);auto r=w->open_sample_editor();if(r!=XTC_OK)w->report_error("Sample editor unavailable",r);},this); + menu->add("Edit/Audio tools/Normalize source peak...",0,[](UiWidget *,void *c){static_cast(c)->normalize_dialog();},sequencer_); + menu->add("Edit/Audio tools/Set boundary fades...",0,[](UiWidget *,void *c){static_cast(c)->fades_dialog();},sequencer_); + menu->add("Edit/Audio tools/Remove boundary fades",0,[](UiWidget *,void *c){static_cast(c)->transform_selection(XTC_CLIPS_FADE,0);},sequencer_); + menu->add("Edit/MIDI tools/Generate arpeggio...",0,[](UiWidget *,void *c){static_cast(c)->arpeggio_dialog();},sequencer_); + menu->add("Edit/MIDI tools/Build chords...",0,[](UiWidget *,void *c){static_cast(c)->chord_dialog();},sequencer_); + menu->add("Edit/MIDI tools/Strum chords...",0,[](UiWidget *,void *c){static_cast(c)->strum_dialog();},sequencer_); + menu->add("Edit/MIDI tools/Humanize...",0,[](UiWidget *,void *c){static_cast(c)->humanize_dialog();},sequencer_); + menu->add("Edit/MIDI tools/Shape velocities...",0,[](UiWidget *,void *c){static_cast(c)->velocity_dialog();},sequencer_); + menu->add("Edit/MIDI tools/Correct to scale...",0,[](UiWidget *,void *c){static_cast(c)->scale_dialog();},sequencer_); + menu->add("Edit/MIDI tools/Note gate...",0,[](UiWidget *,void *c){static_cast(c)->gate_dialog();},sequencer_); + menu->add("Edit/MIDI tools/Legato",0,[](UiWidget *,void *c){xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_LEGATO;static_cast(c)->transform_midi(edit);},sequencer_); + menu->add("Edit/MIDI tools/Swing quantize...",0,[](UiWidget *,void *c){static_cast(c)->swing_dialog();},sequencer_); + menu->add("Edit/Align clip starts to cursor",0,[](UiWidget *,void *c){auto *s=static_cast(c);s->transform_selection(XTC_CLIPS_ALIGN,static_cast(s->position()));},sequencer_); + menu->add("Edit/Slip sources earlier",0,[](UiWidget *,void *c){auto *s=static_cast(c);s->transform_selection(XTC_CLIPS_SLIP,-static_cast(s->grid()));},sequencer_); + menu->add("Edit/Slip sources later",0,[](UiWidget *,void *c){auto *s=static_cast(c);s->transform_selection(XTC_CLIPS_SLIP,static_cast(s->grid()));},sequencer_); + menu->add("Edit/Repeat selected phrase...",0,[](UiWidget *,void *c){static_cast(c)->repeat_dialog();},sequencer_); + menu->add("Edit/Copy clips",0,[](UiWidget *,void *c){static_cast(c)->copy_selection();},sequencer_); + menu->add("Edit/Paste clips at cursor",0,[](UiWidget *,void *c){static_cast(c)->paste_selection();},sequencer_); + menu->add("Edit/Consolidate MIDI clips",0,[](UiWidget *,void *c){static_cast(c)->merge_midi();},sequencer_); + menu->add("Edit/Duplicate complete track...",0,[](UiWidget *,void *c){static_cast(c)->duplicate_track_dialog();},sequencer_); + menu->add("Edit/Time/Insert gap...",0,[](UiWidget *,void *c){static_cast(c)->ripple_dialog(false);},sequencer_); + menu->add("Edit/Time/Remove range...",0,[](UiWidget *,void *c){static_cast(c)->ripple_dialog(true);},sequencer_); + menu->add("Edit/Markers/Set at cursor...",0,[](UiWidget *,void *c){static_cast(c)->marker_dialog();},sequencer_); + menu->add("Edit/Markers/Remove at cursor",0,[](UiWidget *,void *c){static_cast(c)->marker(nullptr);},sequencer_); + menu->add("Edit/Select all clips",0,[](UiWidget *,void *c){static_cast(c)->select_all_clips();},sequencer_); + menu->add("Edit/Duplicate clip",UI_CTRL+'d',[](UiWidget *, void *c) { (void)static_cast(c)->duplicate_selection(); },sequencer_); + menu->add("Edit/Split clip at cursor", 0, [](UiWidget *, void *c) { (void)static_cast(c)->split_selection(); }, sequencer_); + menu->add("Edit/Audio tools/Crossfade two selected clips",0,[](UiWidget *,void *c){static_cast(c)->crossfade_selection();},sequencer_); + menu->add("Edit/Audio clip gain and fades", 0, [](UiWidget *, void *c) { static_cast(c)->audio_properties_dialog(); }, sequencer_); + menu->add("Edit/Rename clip or track",UI_F+2,[](UiWidget *, void *c) { + const char *name = ui_input("Name (1..63 UTF-8 bytes)",""); if (name) (void)static_cast(c)->rename_selection(name); + },sequencer_); + for (const char *label : {"View/Snap/1 tick","View/Snap/120 ticks","View/Snap/160 ticks","View/Snap/240 ticks","View/Snap/320 ticks","View/Snap/480 ticks","View/Snap/960 ticks","View/Snap/1920 ticks","View/Snap/3840 ticks"}) + menu->add(label,0,[](UiWidget *widget,void *c) { + auto *m = static_cast(widget); + (void)static_cast(c)->grid(std::strtoull(m->mvalue()->label(),nullptr,10)); + },sequencer_); + sequencer_->callback( + [](UiWidget *, void *c) { + static_cast(c)->mark_dirty(); + }, + this); + add_midi->callback( + [](UiWidget *, void *c) { + static_cast(c)->add_midi_clip(); + }, + sequencer_); + add_region->callback( + [](UiWidget *, void *c) { + static_cast(c)->add_region(); + }, + sequencer_); + delete_region->callback( + [](UiWidget *, void *c) { + static_cast(c)->delete_selection(); + }, + sequencer_); + zoom_in->callback( + [](UiWidget *, void *c) { + static_cast(c)->zoom(1); + }, + sequencer_); + zoom_out->callback( + [](UiWidget *, void *c) { + static_cast(c)->zoom(-1); + }, + sequencer_); + editors_[0].content->resizable(timeline_scroll);editors_[0].content->end(); + sequencer->resizable(editors_[0].content); + sequencer->end(); + auto *mixer = new UiGroup(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( + [](UiWidget *, void *c) { + static_cast(c)->mark_dirty(); + }, + this); + mixer_placeholder_=new UiGroup(228,210,1032,120); + auto *detached_hint=new UiBox(240,220,600,30,"The mixer is open in a separate window."); + detached_hint->align(UI_ALIGN_LEFT|UI_ALIGN_INSIDE); + auto *show_mixer=new UiButton(240,260,170,30,"Show mixer window"); + show_mixer->callback([](UiWidget *,void *c){static_cast(c)->mixer_window_->show();},this); + auto *dock_mixer=new UiButton(424,260,140,30,"Dock mixer"); + dock_mixer->callback([](UiWidget *,void *c){static_cast(c)->detach_mixer(false);},this); + mixer_placeholder_->end(); mixer_placeholder_->hide(); + mixer->resizable(mixer_); + mixer->end(); + auto *graph = new UiGroup(216, 158, 1056, 546, "Modular Graph"); + graph->begin(); + editors_[1].owner=this;editors_[1].index=1;editors_[1].page=graph; + editors_[1].content=new UiGroup(216,158,1056,546);editors_[1].content->begin(); + graph_state_ = new UiBox(228, 200, 956, 24, "Graph: offline"); + graph_state_->align(UI_ALIGN_LEFT | UI_ALIGN_INSIDE); + graph_state_->labelsize(12); + auto *feedback = + new UiBox(228, 666, 1032, 28, + "Add nodes. Drag output to input to connect; click a node or wire then Delete. Scroll to pan."); + feedback->align(UI_ALIGN_LEFT | UI_ALIGN_INSIDE | UI_ALIGN_WRAP); + feedback->labelsize(12); + auto *scroll = new UiScroll(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, [](UiWidget *, void *c) { (void)static_cast(c)->auto_layout(); }, graph_widget_); + menu->add("Edit/Insert unity gain in selected wire", 0, [](UiWidget *, void *c) { (void)static_cast(c)->insert_gain(); }, graph_widget_); + auto *oscillator = new UiButton(228, 168, 130, 26, "Add oscillator"); + oscillator->callback( + [](UiWidget *, void *context) { + (void)static_cast(context)->add_node(XTC_NODE_OSCILLATOR); + }, + graph_widget_); + auto *gain = new UiButton(366, 168, 110, 26, "Add gain"); + gain->callback( + [](UiWidget *, void *context) { + (void)static_cast(context)->add_node(XTC_NODE_GAIN); + }, + graph_widget_); + auto *meter = new UiButton(484, 168, 110, 26, "Add meter"); + meter->callback( + [](UiWidget *, void *context) { + (void)static_cast(context)->add_node(XTC_NODE_METER); + }, + graph_widget_); + auto *output = new UiButton(602, 168, 110, 26, "Add output"); + output->callback( + [](UiWidget *, void *context) { + (void)static_cast(context)->add_node(XTC_NODE_OUTPUT); + }, + graph_widget_); + auto *audio = new UiButton(858, 168, 130, 26, "Add audio source"); + audio->callback( + [](UiWidget *, void *context) { + (void)static_cast(context)->add_node(XTC_NODE_AUDIO); + }, + graph_widget_); + auto *instrument = new UiButton(996, 168, 130, 26, "Add instrument"); + instrument->callback( + [](UiWidget *, void *c) { + (void)static_cast(c)->add_node(XTC_NODE_INSTRUMENT); + }, + graph_widget_); + auto *remove = new UiButton(720, 168, 130, 26, "Delete selection"); + remove->callback( + [](UiWidget *, void *context) { + (void)static_cast(context)->delete_selected(); + }, + graph_widget_); + editors_[1].content->resizable(scroll);editors_[1].content->end(); + graph->resizable(editors_[1].content);graph->end(); + piano_page_=new UiGroup(216,158,1056,546,"Piano roll"); + piano_placeholder_=new UiGroup(228,210,1000,120); + auto *piano_hint=new UiBox(240,220,900,30,"Select a MIDI clip, then open its piano roll. Float or dock it from View.");piano_hint->align(UI_ALIGN_LEFT|UI_ALIGN_INSIDE); + auto *open_piano=new UiButton(240,260,190,30,"Open selected MIDI clip");open_piano->callback(piano_callback,this); + piano_placeholder_->end();piano_page_->end(); + for(auto &panel:editors_) { + panel.page->begin();panel.placeholder=new UiGroup(228,210,1000,120); + auto *hint=new UiBox(240,220,900,30,"This editor is open in a separate window.");hint->align(UI_ALIGN_LEFT|UI_ALIGN_INSIDE); + auto *show=new UiButton(240,260,170,30,"Show editor window"); + show->callback([](UiWidget *,void *c){auto *p=static_cast(c);if(p->window)p->window->show();},&panel); + auto *dock=new UiButton(424,260,140,30,"Dock editor"); + dock->callback([](UiWidget *,void *c){auto *p=static_cast(c);p->owner->detach_editor(p->index,false);},&panel); + panel.placeholder->end();panel.placeholder->hide();panel.page->end(); + } + track_inserts_=new TrackInsertsPanel(216,158,1056,546,this); + tabs_->end(); + tabs_->value(sequencer); + menu->add("View/Track inserts",0,view_callback,track_inserts_); + menu->add("Track/Show selected track inserts",UI_CTRL+'5',[](UiWidget *,void *c){auto *w=static_cast(c);auto r=w->open_track_inserts();if(r!=XTC_OK)w->report_error("Select an audio or MIDI track to open inserts",r);},this); + menu->add("View/Sequencer", UI_CTRL + '1', view_callback, sequencer); + menu->add("View/Mixer", UI_CTRL + '2', view_callback, mixer); + menu->add("View/Modular Graph", UI_CTRL + '3', view_callback, graph); + menu->add("Help/About", 0, about_callback); + + auto *sidebar = new UiGroup(12,126,192,578); + sidebar_=sidebar; + sidebar->box(UI_BORDER_BOX); + auto midi_create = [](UiWidget *, 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); }; + auto audio_create = [](UiWidget *, 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); }; + add_track->add("Empty track",0,[](UiWidget *,void *c){static_cast(c)->add_track();},sequencer_); + add_track->add("New MIDI track",0,midi_create,this); + add_track->add("New audio track",0,audio_create,this); + add_track->add("Import MIDI",0,[](UiWidget *,void *c){static_cast(c)->import_midi_dialog();},this); + add_track->add("Import WAV",0,audio_file_callback,this); + add_track->add("Load instrument plugin",0,instrument_callback,this); + add_track->add("Edit MIDI notes",0,piano_callback,this); + menu->add("Track/New MIDI track",UI_CTRL+UI_SHIFT+'m',midi_create,this); + menu->add("Track/New audio track",UI_CTRL+UI_SHIFT+'a',audio_create,this); + menu->add("View/Track inspector",0,[](UiWidget *,void *c){ + auto *w=static_cast(c);w->show_track_inspector(!w->sidebar_->visible()); + },this,UI_MENU_TOGGLE); + auto *selection_title = new UiBox(24,140,168,24,"TRACK INSPECTOR"); + selection_title->align(UI_ALIGN_LEFT | UI_ALIGN_INSIDE); + selection_info_ = new UiBox(24,174,168,82,"Select a track to inspect it."); + selection_info_->align(UI_ALIGN_TOP_LEFT | UI_ALIGN_INSIDE | UI_ALIGN_WRAP); + inspector_gain_ = new UiValue_Input(78, 264, 114, 26, "Gain"); + inspector_gain_->bounds(0, 4); + inspector_gain_->step(.01); + inspector_pan_ = new UiValue_Input(78, 298, 114, 26, "Pan"); + inspector_pan_->bounds(-1, 1); + inspector_pan_->step(.01); + inspector_mute_ = new UiCheck_Button(24, 332, 78, 24, "Mute"); + inspector_solo_ = new UiCheck_Button(108, 332, 84, 24, "Solo"); + for (auto *control : {static_cast(inspector_gain_), static_cast(inspector_pan_), + static_cast(inspector_mute_), static_cast(inspector_solo_)}) { + control->when(UI_WHEN_RELEASE); + control->callback(inspector_callback, this); + } + inspector_gain_->tooltip( + "Selected track linear gain: 0 silence, 1 unity, 4 maximum. Automation overrides static gain."); + inspector_pan_->tooltip("Selected track pan: -1 left, 0 center, +1 right."); + auto *tip = new UiBox(24, 368, 168, 52, "Ctrl+P Commands\nCtrl+1/2/3 Views\nCtrl+0 Fit song"); + tip->align(UI_ALIGN_LEFT | UI_ALIGN_INSIDE); + sidebar->end(); + status_ = new UiBox(12,714,1256,26,"JACK: not connected"); + status_->align(UI_ALIGN_LEFT | UI_ALIGN_INSIDE); + error_ = new UiBox(12,744,1256,44,""); + error_->align(UI_ALIGN_LEFT | UI_ALIGN_INSIDE | UI_ALIGN_WRAP); + xtc_ui::style(this); + color(xtc_ui::background()); + 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(UI_COURIER_BOLD); transport_position_->labelsize(18); + tempo_display_->labelcolor(xtc_ui::muted()); + play_->labelcolor(xtc_ui::accent()); record_->labelcolor(ui_rgb_color(245,132,142)); + error_->labelcolor(ui_rgb_color(255,166,139)); + end(); + resizable(tabs_); + size_range(1220, 720); + callback(close_callback, this); + apply_ui_preferences(); + show_track_inspector(settings_.inspector_visible!=0); + open_backend(); + if(config_result!=XTC_OK && config_result!=XTC_ERR_NOT_FOUND) report_error("Preferences file invalid; using defaults",config_result); + Ui::add_timeout(static_cast(settings_.refresh_ms)/1000,timer_callback,this); +} + +void MainWindow::detach_editor(unsigned int index,bool detached) +{ + if(index>=2)return; + auto &panel=editors_[index];if(!panel.content || panel.detached==detached)return; + auto *scroll=index==0 ? timeline_scroll_ : dynamic_cast(graph_widget_->parent()); + int scroll_x=scroll ? scroll->xposition() : 0,scroll_y=scroll ? scroll->yposition() : 0; + if(detached && !panel.window) { + auto *current=UiGroup::current();UiGroup::current(nullptr); + panel.window.reset(new PanelWindow(1100,650,index==0 ? "xtc — Sequencer (close to dock)" : "xtc — Modular Graph (close to dock)",this)); + auto *dock=new UiButton(12,6,150,28,"Dock editor");dock->callback([](UiWidget *,void *c){auto *p=static_cast(c);p->owner->detach_editor(p->index,false);},&panel); + panel.window->end();panel.window->size_range(index==0 ? 1100 : 1000,420);xtc_ui::style(panel.window.get()); + panel.window->callback([](UiWidget *,void *c){auto *p=static_cast(c);p->owner->detach_editor(p->index,false);},&panel); + restore_panel(panel.window.get(),static_cast(index==0 ? settings_.sequencer_x : settings_.graph_x),static_cast(index==0 ? settings_.sequencer_y : settings_.graph_y),static_cast(index==0 ? settings_.sequencer_width : settings_.graph_width),static_cast(index==0 ? settings_.sequencer_height : settings_.graph_height),index==0 ? 1100 : 1000,420); + UiGroup::current(current); + } + panel.content->parent()->remove(panel.content);panel.detached=detached; + if(detached) { + panel.page->resizable(nullptr);panel.window->add(panel.content); + panel.content->resize(12,44,panel.window->w()-24,panel.window->h()-56);panel.window->resizable(panel.content);panel.placeholder->show(); + } else { + panel.window->resizable(nullptr);panel.page->add(panel.content); + panel.content->resize(panel.page->x(),panel.page->y(),panel.page->w(),panel.page->h()); + panel.page->resizable(panel.content);panel.placeholder->hide();panel.window->hide(); + } + if(scroll)scroll->scroll_to(scroll_x,scroll_y); + panel.content->show();panel.page->init_sizes(); + if(index==0)settings_.sequencer_detached=detached;else settings_.graph_detached=detached; + auto *item=const_cast(menu_->find_item(index==0 ? "View/Detach sequencer" : "View/Detach modular graph"));if(detached)item->set();else item->clear(); + if(detached)panel.window->show(); + redraw(); +} +void MainWindow::detach_piano(bool detached) +{ + settings_.piano_detached=detached; + auto *item=const_cast(menu_->find_item("View/Float piano roll"));if(detached)item->set();else item->clear(); + if(!piano_content_) {piano_detached_=detached;return;} + if(piano_content_->window()==piano_window_.get() && detached) {piano_detached_=true;return;} + if(piano_content_->parent()==piano_page_ && !detached) {piano_detached_=false;return;} + auto *scroll=dynamic_cast(piano_->parent());int sx=scroll->xposition(),sy=scroll->yposition(); + piano_content_->parent()->remove(piano_content_);piano_detached_=detached; + if(detached) { + piano_page_->resizable(nullptr);piano_placeholder_->show();piano_window_->add(piano_content_); + piano_content_->resize(0,0,piano_window_->w(),piano_window_->h());piano_window_->resizable(piano_content_);piano_window_->show(); + } else { + piano_window_->resizable(nullptr);piano_placeholder_->hide();piano_page_->add(piano_content_); + piano_content_->resize(piano_page_->x(),piano_page_->y(),piano_page_->w(),piano_page_->h()); + piano_page_->resizable(piano_content_);piano_window_->hide();tabs_->value(piano_page_); + } + scroll->scroll_to(sx,sy);piano_content_->show();piano_page_->init_sizes(); + redraw(); +} +void MainWindow::reset_layout() +{ + detach_transport(false);detach_mixer(false);detach_editor(0,false);detach_editor(1,false);detach_piano(false); + settings_.transport_x=settings_.transport_y=settings_.sequencer_x=settings_.sequencer_y=settings_.graph_x=settings_.graph_y=80; + settings_.mixer_x=settings_.mixer_y=settings_.piano_x=settings_.piano_y=100; + settings_.transport_width=800;settings_.transport_height=300;settings_.mixer_width=1056;settings_.mixer_height=600; + settings_.sequencer_width=settings_.graph_width=1100;settings_.sequencer_height=settings_.graph_height=650; + settings_.piano_width=900;settings_.piano_height=620;settings_.track_header_width=180; + sequencer_->header_width(180);tabs_->value(editors_[0].page);apply_ui_preferences(); +} +void MainWindow::show_track_inspector(bool visible) +{ + const int left=visible ? 216 : 12; + if(visible)sidebar_->show();else sidebar_->hide(); + tabs_->resize(left,tabs_->y(),w()-left-8,tabs_->h()); + auto *menu=dynamic_cast(child(0)); + if(menu) { auto *item=const_cast(menu->find_item("View/Track inspector")); + if(item) { if(visible)item->set();else item->clear(); } } + init_sizes();redraw(); +} +void MainWindow::resize(int x,int y,int width,int height) +{ + UiDouble_Window::resize(x,y,width,height); + if(transport_group_)layout_transport(); +} +void MainWindow::layout_transport() +{ + if(layout_transport_busy_ || !transport_group_ || transport_items_.empty())return; + layout_transport_busy_=true; + int width=transport_detached_ && transport_window_ ? transport_window_->w() : w(); + int origin=transport_detached_ ? 32 : 28; + bool compact=transport_detached_ && width<1220; + int left=12,top=12; + transport_group_->resizable(nullptr); + for(const auto &item:transport_items_) { + int x,y; + if(compact) { + if(left+item.w+(item.widget==tempo_display_ ? 36 : 0)>width-12){left=12;top+=40;} + x=left;y=origin+top;left+=item.w+(item.widget==tempo_display_ ? 36 : 0)+8; + } else {x=item.x*width/1280;y=origin+item.y;} + item.widget->resize(x,y,item.w,item.h); + } + int height=compact ? top+48 : 90; + transport_group_->resize(0,origin,width,height); + // Group resize can transform children: restore exact layout after it. + left=12;top=12; + for(const auto &item:transport_items_) { + int x,y;if(compact){if(left+item.w+(item.widget==tempo_display_ ? 36 : 0)>width-12){left=12;top+=40;}x=left;y=origin+top;left+=item.w+(item.widget==tempo_display_ ? 36 : 0)+8;} + else{x=item.x*width/1280;y=origin+item.y;} + item.widget->resize(x,y,item.w,item.h); + } + transport_group_->init_sizes(); + if(transport_detached_ && transport_window_ && transport_window_->h()resize(transport_window_->x(),transport_window_->y(),width,origin+height+8); + layout_transport_busy_=false; +} +void MainWindow::detach_transport(bool detached) +{ + if (detached==transport_detached_ || !transport_group_) return; + if (detached && !transport_window_) { + auto *current=UiGroup::current(); UiGroup::current(nullptr); + transport_window_.reset(new PanelWindow(static_cast(settings_.transport_width),static_cast(settings_.transport_height),"xtc — Transport (close to dock)",this)); + auto *dock=new UiButton(12,4,140,24,"Dock transport"); + dock->callback([](UiWidget *,void *c){static_cast(c)->detach_transport(false);},this); + xtc_ui::style(transport_window_.get()); + transport_window_->end(); transport_window_->size_range(400,130); + transport_window_->callback([](UiWidget *,void *c){static_cast(c)->detach_transport(false);},this); + restore_panel(transport_window_.get(),static_cast(settings_.transport_x),static_cast(settings_.transport_y),static_cast(settings_.transport_width),static_cast(settings_.transport_height),400,130); + UiGroup::current(current); + } + transport_detached_=detached;settings_.transport_detached=detached; + transport_group_->parent()->remove(transport_group_); + if (detached) { + transport_window_->add(transport_group_); + transport_group_->resize(0,32,transport_window_->w(),90); + transport_window_->resizable(transport_group_); + } else { + transport_window_->resizable(nullptr); + add(transport_group_); transport_group_->resize(0,28,w(),90); + transport_window_->hide(); + } + int delta=detached ? -90 : 90; + tabs_->resize(tabs_->x(),tabs_->y()+delta,tabs_->w(),tabs_->h()-delta); + sidebar_->resize(sidebar_->x(),sidebar_->y()+delta,sidebar_->w(),sidebar_->h()-delta); + init_sizes(); + auto *item=const_cast(menu_->find_item("View/Detach transport")); + if (detached) item->set(); else item->clear(); + layout_transport(); + if (detached) transport_window_->show(); + redraw(); +} + +void MainWindow::detach_mixer(bool detached) +{ + if (detached==mixer_detached_ || !mixer_) return; + if (detached && !mixer_window_) { + auto *current=UiGroup::current(); UiGroup::current(nullptr); + mixer_window_.reset(new PanelWindow(1056,600,"xtc — Mixer (close to dock)",this)); + auto *dock=new UiButton(12,6,140,28,"Dock mixer"); + dock->callback([](UiWidget *,void *c){static_cast(c)->detach_mixer(false);},this); + xtc_ui::style(mixer_window_.get()); + mixer_window_->end(); mixer_window_->size_range(600,420); + mixer_window_->callback([](UiWidget *,void *c){static_cast(c)->detach_mixer(false);},this); + restore_panel(mixer_window_.get(),static_cast(settings_.mixer_x),static_cast(settings_.mixer_y),static_cast(settings_.mixer_width),static_cast(settings_.mixer_height),600,420); + UiGroup::current(current); + } + mixer_detached_=detached;settings_.mixer_detached=detached; + mixer_->parent()->remove(mixer_); + if (detached) { + mixer_page_->resizable(nullptr); + mixer_window_->add(mixer_); mixer_->resize(12,44,mixer_window_->w()-24,mixer_window_->h()-56); + mixer_window_->resizable(mixer_); mixer_placeholder_->show(); + } else { + mixer_window_->resizable(nullptr); + mixer_page_->add(mixer_); + mixer_->resize(mixer_page_->x()+12,mixer_page_->y()+10,mixer_page_->w()-24,mixer_page_->h()-24); + mixer_page_->resizable(mixer_); mixer_placeholder_->hide(); mixer_window_->hide(); + } + mixer_->show(); mixer_page_->init_sizes(); + auto *item=const_cast(menu_->find_item("View/Detach mixer")); + if (detached) item->set(); else item->clear(); + if (detached) mixer_window_->show(); + redraw(); +} + +void MainWindow::draw() +{ + // JUCE 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(); + UiDouble_Window::draw(); +} + +/** + * @brief Removes the status timer and releases backend and recording state. + * + * A backend that fails to close is intentionally retained with its recorder + * until process exit so active callbacks never observe freed state. + */ +MainWindow::~MainWindow() +{ + Ui::remove_timeout(timer_callback, this); + // Return ownership before member windows and the main widget tree destruct. + detach_transport(false); + detach_mixer(false);detach_editor(0,false);detach_editor(1,false);detach_piano(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. + if (backend_ != nullptr && xtc_backend_close(backend_) != XTC_OK) + std::fputs("Failed to close JACK during GUI destruction; backend and recording retained until process exit.\n", + stderr); + else if (recorder_) { + xtc_result result = xtc_recorder_finish(recorder_); + std::fprintf(stderr, "Recording finalized during shutdown (%s): %s\n", xtc_result_string(result), + xtc_recorder_recovery_path(recorder_)); + xtc_recorder_destroy(recorder_); + } +} + +/** + * @brief Shows an operation failure and a context-sensitive hint. + * + * @param operation Human-readable operation name; names containing + * "Project" select a file and permission hint. + * @param result Failing result code whose name is included in the message. + */ +void MainWindow::report_error(const char *operation, xtc_result result) +{ + char text[384]; + const char *hint = std::strstr(operation, "Project") != nullptr + ? "Check the project path, file format and write permissions." + : result == XTC_ERR_OUT_OF_MEMORY ? "Insufficient memory; the command was not accepted." + : result == XTC_ERR_CAPACITY ? "A project capacity limit was reached." + : result == XTC_ERR_NOT_FOUND || result == XTC_ERR_INVALID_ARGUMENT ? "Check the selected target and entered values." + : result == XTC_ERR_UNSUPPORTED ? "Check optional build support and the requested format or plugin layout." + : "Check the operation details; for audio failures use Reconnect to retry."; + std::snprintf(text, sizeof(text), "%s: %s. %s", operation, xtc_result_string(result), hint); + set_label(error_, text); +} + +/** + * @brief Edit handler passed to the modular graph widget. + * + * Applies the edit to a candidate graph, clones the project around it, and + * submits the new graph to the engine when connected. The live project is + * replaced only after every step succeeds. + * + * @param context MainWindow * passed as the graph widget owner context. + * @param edit Graph edit to apply. + * @param out_added_id Optional output receiving the new node ID when the edit + * adds a node; untouched otherwise or when nullptr. + * @return XTC_OK on success, XTC_ERR_INVALID_STATE while recording, or an + * error from the graph, project, or backend operation. + */ +xtc_result MainWindow::edit_graph(void *context, const xtc_graph_edit &edit, size_t *out_added_id) +{ + auto *window = static_cast(context); + if (window->recorder_) + return XTC_ERR_INVALID_STATE; + xtc_graph *candidate = nullptr; + size_t added_id = SIZE_MAX; + xtc_result result = xtc_graph_apply_edit(xtc_project_graph(window->project_.get()), &edit, &candidate, &added_id); + if (result != XTC_OK) + return result; + xtc_project *replacement = nullptr; + result = xtc_project_clone(window->project_.get(), &replacement); + if (result == XTC_OK) + result = xtc_project_set_graph(replacement, candidate); + xtc_graph_destroy(candidate); + if (result == XTC_OK) result = xtc_history_reserve(window->history_.get()); + if (result == XTC_OK && window->ready_) + result = xtc_backend_submit(window->backend_, xtc_project_graph(replacement)); + if (result != XTC_OK) { + xtc_project_destroy(replacement); + return result; + } + window->project_.reset(replacement); + window->graph_widget_->set_graph(xtc_project_graph(replacement)); + window->sequencer_->set_project(replacement, false); + window->mark_dirty(); + if (out_added_id != nullptr && (edit.kind == XTC_EDIT_ADD_NODE || edit.kind == XTC_EDIT_INSERT_GAIN)) + *out_added_id = added_id; + window->refresh_status(); + return XTC_OK; +} + +/** + * @brief Opens the JACK backend and submits the current graph. + * + * Uses the default backend configuration and leaves the engine stopped. + * Failure is reported and leaves the backend disconnected; a handle that + * opened but failed to accept the graph is retained for reconnect. + */ +void MainWindow::open_backend() +{ + xtc_backend_config config = {XTC_BACKEND_JACK, settings_.client_name, static_cast(settings_.max_frames), static_cast(settings_.channels)}; + xtc_result result = xtc_backend_open(&config, &backend_); + ready_ = result == XTC_OK; + running_ = false; + if (result != XTC_OK) + report_error("Open JACK failed", result); + else { + xtc_backend_status format{}; + result = xtc_backend_get_status(backend_, &format); + if (result == XTC_OK) + result = xtc_backend_set_transition_frames(backend_, static_cast(format.sample_rate)*static_cast(settings_.transition_ms)/1000U); + if (result == XTC_OK) + result = xtc_backend_submit(backend_, xtc_project_graph(project_.get())); + if (result != XTC_OK) { + ready_ = false; // Handle is retained for close/reconnect; graph was not installed. + report_error("Submit graph failed", result); + } else + set_label(error_, ""); + } + refresh_status(); +} + +/** + * @brief Starts the audio engine with the current project. + * + * Does nothing when already running. Reports an error when disconnected. + * Otherwise prepares the project and starts the engine. Missing or + * unreadable audio is reported as a warning after a successful start. + */ +void MainWindow::start_engine() +{ + if (!ready_) { + report_error("Audio is disconnected; use Reconnect", XTC_ERR_INVALID_STATE); + return; + } + if (running_) + return; + xtc_result result = configure_loop(project_.get(),settings_); + if(result==XTC_OK) result = xtc_backend_prepare_project(backend_, project_.get()); + if (result == XTC_OK) + result = start_configured_backend(); + if (result != XTC_OK) + report_error("Start failed; check audio and saved MIDI devices in Preferences", result); + else { + set_label(error_, midi_route_warning_.c_str()); + const char *issue = xtc_backend_asset_issue(backend_); + if (issue[0]) { + char message[640]; + std::snprintf(message, sizeof(message), + "Missing/unreadable audio (silent): %s. Use File/Relink selected audio or Load sampler WAV to repair.", issue); + std::string warning=midi_route_warning_; + if(!warning.empty())warning+=" "; + warning+=message; + set_label(error_, warning.c_str()); + } + } + refresh_status(); +} + +/** + * @brief Requests a graceful playback stop, or immediately finalizes recording. + * + * Does nothing when disconnected. Failures are reported in the window. + */ +void MainWindow::stop_engine() +{ + if (!ready_) + return; + xtc_result result = recorder_ ? xtc_backend_stop(backend_) : xtc_backend_request_stop(backend_); + if (result != XTC_OK) + report_error("Stop engine failed", result); + else { + set_label(error_, ""); + if (recorder_) + finish_recording(); + } + refresh_status(); +} + +xtc_result MainWindow::pause_playback() +{ + xtc_result result = recorder_ || !ready_ ? XTC_ERR_INVALID_STATE : xtc_backend_request_stop(backend_); + if (result != XTC_OK) + report_error("Pause failed; use Stop to finish recording or Reconnect audio", result); + else + set_label(error_, ""); + refresh_status(); + return result; +} + +xtc_result MainWindow::return_to_start() +{ + if (recorder_) { + report_error("Stop recording before returning to start", XTC_ERR_INVALID_STATE); + return XTC_ERR_INVALID_STATE; + } + xtc_result result = XTC_OK; + if (ready_) { + result = xtc_backend_stop(backend_); + if (result == XTC_OK) + result = xtc_backend_seek(backend_, 0); + } + if (result != XTC_OK) + report_error("Return to start failed", result); + else { + sequencer_->position(0); + set_label(error_, ""); + } + refresh_status(); + return result; +} + +/** + * @brief Closes and reopens the JACK backend, leaving the engine stopped. + * + * Refuses to run while recording. If closing fails the existing handle is + * retained for a later retry and the failure is reported. + */ +void MainWindow::reconnect_engine() +{ + if (recorder_) { + report_error("Stop recording before reconnecting", XTC_ERR_INVALID_STATE); + return; + } + xtc_result result = xtc_backend_close(backend_); + if (result != XTC_OK) { + report_error("Reconnect could not close JACK", result); + return; // Preserve the existing handle for a later retry. + } + backend_ = nullptr; + ready_ = false; + running_ = false; + open_backend(); +} + +/** + * @brief Polls the backend and updates labels, meters, and button states. + * + * When disconnected, shows offline text and disables transport controls. + * Otherwise updates the sequencer position, mixer peaks, status line, and + * error indicators from the latest engine status, then collects backend + * work. + */ +void MainWindow::update_history_controls() +{ + size_t undo_count = 0, redo_count = 0; + if (!menu_ || xtc_history_counts(history_.get(),&undo_count,&redo_count) != XTC_OK) return; + for (const char *path : {"Edit/Undo","Edit/Redo","Edit/Redo alternate"}) { + int index = menu_->find_index(path); + if (index >= 0) menu_->mode(index,(!recorder_ && (std::strcmp(path,"Edit/Undo") == 0 ? undo_count : redo_count)) ? 0 : UI_MENU_INACTIVE); + } +} +void MainWindow::refresh_status() +{ + refresh_workspace(); + update_history_controls(); + if (mixer_ && !ready_) mixer_->meters(xtc_meter_snapshot{}); + if(track_inserts_ && !ready_)track_inserts_->meters(xtc_meter_snapshot{},false); + bool can_play = false, can_record = false; + if (!ready_) { + set_label(indicator_, "Audio: disconnected"); + set_label(status_, "JACK: not connected — use Reconnect after starting the server"); + indicator_->labelcolor(ui_rgb_color(255, 155, 120)); + set_label(graph_state_, "Graph: offline — edits stored locally; Reconnect to play"); + } else { + xtc_backend_collect(backend_); // Completes an acknowledged graceful stop before reading status. + xtc_backend_status status = {}; + xtc_result result = xtc_backend_get_status(backend_, &status); + if (result != XTC_OK) { + report_error("Read engine status failed", result); + if(track_inserts_)track_inserts_->meters(xtc_meter_snapshot{},false); + set_label(indicator_, "Audio: status unavailable"); + set_label(graph_state_, "Graph: engine status unavailable"); + set_label(status_, "Unable to read JACK status — use Reconnect"); + indicator_->labelcolor(ui_rgb_color(255, 155, 120)); + } else { + uint64_t tick = 0; + if (xtc_project_sample_to_tick(project_.get(), status.sample_position, status.sample_rate, &tick) == XTC_OK) + sequencer_->position(tick); + running_ = status.running != 0; + const bool fault = status.server_lost || status.sample_rate_changed || status.process_result != XTC_OK; + can_play = !running_ && !fault; + can_record = !fault && recorder_ == nullptr; + const char *state = status.server_lost ? "Audio: server lost — Reconnect required" + : status.sample_rate_changed ? "Audio: sample rate changed — Reconnect required" + : status.process_result != XTC_OK ? "Audio: processing error" + : status.stopping ? "Audio: stopping — fading to silence" + : recorder_ ? "Audio: recording JACK input — Stop to finish" + : running_ ? "Audio: running — modular graph" + : "Audio: ready / stopped"; + set_label(indicator_, state); + indicator_->labelcolor(fault ? ui_rgb_color(255, 155, 120) + : running_ ? ui_rgb_color(125, 225, 160) + : ui_rgb_color(215, 215, 215)); + xtc_meter_snapshot meters{}; + if (xtc_backend_read_meters(backend_, &meters) == XTC_OK) mixer_->meters(meters); + else { meters.master_left=status.peak_left;meters.master_right=status.peak_right;mixer_->meters(meters); } + if(track_inserts_)track_inserts_->meters(meters,running_ && !fault,static_cast(settings_.refresh_ms)/1000); + char text[256]; + std::snprintf( + text, sizeof(text), + "JACK | %u Hz | %u frames | stereo | %u blocks | %u xruns | %s | %u asset issues | %u MIDI drops", + status.sample_rate, status.buffer_frames, status.processed_blocks, status.xruns, + xtc_result_string(status.process_result), status.asset_issues, status.midi_dropped); + set_label(status_, text); + if (fault) + set_label(graph_state_, "Graph: engine unavailable — Reconnect required"); + else if (status.submitted_revision != status.active_revision) + set_label(graph_state_, running_ ? "Graph: queued — engine still processing previous graph" + : "Graph: prepared — starts on Play"); + else + set_label(graph_state_, running_ ? "Graph: active in engine" : "Graph: stopped"); + } + } + if (can_play) + play_->activate(); + else + play_->deactivate(); + if (ready_ && running_ && !recorder_) + pause_->activate(); + else + pause_->deactivate(); + if (!recorder_) + home_->activate(); + else + home_->deactivate(); + if (can_record) + record_->activate(); + else + record_->deactivate(); + if (ready_ && (running_ || recorder_)) + stop_->activate(); + else + stop_->deactivate(); +} + +/** + * @brief Attempts to close the window after discarding unsaved changes. + * + * Refuses while recording. On success closes the backend, removes the status + * timer, hides the piano-roll window, and hides this window. + * + * @return True when the window closed; false when recording is active, the + * user cancels discarding, or the backend cannot close. + */ +bool MainWindow::close_window() +{ + if (recorder_) { + report_error("Stop recording before closing", XTC_ERR_INVALID_STATE); + return false; + } + if (!discard_changes()) + return false; + xtc_result result = xtc_backend_close(backend_); + if (result != XTC_OK) { + report_error("Close JACK failed; window kept open for retry", result); + return false; + } + backend_ = nullptr; + ready_ = false; + running_ = false; + if(!configuration_.empty() && configuration_valid_) { + xtc_result saved=save_preferences(); + if(saved!=XTC_OK) {report_error("Could not save application settings; window kept open for retry",saved);return false;} + } + closing_ = true; + Ui::remove_timeout(timer_callback, this); + for(auto &panel:editors_)if(panel.window)panel.window->hide(); + if (piano_window_) + piano_window_->hide(); + if (transport_window_) transport_window_->hide(); + if (mixer_window_) mixer_window_->hide(); + hide(); + return true; +} + +/** + * @brief Handles JUCE events for the window. + * + * Space without Ctrl/Alt/Meta toggles the audio engine and is consumed. + * + * @param event JUCE event code, such as UI_SHORTCUT. + * @return 1 when Space was consumed; otherwise the UiDouble_Window result. + */ +int MainWindow::panel_shortcut(int event) +{ + if (event == UI_SHORTCUT && dynamic_cast(Ui::focus()) && + (Ui::event_key() == ' ' || (Ui::event_state(UI_CTRL) && + (Ui::event_key() == 'z' || Ui::event_key() == 'y' || Ui::event_key() == 'd')))) return 0; + if (event == UI_SHORTCUT && Ui::event_key() == ' ' && (Ui::event_state() & (UI_CTRL | UI_ALT | UI_META)) == 0) { + if (running_) + stop_engine(); + else + start_engine(); + return 1; + } + if (event == UI_SHORTCUT) return menu_->test_shortcut() != nullptr; + return 0; +} + +int MainWindow::handle(int event) +{ + if (panel_shortcut(event)) return 1; + return UiDouble_Window::handle(event); +} + +/** + * @brief JUCE callback for the Play menu item and button. + * + * @param context MainWindow * whose engine is started. + */ +void MainWindow::play_callback(UiWidget *, void *context) +{ + static_cast(context)->start_engine(); +} + +/** + * @brief JUCE callback for the Stop menu item and button. + * + * @param context MainWindow * whose engine is stopped. + */ +void MainWindow::stop_callback(UiWidget *, void *context) +{ + static_cast(context)->stop_engine(); +} + +/** + * @brief JUCE callback for the Reconnect menu item and button. + * + * @param context MainWindow * whose backend is reopened. + */ +void MainWindow::reconnect_callback(UiWidget *, void *context) +{ + static_cast(context)->reconnect_engine(); +} + +/** + * @brief JUCE callback for the window close box, File/Quit, and close events. + * + * @param context MainWindow * whose close is requested; a refused close + * simply leaves the window open. + */ +void MainWindow::close_callback(UiWidget *, void *context) +{ + (void)static_cast(context)->close_window(); +} + +/** + * @brief JUCE menu callback that selects a tab page in the main window. + * + * @param widget Triggering menu item; window() must return the MainWindow. + * @param view UiWidget * tab page to make current, not the MainWindow. + */ +void MainWindow::view_callback(UiWidget *widget, void *view) +{ + auto *window = static_cast(widget->window()); + window->tabs_->value(static_cast(view)); + window->tabs_->redraw(); + for(auto &panel:window->editors_)if(view==panel.page && panel.detached)panel.window->show(); + if(view==window->piano_page_) {if(window->open_piano_roll()!=XTC_OK)window->tabs_->value(window->editors_[0].page);} + if (view==window->mixer_page_ && window->mixer_detached_) window->mixer_window_->show(); +} + +/** + * @brief JUCE help callback that shows the application name and version. + * + */ +void MainWindow::about_callback(UiWidget *, void *) +{ + ui_message("%s %s\nModular audio workstation\nArrangement markers, MIDI composition, audio editing and modular mixing.", + XTC_PROJECT_NAME, XTC_VERSION_STRING); +} + +/** + * @brief JUCE timeout callback that refreshes status and supervises recording. + * + * While recording, drains the recorder and stops the engine if draining + * fails or the backend reports a lost server, sample-rate change, or + * processing error. Reschedules itself every 0.1 seconds until closing. + * + * @param context MainWindow * registered with Ui::add_timeout(). + */ +void MainWindow::timer_callback(void *context) +{ + xtc_plugin_idle(); + auto *window = static_cast(context); + if (window->recorder_) { + xtc_result r = xtc_recorder_drain(window->recorder_); + xtc_backend_status status{}; + xtc_backend_get_status(window->backend_, &status); + if (r != XTC_OK || status.server_lost || status.sample_rate_changed || status.process_result != XTC_OK) + window->stop_engine(); + } + if(window->settings_.stop_after_tail && !window->settings_.loop_enabled && !window->recorder_) { + xtc_backend_status status{};uint64_t end=0; + if(xtc_backend_get_status(window->backend_,&status)==XTC_OK && status.running && + xtc_project_render_length(window->project_.get(),status.sample_rate,static_cast(window->settings_.tail_ms),&end)==XTC_OK && status.sample_position>=end) + window->stop_engine(); + } + window->refresh_status(); + window->follow_playhead(); + if (!window->closing_) + Ui::repeat_timeout(static_cast(window->settings_.refresh_ms)/1000,timer_callback,context); +} + +/** + * @brief Records the current project in history and marks the window dirty. + * + * Dependent widgets are refreshed with the current project and graph, and + * the title gains the unsaved marker. A history failure is reported. + */ +void MainWindow::mark_dirty() +{ + xtc_result history_result = xtc_history_record(history_.get(), project_.get()); + if (history_result != XTC_OK) + report_error("Undo history could not retain edit", history_result); + mixer_->set_project(project_.get()); + graph_widget_->set_graph(xtc_project_graph(project_.get())); + if (piano_) + piano_->set_project(project_.get(), piano_->clip()); + dirty_ = true; + update_history_controls(); + copy_label("xtc * — unsaved changes"); +} + +/** + * @brief Asks whether unsaved changes may be discarded. + * + * @return True when the project is clean, prompting is disabled, or the user + * chose Discard; false when the user cancels. + */ +bool MainWindow::discard_changes() +{ + return !dirty_ || !prompt_unsaved_ || + ui_choice("Discard unsaved project changes?", "Cancel", "Discard", nullptr) == 1; +} + +/** + * @brief Hook used by the sequencer to seek the audio engine. + * + * Does nothing when the backend is disconnected. Otherwise reads the engine + * sample rate, converts the tick position to samples, and seeks the backend. + * + * @param context MainWindow * passed as the sequencer owner context. + * @param tick Target timeline position in project ticks. + * @return XTC_OK when disconnected or the seek succeeded, or the status, + * conversion, or backend seek error. + */ +xtc_result MainWindow::seek(void *context, uint64_t tick) +{ + auto *window = static_cast(context); + if (!window->ready_) + return XTC_OK; + xtc_backend_status status{}; + xtc_result r = xtc_backend_get_status(window->backend_, &status); + uint64_t sample = 0; + if (r == XTC_OK) + r = xtc_project_tick_to_sample(window->project_.get(), tick, status.sample_rate, &sample); + return r == XTC_OK ? xtc_backend_seek(window->backend_, sample) : r; +} + +/** + * @brief Saves the current project to a path and updates the window title. + * + * On success the path is remembered and the unsaved marker is cleared. + * + * @param path Destination project path; nullptr is invalid. + * @return XTC_OK on success, XTC_ERR_INVALID_ARGUMENT for nullptr, + * XTC_ERR_INVALID_STATE while recording, or the save error. + */ +xtc_result MainWindow::save_project(const char *path) +{ + if (recorder_) + return XTC_ERR_INVALID_STATE; + if (path == nullptr) + return XTC_ERR_INVALID_ARGUMENT; + std::string saved_path(path); + xtc_result r = xtc_project_save(project_.get(), path); + if (r == XTC_OK) { + path_ = saved_path; + dirty_ = false; + copy_label(path_.c_str()); + } + return r; +} + +/** + * @brief Installs a candidate project as the active project. + * + * Rejected while recording. Optionally builds fresh undo history, stops and + * prepares the connected backend, or validates offline audio and MIDI plans + * when disconnected. On success the window takes ownership of candidate, + * rebinds widgets, resets the graph layout, clears the dirty flag, and shows + * any missing-asset warning. On failure ownership stays with the caller. + * + * @param candidate Project to install; must be non-null. + * @param reset_history True to discard existing undo/redo history and start + * from candidate. + * @return XTC_OK on success, XTC_ERR_INVALID_STATE while recording, or an + * error from history creation, the backend, or offline planning. + */ +xtc_result MainWindow::install_project(xtc_project *candidate, bool reset_history) +{ + if (recorder_) + return XTC_ERR_INVALID_STATE; + std::unique_ptr new_history(nullptr, xtc_history_destroy); + if (reset_history) { + xtc_history *h = nullptr; + xtc_result result = xtc_history_create(candidate, &h); + if (result != XTC_OK) + return result; + new_history.reset(h); + } + xtc_result r = XTC_OK; + if (ready_) { + r = xtc_backend_stop(backend_); + if (r == XTC_OK) + r = xtc_backend_seek(backend_, 0); + if(r==XTC_OK) r=configure_loop(candidate,settings_); + if (r == XTC_OK) + r = xtc_backend_prepare_project(backend_, candidate); + } + xtc_audio_plan *offline = nullptr; + if (r == XTC_OK && !ready_) + r = xtc_audio_plan_create(candidate, static_cast(settings_.offline_rate), &offline); + xtc_midi_plan *offline_midi = nullptr; + if (r == XTC_OK && !ready_) + r = xtc_midi_plan_create(candidate, static_cast(settings_.offline_rate), &offline_midi); + size_t sample_issues=xtc_midi_plan_asset_issues(offline_midi); + xtc_midi_plan_destroy(offline_midi); + if (r == XTC_OK && !ready_) { + xtc_plan *graph = nullptr; + r = xtc_graph_compile(xtc_project_graph(candidate),static_cast(settings_.offline_rate),256,&graph); + xtc_plan_destroy(graph); + } + if (r != XTC_OK) { + xtc_audio_plan_destroy(offline); + refresh_status(); // A failed install may have stopped playback. + return r; + } + std::string issue = ready_ ? xtc_backend_asset_issue(backend_) : ""; + if (offline) { + const char *path = ""; + xtc_audio_plan_issues(offline, &path); + issue = path; + xtc_audio_plan_destroy(offline); + } + if(sample_issues)issue="Sampler WAV missing/invalid; File/Load sampler WAV relinks the selected track"; + if (piano_window_) + piano_window_->hide(); + if (piano_) + piano_->set_project(candidate, UINT64_MAX); + project_.reset(candidate); + mixer_->set_project(candidate); + if (reset_history) { + history_.swap(new_history); + for(auto &snapshot:mix_snapshots_)snapshot.reset(); + if(track_inserts_)track_inserts_->clear_session(); + xtc_tap_tempo_reset(&tap_state_); + } + graph_widget_->reset_layout(); + graph_widget_->set_graph(xtc_project_graph(candidate)); + sequencer_->set_project(candidate,true,!reset_history); + if (reset_history) + tabs_->value(tabs_->child(0)); + dirty_ = false; + if (!issue.empty()) { + char text[640]; + std::snprintf(text, sizeof(text), "Missing/unreadable audio (silent): %s. Use File/Relink selected audio or Load sampler WAV to repair.", + issue.c_str()); + set_label(error_, text); + } else + set_label(error_, ""); + refresh_status(); + return XTC_OK; +} + +/** + * @brief Loads a project from a path and makes it the current project. + * + * The file is loaded into a candidate project and installed only on success. + * + * @param path Source project path; nullptr is invalid. + * @return XTC_OK on success, XTC_ERR_INVALID_ARGUMENT for nullptr, or an + * error from loading or installing the project. + */ +xtc_result MainWindow::open_project(const char *path) +{ + BusyOperation busy(*this,status_,"Loading and preparing project…"); + if (path == nullptr) + return XTC_ERR_INVALID_ARGUMENT; + project_issue_.clear(); + std::string loaded_path(path); + xtc_project *candidate = nullptr; + xtc_result r = xtc_project_create(&candidate); + xtc_project_diagnostic diagnostic{}; + if (r == XTC_OK) { + r = xtc_project_load_diagnostic(candidate,path,&diagnostic); + if (r != XTC_OK) { + project_issue_ = "Project load line " + std::to_string(diagnostic.line) + " (" + diagnostic.record + "): " + diagnostic.message; + report_error(project_issue_.c_str(),r); + } + } + if (r == XTC_OK) + r = install_project(candidate); + if (r != XTC_OK) { + if (candidate) { + const xtc_graph *graph = xtc_project_graph(candidate); + size_t nodes = 0, edges = 0; + xtc_graph_get_counts(graph,&nodes,&edges); + for (size_t i = 0; i < nodes; ++i) { + xtc_node_info node{}; xtc_plugin_spec spec{}; + if (xtc_graph_get_node(graph,i,&node) != XTC_OK || node.type != XTC_NODE_PLUGIN || + xtc_graph_get_plugin(graph,node.id,&spec) != XTC_OK) continue; + double rate = sample_rate(); xtc_backend_status status{}; + if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate; + xtc_plugin *check = nullptr; + xtc_result plugin_result = xtc_plugin_create(&spec,rate,256,&check); + xtc_plugin_destroy(check); + if (plugin_result != XTC_OK) { + project_issue_ = std::string("Missing/incompatible plugin ") + spec.label + " at " + spec.path + + "; restore a compatible module and retry Open. Saved document retained"; + report_error(project_issue_.c_str(),plugin_result); + break; + } + } + } + xtc_project_destroy(candidate); + } else { + path_ = loaded_path; + copy_label(path_.c_str()); + } + return r; +} + +xtc_result MainWindow::new_project() +{ + if (recorder_) + return XTC_ERR_INVALID_STATE; + xtc_project *candidate = nullptr; + xtc_result result = xtc_project_create(&candidate); + if(result==XTC_OK) project_defaults(candidate); + if (result == XTC_OK) + result = install_project(candidate); + if (result != XTC_OK) + xtc_project_destroy(candidate); + else { + path_.clear(); + copy_label("xtc — Untitled"); + } + return result; +} + +/** + * @brief JUCE menu callback for the File/New, Open, Save, and Save As items. + * + * Reads the action from the selected menu label, confirms discarding unsaved + * changes for New and Open, and uses a native file chooser unless Save can + * reuse the remembered path. Refuses file operations while recording and + * reports failures in the window. + * + * @param widget Triggering UiMenu_Bar whose selected label names the action. + * @param context MainWindow * owning the project and file path. + */ +void MainWindow::file_callback(UiWidget *widget, void *context) +{ + auto *window = static_cast(context); + if (window->recorder_) { + window->report_error("Stop recording before file operations", XTC_ERR_INVALID_STATE); + return; + } + auto *menu = static_cast(widget); + const char *action = menu->mvalue()->label(); + xtc_result r = XTC_OK; + window->project_issue_.clear(); + if (std::strcmp(action, "New") == 0) { + if (!window->discard_changes()) + return; + r = window->new_project(); + } else { + bool open = std::strcmp(action, "Open") == 0; + if (open && !window->discard_changes()) + return; + if (!open && std::strcmp(action, "Save") == 0 && !window->path_.empty()) + r = window->save_project(window->path_.c_str()); + else { + UiNative_File_Chooser chooser; + chooser.title(open ? "Open project" : "Save project"); + chooser.type(open ? UiNative_File_Chooser::BROWSE_FILE : UiNative_File_Chooser::BROWSE_SAVE_FILE); + chooser.filter("xtc projects *.xtc"); + if (!open) + chooser.options(UiNative_File_Chooser::SAVEAS_CONFIRM); + int result = chooser.show(); + if (result == 1) + return; + if (result < 0) + r = XTC_ERR_IO; + else + r = open ? window->open_project(chooser.filename()) : window->save_project(chooser.filename()); + } + } + if (r != XTC_OK) + window->report_error(window->project_issue_.empty() ? "Project operation failed" : window->project_issue_.c_str(), r); +} + +/** + * @brief Hook called by arrangement widgets before mutating the project. + * + * Refuses edits while recording. When connected, stops the engine so the + * mutation cannot race the audio thread, then refreshes the status display. + * + * @param context MainWindow * passed as the widget owner context. + * @return XTC_OK when disconnected or the stop succeeded, + * XTC_ERR_INVALID_STATE while recording, or the backend stop error. + */ +xtc_result MainWindow::before_arrangement_edit(void *context) +{ + auto *window = static_cast(context); + if (window->recorder_) return XTC_ERR_INVALID_STATE; + xtc_result r = xtc_history_reserve(window->history_.get()); + if (r == XTC_OK && window->ready_) r = xtc_backend_stop(window->backend_); + window->refresh_status(); + return r; +} + +/** + * @brief Imports a WAV file as an audio clip in a cloned project. + * + * Stops the engine first, creates an "Audio" track when none is selected, + * and places the clip at the sequencer cursor. The clone replaces the live + * project only after every step succeeds. + * + * @param path Path to the WAV file to import. + * @return XTC_OK on success, or an error from the engine stop, clone, import, + * or backend preparation. + */ +xtc_result MainWindow::import_audio(const char *path) +{ + BusyOperation busy(*this,status_,"Importing audio and preparing playback…"); + xtc_result r = before_arrangement_edit(this); + if (r != XTC_OK) + return r; + xtc_project *candidate = nullptr; + r = xtc_project_clone(project_.get(), &candidate); + uint64_t track = sequencer_->selected_track(), clip = 0; + if (r == XTC_OK && track == UINT64_MAX) + r = xtc_project_add_track(candidate, "Audio", &track); + if (r == XTC_OK) + r = xtc_project_import_audio(candidate, track, path, sequencer_->position(), &clip); + if (r == XTC_OK && ready_) + r = xtc_backend_prepare_project(backend_, candidate); + if (r != XTC_OK) { + xtc_project_destroy(candidate); + return r; + } + project_.reset(candidate); + sequencer_->set_project(candidate, false); + sequencer_->select(track,clip); + graph_widget_->set_graph(xtc_project_graph(candidate)); + mark_dirty(); + refresh_status(); + return XTC_OK; +} + +/** + * @brief Starts recording JACK input to a WAV file. + * + * Requires a connected backend and no active recording. The engine is + * stopped, the current position and selected track are remembered, and a + * stereo recorder at the engine sample rate is attached before starting. + * Failure leaves the window without a recorder. + * + * @param path Destination WAV path chosen by the user. + * @return XTC_OK when recording started, XTC_ERR_INVALID_STATE when + * disconnected or already recording, or another backend or recorder + * error. + */ +xtc_result MainWindow::record_audio(const char *path) +{ + if (!ready_ || recorder_ || settings_.loop_enabled) + return XTC_ERR_INVALID_STATE; + xtc_result r = xtc_backend_stop(backend_); + xtc_backend_status status{}; + if (r == XTC_OK) + r = xtc_backend_get_status(backend_, &status); + if (r == XTC_OK) + r = xtc_project_sample_to_tick(project_.get(), status.sample_position, status.sample_rate, &recording_tick_); + if (r == XTC_OK) + r = xtc_backend_prepare_project(backend_, project_.get()); + if (r == XTC_OK) + r = xtc_recorder_create(path, status.sample_rate, static_cast(status.sample_rate) * static_cast(settings_.recording_seconds), &recorder_); + if (r != XTC_OK) + return r; + recording_path_ = path; + recording_track_ = sequencer_->selected_track(); + r = xtc_backend_set_recorder(backend_, recorder_); + if (r == XTC_OK) + r = start_configured_backend(); + if (r != XTC_OK) { + (void)xtc_backend_set_recorder(backend_, nullptr); + xtc_recorder_destroy(recorder_); + recorder_ = nullptr; + } + refresh_status(); + return r; +} + +/** + * @brief Finalizes the active recording and adds the take to the project. + * + * Must run only after the engine has stopped, so no audio callback can + * reference the recorder ring. The recorder is detached, finished, and + * destroyed. A failed finalize or empty take is reported and not added. If + * the subsequent import fails, the saved WAV path is reported for recovery. + */ +void MainWindow::finish_recording() +{ + // Called only after successful backend_stop: no producer can reference ring. + (void)xtc_backend_set_recorder(backend_, nullptr); + uint64_t frames = 0; + (void)xtc_recorder_status(recorder_, &frames); + xtc_result r = xtc_recorder_finish(recorder_); + std::string recovery_path = xtc_recorder_recovery_path(recorder_); + xtc_recorder_destroy(recorder_); + recorder_ = nullptr; + if (r != XTC_OK) { + char text[768]; + std::snprintf(text, sizeof(text), + "Recording failed (%s). Retained take/temporary: %s. Recover with xtc-check --recover-recording. Not added.", + xtc_result_string(r), recovery_path.c_str()); + set_label(error_, text); + return; + } + if (!frames) { + set_label(error_, "Empty recording saved; no clip added."); + return; + } + xtc_project *candidate = nullptr; + r = xtc_project_clone(project_.get(), &candidate); + uint64_t clip = 0, track = recording_track_; + if (r == XTC_OK && track == UINT64_MAX) + r = xtc_project_add_track(candidate, "Recording", &track); + if (r == XTC_OK) + r = xtc_project_import_audio(candidate, track, recording_path_.c_str(), recording_tick_, &clip); + if (r == XTC_OK) r = xtc_history_reserve(history_.get()); + if (r == XTC_OK) + r = xtc_backend_prepare_project(backend_, candidate); + if (r != XTC_OK) { + xtc_project_destroy(candidate); + char text[768]; + std::snprintf(text, sizeof(text), "Take saved to %s; adding clip failed: %s. Import the WAV to recover.", + recording_path_.c_str(), xtc_result_string(r)); + set_label(error_, text); + return; + } + project_.reset(candidate); + sequencer_->set_project(candidate, false); + graph_widget_->set_graph(xtc_project_graph(candidate)); + mark_dirty(); +} + +/** + * @brief JUCE callback for the audio import, record, and relink commands. + * + * The triggering widget supplies the menu label; the Record button uses the + * "Record WAV" default. A native file chooser selects the file, after which + * the selected clip is relinked, recording starts, or a WAV is imported. + * Failures are reported in the window. + * + * @param widget Triggering UiMenu_Bar or UiButton; menu labels select the + * action. + * @param context MainWindow * owning the project and sequencer. + */ +void MainWindow::audio_file_callback(UiWidget *widget, void *context) +{ + auto *window = static_cast(context); + auto *menu = dynamic_cast(widget); + 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_) { + window->report_error("Stop recording first", XTC_ERR_INVALID_STATE); + return; + } + UiNative_File_Chooser chooser; + chooser.title(recording ? "Record JACK input to WAV" : relink ? "Relink selected clip" : "Import WAV"); + chooser.type(recording ? UiNative_File_Chooser::BROWSE_SAVE_FILE : UiNative_File_Chooser::BROWSE_FILE); + chooser.filter("WAV audio\t*.wav"); + // Recordings require a new destination; existing files are never replaced. + int chosen = chooser.show(); + if (chosen == 1) + return; + xtc_result r = chosen < 0 ? XTC_ERR_IO : XTC_OK; + if (r == XTC_OK && relink) { + r = before_arrangement_edit(window); + if (r == XTC_OK) + r = xtc_project_set_clip_asset(window->project_.get(), window->sequencer_->selected_clip(), + chooser.filename()); + if (r == XTC_OK) { + window->mark_dirty(); + window->sequencer_->redraw(); + } + } else if (r == XTC_OK) + r = recording ? window->record_audio(chooser.filename()) : window->import_audio(chooser.filename()); + if (r != XTC_OK) + window->report_error(recording ? "Project recording failed; choose a new WAV path in a writable directory" + : "Project audio operation failed", r); +} + +/** + * @brief Shows the piano roll for the selected MIDI clip. + * + * The piano-roll window is created on first use and reused afterwards. + * + * @return XTC_OK on success, or XTC_ERR_INVALID_STATE when the selected clip + * does not exist or is not MIDI. + */ +xtc_result MainWindow::open_piano_roll() +{ + xtc_clip_info clip{}; + if (xtc_project_find_clip(project_.get(), sequencer_->selected_clip(), &clip) != XTC_OK || !clip.midi) + return XTC_ERR_INVALID_STATE; + if (!piano_window_) { + piano_window_ = std::make_unique(900, 620, "xtc — Piano roll (close to dock)",this); + piano_window_->begin(); + piano_content_=new UiGroup(0,0,900,620);piano_content_->begin(); + auto *menu = new UiMenu_Bar(12, 8, 876, 30); + const char *commands[] = {"Notes/Grid (G)", "Notes/Filter (F)", "Notes/Velocity and channel (V)", + "Notes/Quantize (Q)", "Notes/Transpose (T)", "Notes/Repeat (Ctrl+D)", + "Notes/Select filtered (Ctrl+A)", "Notes/Delete"}; + for (int i = 0; i < 8; ++i) + menu->add(commands[i], 0, [](UiWidget *widget, void *data) { + auto *owner = static_cast(data); + auto *bar = static_cast(widget); + owner->piano_->command(static_cast(bar->mvalue() - bar->menu())); + }, this); + menu->add("Edit/Undo",UI_CTRL+'z',history_callback,this); + menu->add("Edit/Redo",UI_CTRL+UI_SHIFT+'z',history_callback,this); + menu->add("Edit/Redo alternate",UI_CTRL+'y',history_callback,this); + piano_snap_ = new UiChoice(60,46,150,26,"Snap"); + for (size_t i = 0; i < xtc_ui::snap_count; ++i) { int item = piano_snap_->add("Snap"); piano_snap_->replace(item,xtc_ui::snap_labels[i]); } + piano_snap_->callback([](UiWidget *widget, void *c) { + auto *w = static_cast(c); int value = static_cast(widget)->value(); + if (value >= 0 && static_cast(value) < xtc_ui::snap_count) (void)w->piano_->set_grid(xtc_ui::snap_ticks[value]); + },this); + note_velocity_ = new UiValue_Slider(340,46,300,26,"Note velocity"); + note_velocity_->align(UI_ALIGN_LEFT); note_velocity_->value(100); + note_velocity_->tooltip("Selected note MIDI velocity, 1..127. Instrument patches determine the response."); + note_velocity_->type(UI_HOR_FILL_SLIDER); note_velocity_->bounds(1,127); note_velocity_->step(1); + note_velocity_->when(UI_WHEN_RELEASE); + note_velocity_->callback([](UiWidget *widget, void *c) { + auto *w = static_cast(c); + (void)w->piano_->set_velocity(w->piano_->selected_note(),static_cast(static_cast(widget)->value())); + },this); + auto *scroll = new UiScroll(12,84,876,404); + piano_ = new PianoRollWidget(12,84, project_.get(), clip.id, before_arrangement_edit, this); + piano_->callback( + [](UiWidget *, void *c) { + static_cast(c)->mark_dirty(); + }, + this); + scroll->end(); + velocity_lane_ = new VelocityLane(12,500,876,108,*piano_); + piano_content_->resizable(scroll);piano_content_->end(); + piano_window_->end(); + piano_window_->callback([](UiWidget *,void *c){static_cast(c)->detach_piano(false);},this); + piano_window_->size_range(800,520); + xtc_ui::style(piano_window_.get()); + piano_window_->resizable(piano_content_); + scroll->scroll_to(0, 60 * 16); + auto px=settings_.piano_x,py=settings_.piano_y,pw=settings_.piano_width,ph=settings_.piano_height; + collect_preferences();settings_.piano_x=px;settings_.piano_y=py;settings_.piano_width=pw;settings_.piano_height=ph; + apply_ui_preferences(); + } else + piano_->set_project(project_.get(), clip.id); + refresh_workspace(); + detach_piano(settings_.piano_detached!=0); + if(piano_detached_)piano_window_->show();else {tabs_->value(piano_page_);tabs_->redraw();} + return XTC_OK; +} + +/** + * @brief JUCE callback for the Piano roll menu item and button. + * + * Refuses to open the editor while recording and asks the user to select a + * MIDI clip when open_piano_roll() fails. + * + * @param context MainWindow * owning the piano roll. + */ +void MainWindow::piano_callback(UiWidget *, void *context) +{ + auto *window = static_cast(context); + if (window->recorder_) { + window->report_error("Stop recording before editing MIDI", XTC_ERR_INVALID_STATE); + return; + } + xtc_result r = window->open_piano_roll(); + if (r != XTC_OK) + set_label(window->error_, "Select a MIDI clip, then open Piano roll."); +} + +/** + * @brief JUCE callback for the Panic menu item and button. + * + * When the backend is connected, sends a panic request that silences held + * instrument notes and reports failures. + * + * @param context MainWindow * owning the backend. + */ +void MainWindow::panic_callback(UiWidget *, void *context) +{ + auto *window = static_cast(context); + if (!window->ready_) + return; + xtc_result r = xtc_backend_panic(window->backend_); + if (r != XTC_OK) + window->report_error("Panic failed", r); +} + +/** + * @brief Applies one undo or redo step from the project history. + * + * The selected snapshot is installed without resetting history and then + * accepted, leaving the window marked dirty. + * + * @param redo True to redo, false to undo. + * @return XTC_OK on success, XTC_ERR_INVALID_STATE while recording, + * XTC_ERR_NOT_FOUND when no step is available, or another error from + * checkout, install, or accept. + */ +xtc_result MainWindow::undo(bool redo) +{ + if (recorder_) + return XTC_ERR_INVALID_STATE; + const uint64_t track = sequencer_->selected_track(), clip = sequencer_->selected_clip(); + const uint64_t mixer_track = mixer_->selected_track(); + const size_t node = graph_widget_->selected_node(); + const uint64_t piano_clip = piano_ ? piano_->clip() : UINT64_MAX; + const uint64_t note = piano_ ? piano_->selected_note() : UINT64_MAX; + const bool piano_visible = piano_window_ && piano_window_->shown(); + xtc_project *candidate = nullptr; + xtc_result r = xtc_history_checkout(history_.get(), redo ? 1 : 0, &candidate); + if (r == XTC_OK) + r = install_project(candidate, false); + if (r != XTC_OK) { + xtc_project_destroy(candidate); + return r; + } + xtc_clip_info selected_clip{}; + if (xtc_project_find_clip(project_.get(),clip,&selected_clip) == XTC_OK) + sequencer_->select(selected_clip.track_id,clip); + else sequencer_->select(track); + mixer_->select_track(mixer_track); + graph_widget_->select_node(node); + xtc_clip_info info{}; + if (piano_ && xtc_project_find_clip(project_.get(),piano_clip,&info) == XTC_OK && info.midi) { + piano_->set_project(project_.get(),piano_clip); piano_->select_note(note); + if (piano_visible) piano_window_->show(); + } + r = xtc_history_accept(history_.get(), redo ? 1 : 0); + dirty_ = true; + update_history_controls(); + copy_label("xtc * — unsaved changes"); + return r; +} + +/** + * @brief JUCE menu callback for the Undo and Redo menu items. + * + * The selected menu label starting with "Redo" chooses redo; all other + * labels undo. Failures other than XTC_ERR_NOT_FOUND are shown in the window. + * + * @param widget UiMenu_Bar whose selected item triggered the callback. + * @param context MainWindow * owning the menu and history. + */ +void MainWindow::history_callback(UiWidget *widget, void *context) +{ + auto *window = static_cast(context); + auto *menu = static_cast(widget); + bool redo = std::strncmp(menu->mvalue()->label(), "Redo", 4) == 0; + xtc_result r = window->undo(redo); + if (r != XTC_OK && r != XTC_ERR_NOT_FOUND) + 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,static_cast(settings_.clip_ticks),&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() +{ + if(track_inserts_)track_inserts_->refresh(); + if (loop_button_) { + loop_button_->value(settings_.loop_enabled != 0); + if (recorder_) + loop_button_->deactivate(); + else + loop_button_->activate(); + } + if (snap_) + snap_->value(xtc_ui::snap_index(sequencer_->grid())); + if (piano_) { + piano_snap_->value(xtc_ui::snap_index(piano_->grid())); + size_t count = 0; + bool selected = false; + xtc_project_note_count(project_.get(), &count); + for (size_t i = 0; i < count; ++i) { + xtc_note_info note{}; + xtc_project_get_note(project_.get(), i, ¬e); + if (note.id == piano_->selected_note() && piano_->note_visible(note)) { + selected = true; + if (Ui::pushed() != note_velocity_) + note_velocity_->value(note.velocity); + break; + } + } + if (selected) + note_velocity_->activate(); + else + note_velocity_->deactivate(); + velocity_lane_->redraw(); + } + char position[64], 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); + if (!editing_number(tempo_display_)) + tempo_display_->value(xtc_project_tempo(project_.get()) / 1000.0); + if (recorder_) + tempo_display_->deactivate(); + else + tempo_display_->activate(); + size_t tracks = 0, clips = 0; + xtc_project_get_counts(project_.get(), &tracks, &clips); + uint64_t end = 0; + for (size_t i = 0; i < clips; ++i) { + xtc_clip_info clip{}; + xtc_project_get_clip(project_.get(), i, &clip); + end = std::max(end, clip.start_tick + clip.length_tick); + } + std::snprintf(info, sizeof(info), "%zu tracks / %zu clips / %llu bars", tracks, clips, + static_cast((end + 3839) / 3840)); + set_label(session_info_, info); + bool mixer_context = tabs_->value() == mixer_page_ || + (mixer_detached_ && Ui::focus() && Ui::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) { + xtc_track_info track{}; + xtc_project_get_track(project_.get(), i, &track); + if (track.id != target) + continue; + inspected_track_ = track.id; + for (auto *control : {static_cast(inspector_gain_), static_cast(inspector_pan_), + static_cast(inspector_mute_), static_cast(inspector_solo_)}) { + if (recorder_) + control->deactivate(); + else + control->activate(); + } + if (selection_changed || !editing_number(inspector_gain_)) + inspector_gain_->value(track.gain); + if (selection_changed || !editing_number(inspector_pan_)) + inspector_pan_->value(track.pan); + if (Ui::pushed() != inspector_mute_) + inspector_mute_->value(track.mute); + if (Ui::pushed() != inspector_solo_) + inspector_solo_->value(track.solo); + xtc_plugin_spec spec{}; + bool plugin = xtc_project_get_instrument(project_.get(), track.id, &spec) == XTC_OK; + std::snprintf(info, sizeof(info), "%s\n%s %s%s\n%s", track.name, track.is_bus ? "BUS" : "TRACK", + track.mute ? "Muted " : "", track.solo ? "Solo" : "", + track.is_bus ? "Bus output" + : plugin ? spec.label + : "Built-in MIDI sine"); + set_label(selection_info_, info); + return; + } + for (auto *control : {static_cast(inspector_gain_), static_cast(inspector_pan_), + static_cast(inspector_mute_), static_cast(inspector_solo_)}) + control->deactivate(); + std::snprintf(info, sizeof(info), "No track selected\nCreate a MIDI track or import a WAV to begin."); + set_label(selection_info_, info); +} + +xtc_result MainWindow::assign_instrument(uint64_t track, const char *path, unsigned int index) +{ + uint64_t clip = sequencer_->selected_track() == track ? sequencer_->selected_clip() : UINT64_MAX; + if (recorder_) return XTC_ERR_INVALID_STATE; + xtc_project *candidate = nullptr; + xtc_plugin_spec spec{}; + xtc_result r = path ? xtc_plugin_probe(path,index,sample_rate(),&spec) : XTC_OK; + if (r == XTC_OK && path && !spec.instrument) return XTC_ERR_UNSUPPORTED; + if (r == XTC_OK) r = xtc_project_clone(project_.get(),&candidate); + if (r == XTC_OK) r = xtc_project_set_instrument(candidate,track,path ? &spec : nullptr); + 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); mark_dirty(); } + return r; +} +xtc_result MainWindow::import_midi_file(const char *path) +{ + if(recorder_)return XTC_ERR_INVALID_STATE; + uint64_t track=sequencer_->selected_track(),start=sequencer_->position(),clip=0;xtc_project *candidate=nullptr; + xtc_result r=before_arrangement_edit(this); + if(r==XTC_OK)r=xtc_project_clone(project_.get(),&candidate); + if(r==XTC_OK && track==UINT64_MAX)r=xtc_project_add_track(candidate,"Imported MIDI",&track); + if(r==XTC_OK)r=xtc_project_import_midi(candidate,track,start,path,&clip); + 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);mark_dirty();refresh_status();} + return r; +} +void MainWindow::import_midi_dialog() +{ + if(recorder_){report_error("Stop recording before importing MIDI",XTC_ERR_INVALID_STATE);return;} + UiNative_File_Chooser chooser;chooser.title("Import MIDI notes (keeps project tempo)");chooser.type(UiNative_File_Chooser::BROWSE_FILE);chooser.filter("Standard MIDI File\t*.{mid,midi}"); + if(chooser.show()!=0)return; + auto r=import_midi_file(chooser.filename());if(r!=XTC_OK)report_error("MIDI import failed (type 0/1 PPQN notes required)",r); + else set_label(error_,"Imported MIDI notes at cursor; file tracks merged and project tempo retained. Undo reverses import."); +} +xtc_result MainWindow::export_midi_clip(const char *path) +{ + if(recorder_)return XTC_ERR_INVALID_STATE; + auto ids=sequencer_->selected_clips();if(ids.size()!=1)return XTC_ERR_INVALID_ARGUMENT; + return xtc_project_export_midi_clip(project_.get(),ids[0],path); +} +void MainWindow::export_midi_dialog() +{ + auto ids=sequencer_->selected_clips();xtc_clip_info clip{}; + if(ids.size()!=1 || xtc_project_find_clip(project_.get(),ids[0],&clip)!=XTC_OK || !clip.midi){report_error("Select one MIDI clip to export",XTC_ERR_INVALID_ARGUMENT);return;} + UiNative_File_Chooser chooser;chooser.title("Export selected MIDI clip");chooser.type(UiNative_File_Chooser::BROWSE_SAVE_FILE); + chooser.filter("Standard MIDI File\t*.mid");chooser.options(UiNative_File_Chooser::SAVEAS_CONFIRM);chooser.preset_file("clip.mid"); + if(chooser.show()!=0)return; + auto r=export_midi_clip(chooser.filename());if(r!=XTC_OK)report_error("MIDI export failed",r);else set_label(error_,"MIDI clip exported (notes, channels, velocities and tempo)."); +} +void MainWindow::export_audio() +{ + if(recorder_){report_error("Stop recording before export",XTC_ERR_INVALID_STATE);return;} + const char *answer=ui_input("Export stereo WAV to a new absolute path (tail configured in Preferences)",""); + if(!answer)return; + std::string path=answer;xtc_export *job=nullptr; + xtc_result r=xtc_export_create(project_.get(),path.c_str(),static_cast(settings_.offline_rate),static_cast(settings_.tail_ms),&job); + if(r==XTC_OK) { + UiDouble_Window dialog(460,130,"Export WAV");dialog.set_modal();dialog.begin(); + UiBox progress(12,12,436,45,"Preparing export");UiButton cancel(330,80,110,30,"Cancel export"); + cancel.callback([](UiWidget *,void *c){static_cast(c)->hide();},&dialog); + dialog.callback([](UiWidget *,void *c){static_cast(c)->hide();},&dialog);dialog.end();xtc_ui::style(&dialog);dialog.show(); + int done=0; + while(dialog.shown() && !done && r==XTC_OK) { + r=xtc_export_step(job,&done);uint64_t position=0,total=0;xtc_export_progress(job,&position,&total); + std::string text=std::to_string(position)+" / "+std::to_string(total)+" frames";progress.copy_label(text.c_str());Ui::check(); + } + if(r==XTC_OK && !done)set_label(status_,"Export cancelled; destination retained"); + else if(r==XTC_OK)set_label(status_,"Export complete"); + dialog.hide(); + } + xtc_export_destroy(job); + if(r!=XTC_OK)report_error("Export failed; destination must be new and media available",r); +} +xtc_result MainWindow::load_sampler(uint64_t track,const char *path) +{ + if(!path || path[0]!='/' || std::strlen(path)>=sizeof(xtc_plugin_spec{}.sample_path))return XTC_ERR_INVALID_ARGUMENT; + xtc_plugin_spec spec{};xtc_result r=xtc_project_get_instrument(project_.get(),track,&spec); + if(r!=XTC_OK || spec.format!=XTC_PLUGIN_BUILTIN || spec.index!=4)r=xtc_plugin_probe(XTC_BUILTIN_PATH,4,sample_rate(),&spec); + if(r!=XTC_OK)return r; + std::strcpy(spec.sample_path,path);return set_instrument_spec(track,spec); +} +xtc_result MainWindow::set_instrument_spec(uint64_t track,const xtc_plugin_spec &spec) +{ + if(recorder_)return XTC_ERR_INVALID_STATE; + uint64_t clip=sequencer_->selected_clip();xtc_project *candidate=nullptr; + xtc_result r=xtc_project_clone(project_.get(),&candidate); + if(r==XTC_OK)r=xtc_project_set_instrument(candidate,track,&spec); + 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);mark_dirty();} + return r; +} +xtc_result MainWindow::select_live_instrument(uint64_t track) +{ + if (recorder_) return XTC_ERR_INVALID_STATE; + xtc_project *candidate = nullptr; + xtc_result r = xtc_project_clone(project_.get(),&candidate); + if (r == XTC_OK) r = xtc_project_set_live_instrument(candidate,track); + 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 mark_dirty(); + return r; +} +void MainWindow::instrument_callback(UiWidget *widget, void *context) +{ + auto *w = static_cast(context); + if (w->recorder_) { w->report_error("Stop recording before instrument changes",XTC_ERR_INVALID_STATE); return; } + 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,"Load sampler WAV")==0) { + const char *path=ui_input("Sampler WAV path (absolute; replaces/relinks selected track sample)",""); + if(path) {r=w->load_sampler(track,path);if(r!=XTC_OK)w->report_error("Sampler preparation failed",r);} + return; + } + if(std::strcmp(label,"Selected instrument controls")==0 || std::strcmp(label,"Selected instrument automation")==0 || + std::strcmp(label,"Save instrument preset")==0 || std::strcmp(label,"Load instrument preset")==0) { + xtc_plugin_spec spec{}; + if(std::strcmp(label,"Load instrument preset")==0) { + const char *path=ui_input("Instrument preset path (absolute)","");if(!path)return; + r=xtc_plugin_preset_load(path,&spec); + if(r==XTC_OK)r=w->set_instrument_spec(track,spec); + } else { + r=xtc_project_get_instrument(w->project_.get(),track,&spec); + if(r==XTC_OK && spec.format==XTC_PLUGIN_BUILTIN && !spec.control_count) + r=xtc_plugin_probe(XTC_BUILTIN_PATH,spec.index,w->sample_rate(),&spec); + if(r==XTC_OK && std::strcmp(label,"Save instrument preset")==0) { + const char *path=ui_input("Save instrument preset path (absolute)","");if(!path)return; + r=xtc_plugin_preset_save(&spec,path); + } else if(r==XTC_OK && std::strcmp(label,"Selected instrument controls")==0) { + xtc_plugin_details details{};xtc_plugin_spec edited{}; + r=xtc_plugin_describe(&spec,w->sample_rate(),&details); + if(r==XTC_OK && xtc_edit_plugin(spec,details,edited))r=w->set_instrument_spec(track,edited); + } else if(r==XTC_OK) { + xtc_parameter_info info[XTC_PLUGIN_CONTROLS]{};r=xtc_plugin_parameters(&spec,w->sample_rate(),info); + std::string prompt="Instrument automation: port tick mode value (mode -1 removes)\n"; + for(unsigned int i=0;i1)r=XTC_ERR_INVALID_ARGUMENT; + else { + unsigned int i=0;for(;i(tick),port,static_cast(mode),value};} + if(r==XTC_OK)r=w->set_instrument_spec(track,spec); + } + } + } + } + if(r!=XTC_OK)w->report_error("Instrument edit failed; check track, preset and control ranges",r); + return; + } + if (std::strcmp(label,"Use built-in instrument") == 0) r = w->assign_instrument(track,nullptr,0); + else if (std::strcmp(label,"Live MIDI to selected instrument") == 0) r = w->select_live_instrument(track); + else if (std::strcmp(label,"Live MIDI to built-in sine") == 0) r = w->select_live_instrument(UINT64_MAX); + else { + std::string module; if(!xtc_choose_plugin_file(w->settings_,module)) return; + unsigned int index=0; + if(!xtc_choose_plugin(module.c_str(),w->sample_rate(),index,true))return; + r=w->assign_instrument(track,module.c_str(),index); + } + if (r != XTC_OK) w->report_error("Instrument assignment failed; select a non-bus track and compatible mono instrument",r); +} + +xtc_result MainWindow::set_control_map(unsigned int channel, const unsigned int controllers[3]) +{ + if (recorder_) return XTC_ERR_INVALID_STATE; + xtc_project *candidate = nullptr; + xtc_result r = xtc_project_clone(project_.get(),&candidate); + if (r == XTC_OK) r = xtc_project_set_control_map(candidate,channel,controllers); + 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 mark_dirty(); + return r; +} +void MainWindow::control_map_callback(UiWidget *, void *context) +{ + auto *w = static_cast(context); + if (w->recorder_) { w->report_error("Stop recording before controller mapping",XTC_ERR_INVALID_STATE); return; } + unsigned int controllers[3]{}; + std::string prompt = "Live sine MIDI bindings (channels 0..15; 128 disables). CC120/123 reserved.\n"; + for (unsigned int channel = 0; channel < 16; ++channel) { + xtc_project_get_control_map(w->project_.get(),channel,controllers); + prompt += std::to_string(channel) + ": volume " + std::to_string(controllers[0]) + + ", pan " + std::to_string(controllers[1]) + ", sustain " + std::to_string(controllers[2]) + "\n"; + } + prompt += "Enter channel volume_CC pan_CC sustain_CC (distinct enabled CCs):"; + const char *answer = ui_input("%s","0 7 10 64",prompt.c_str()); + if (!answer) return; + unsigned int channel; char extra; + xtc_result r = std::sscanf(answer,"%u %u %u %u %c",&channel,controllers,controllers+1,controllers+2,&extra) == 4 ? + w->set_control_map(channel,controllers) : XTC_ERR_INVALID_ARGUMENT; + if (r != XTC_OK) w->report_error("Controller mapping rejected: check channel, unique CCs and reserved panic controllers",r); +} + +xtc_result MainWindow::install_plugin_graph(xtc_graph *graph) +{ + xtc_project *candidate = nullptr; + xtc_result r = xtc_project_clone(project_.get(),&candidate); + if (r == XTC_OK) r = xtc_project_set_graph(candidate,graph); + 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 mark_dirty(); + return r; +} +xtc_result MainWindow::add_plugin(const char *path, unsigned int index, size_t *id) +{ + BusyOperation busy(*this,status_,"Loading plugin and preparing playback…"); + if (recorder_) return XTC_ERR_INVALID_STATE; + double rate = sample_rate(); + xtc_backend_status status{}; + if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate; + xtc_plugin_spec spec{}; + xtc_result r = xtc_plugin_probe(path,index,rate,&spec); + xtc_graph *graph = nullptr; size_t added = SIZE_MAX; + if (r == XTC_OK) r = xtc_graph_clone(xtc_project_graph(project_.get()),&graph); + if (r == XTC_OK) r = xtc_graph_add_plugin(graph,&spec,&added); + xtc_plan *check = nullptr; + if (r == XTC_OK) r = xtc_graph_compile(graph,rate,256,&check); + xtc_plan_destroy(check); + if (r == XTC_OK) r = install_plugin_graph(graph); + xtc_graph_destroy(graph); + if (r == XTC_OK) graph_widget_->select_node(added); + if (r == XTC_OK && id) *id = added; + return r; +} +xtc_result MainWindow::replace_plugin(size_t id, const char *path, unsigned int index) +{ + BusyOperation busy(*this,status_,"Replacing plugin and preparing playback…"); + if (recorder_) return XTC_ERR_INVALID_STATE; + double rate = sample_rate(); xtc_backend_status status{}; + if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate; + xtc_plugin_spec spec{}; + xtc_result r = xtc_plugin_probe(path,index,rate,&spec); + if (r == XTC_OK) r = apply_plugin_spec(id,spec,true); + if (r == XTC_OK) graph_widget_->select_node(id); + return r; +} +xtc_result MainWindow::reset_plugin(size_t id, unsigned int port) +{ + if (recorder_) return XTC_ERR_INVALID_STATE; + double rate = sample_rate(); xtc_backend_status status{}; + if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate; + xtc_plugin_spec spec{}; + xtc_result r = xtc_graph_get_plugin(xtc_project_graph(project_.get()),id,&spec); + if (r == XTC_OK) r = xtc_plugin_reset(&spec,rate,port); + return r == XTC_OK ? apply_plugin_spec(id,spec) : r; +} +xtc_result MainWindow::insert_plugin(const xtc_connection_info &wire, const char *path, unsigned int index, size_t *id) +{ + if (recorder_) return XTC_ERR_INVALID_STATE; + double rate = sample_rate(); xtc_backend_status status{}; + if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate; + xtc_plugin_spec spec{}; + xtc_result r = xtc_plugin_probe(path,index,rate,&spec); + if (r == XTC_OK && !spec.inputs) r = XTC_ERR_UNSUPPORTED; + xtc_graph *graph = nullptr; size_t added = SIZE_MAX; + xtc_graph_edit edit{}; edit.kind = XTC_EDIT_DISCONNECT; edit.data.connection = wire; + if (r == XTC_OK) r = xtc_graph_apply_edit(xtc_project_graph(project_.get()),&edit,&graph,nullptr); + if (r == XTC_OK) r = xtc_graph_add_plugin(graph,&spec,&added); + if (r == XTC_OK) r = xtc_graph_connect_ports(graph,wire.source_node,wire.source_port,added,0); + if (r == XTC_OK) r = xtc_graph_connect_ports(graph,added,1,wire.destination_node,wire.destination_port); + xtc_plan *check = nullptr; + if (r == XTC_OK) r = xtc_graph_compile(graph,rate,256,&check); + xtc_plan_destroy(check); + if (r == XTC_OK) r = install_plugin_graph(graph); + xtc_graph_destroy(graph); + if (r == XTC_OK) { graph_widget_->select_node(added); if (id) *id = added; } + return r; +} +xtc_result MainWindow::set_plugin_control(size_t id, unsigned int port, float value) +{ + if (recorder_) return XTC_ERR_INVALID_STATE; + xtc_plugin_spec spec{}; + xtc_result r = xtc_graph_get_plugin(xtc_project_graph(project_.get()),id,&spec); + if (r != XTC_OK) return r; + size_t i; + for (i = 0; i < spec.control_count && spec.ports[i] != port; ++i) {} + if (i == spec.control_count) return XTC_ERR_NOT_FOUND; + spec.values[i] = value; + return apply_plugin_spec(id,spec); +} +xtc_result MainWindow::set_plugin_automation(size_t id, const xtc_plugin_point &point, bool remove) +{ + if (recorder_) return XTC_ERR_INVALID_STATE; + double rate = sample_rate(); xtc_backend_status status{}; + if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate; + xtc_project *candidate = nullptr; + xtc_result r = xtc_project_clone(project_.get(),&candidate); + xtc_parameter_key key{XTC_PARAMETER_PLUGIN,static_cast(id),point.port}; + if (r == XTC_OK) r = xtc_project_automate(candidate,key,rate,point.tick,point.step,point.value,remove ? 1 : 0); + xtc_plugin_spec spec{}; + if (r == XTC_OK) r = xtc_graph_get_plugin(xtc_project_graph(candidate),id,&spec); + xtc_project_destroy(candidate); + return r == XTC_OK ? apply_plugin_spec(id,spec) : r; +} +xtc_result MainWindow::apply_plugin_spec(size_t id, const xtc_plugin_spec &spec, bool replace) +{ + xtc_graph *graph = nullptr; + xtc_result r = xtc_graph_clone(xtc_project_graph(project_.get()),&graph); + if (r == XTC_OK) r = replace ? xtc_graph_replace_plugin(graph,id,&spec) : xtc_graph_set_plugin(graph,id,&spec); + /* Compile before adoption even with a mocked/disconnected backend. */ + xtc_plan *check = nullptr; + xtc_backend_status status{}; double rate = sample_rate(); + if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate; + if (r == XTC_OK) r = xtc_graph_compile(graph,rate,256,&check); + xtc_plan_destroy(check); + if (r == XTC_OK) r = install_plugin_graph(graph); + xtc_graph_destroy(graph); return r; +} +void MainWindow::plugin_callback(UiWidget *widget, void *context) +{ + auto *w = static_cast(context); + if (w->recorder_) { w->report_error("Stop recording before plugin changes",XTC_ERR_INVALID_STATE); return; } + auto *menu = static_cast(widget); + const char *label = menu->mvalue()->label(); + xtc_result r; + if(std::strcmp(label,"Insert stereo delay on wire")==0 || std::strcmp(label,"Insert stereo reverb on wire")==0) { + xtc_connection_info wire{}; + r=w->graph_widget_->selected_connection(wire) ? w->insert_plugin(wire,XTC_BUILTIN_PATH,std::strcmp(label,"Insert stereo delay on wire")==0 ? 5U : 6U) : XTC_ERR_INVALID_STATE; + } else if(std::strcmp(label,"Add stereo delay")==0 || std::strcmp(label,"Add stereo reverb")==0) { + r=w->add_plugin(XTC_BUILTIN_PATH,std::strcmp(label,"Add stereo delay")==0 ? 5U : 6U); + } else if (std::strcmp(label,"Insert internal delay on wire")==0 || std::strcmp(label,"Insert internal reverb on wire")==0) { + xtc_connection_info wire{}; + r=w->graph_widget_->selected_connection(wire) ? w->insert_plugin(wire,XTC_BUILTIN_PATH,std::strcmp(label,"Insert internal delay on wire")==0 ? 2U : 3U) : XTC_ERR_INVALID_STATE; + } else if (std::strcmp(label,"Add internal delay") == 0 || std::strcmp(label,"Add internal reverb") == 0) { + size_t id=0; r=w->add_plugin(XTC_BUILTIN_PATH, std::strcmp(label,"Add internal delay")==0 ? 2U : 3U, &id); + } else if (std::strcmp(label,"Reset selected plugin") == 0) { + r = w->reset_plugin(w->graph_widget_->selected_node()); + } else if (std::strcmp(label,"Load plugin") == 0 || + std::strcmp(label,"Replace selected plugin") == 0 || + std::strcmp(label,"Insert plugin on selected wire") == 0) { + const size_t selected = w->graph_widget_->selected_node(); + xtc_connection_info wire{}; + const bool insert = std::strcmp(label,"Insert plugin on selected wire") == 0; + const bool replace = std::strcmp(label,"Replace selected plugin") == 0; + if (insert && !w->graph_widget_->selected_connection(wire)) { + w->report_error("Select an existing wire before inserting an effect",XTC_ERR_INVALID_STATE); return; + } + if (replace) { + xtc_plugin_spec old{}; + r = xtc_graph_get_plugin(xtc_project_graph(w->project_.get()),selected,&old); + if (r != XTC_OK) { w->report_error("Select a plugin node to replace",r); return; } + } + std::string module; if(!xtc_choose_plugin_file(w->settings_,module))return; + double rate = w->sample_rate(); xtc_backend_status status{}; + if (w->ready_ && xtc_backend_get_status(w->backend_,&status) == XTC_OK) rate = status.sample_rate; + unsigned int index = 0; + xtc_plugin_spec probe{}; + r = xtc_plugin_probe(module.c_str(),0,rate,&probe); + if (r != XTC_OK && r != XTC_ERR_UNSUPPORTED) { + w->report_error("Cannot discover plugin; check binary path, format, mono layout and dependencies",r); return; + } + if (!xtc_choose_plugin(module.c_str(),rate,index)) return; + if (replace) r = w->replace_plugin(selected,module.c_str(),index); + else if (insert) r = w->insert_plugin(wire,module.c_str(),index); + else r = w->add_plugin(module.c_str(),index); + } else { + size_t id = w->graph_widget_->selected_node(); xtc_plugin_spec spec{}; + r = xtc_graph_get_plugin(xtc_project_graph(w->project_.get()),id,&spec); + if (r == XTC_OK) { + std::string prompt = std::string(spec.label) + " control ports and values:\n"; + if (std::strcmp(label,"Selected plugin control") == 0) { + double rate = w->sample_rate(); xtc_backend_status status{}; + if (w->ready_ && xtc_backend_get_status(w->backend_,&status) == XTC_OK) rate = status.sample_rate; + xtc_plugin_details details{}; xtc_plugin_spec edited{}; + r = xtc_plugin_describe(&spec,rate,&details); + if (r == XTC_OK && xtc_edit_plugin(spec,details,edited)) r = w->apply_plugin_spec(id,edited); + if (r != XTC_OK) w->report_error("Plugin editor failed; configuration retained. Check module identity and control ranges",r); + return; + } + xtc_parameter_info info[XTC_PLUGIN_CONTROLS]{}; + double rate = w->sample_rate(); xtc_backend_status status{}; + if (w->ready_ && xtc_backend_get_status(w->backend_,&status) == XTC_OK) rate = status.sample_rate; + r = xtc_plugin_parameters(&spec,rate,info); + if (r != XTC_OK) { w->report_error("Read plugin controls failed",r); return; } + for (unsigned i = 0; i < spec.control_count; ++i) + prompt += std::to_string(spec.ports[i]) + ": " + info[i].name + " = " + std::to_string(spec.values[i]) + + " [" + std::to_string(info[i].minimum) + ", " + std::to_string(info[i].maximum) + "]\n"; + if (std::strcmp(label,"Selected plugin automation") == 0) { + prompt += "Enter port tick mode value; mode 0 linear, 1 step, -1 remove.\nCurrent tick: " + + std::to_string(w->sequencer_->position()); + const char *answer = ui_input("%s","",prompt.c_str()); + if (!answer) return; + unsigned port; unsigned long long tick; int mode; float value; char extra; + if (std::sscanf(answer,"%u %llu %d %f %c",&port,&tick,&mode,&value,&extra) != 4 || mode < -1 || mode > 1) + r = XTC_ERR_INVALID_ARGUMENT; + else { + xtc_plugin_point point{static_cast(tick),port,mode == 1 ? 1U : 0U,value}; + r = w->set_plugin_automation(id,point,mode == -1); + } + if (r != XTC_OK) w->report_error("Plugin automation rejected: check tick, port, range and discrete mode",r); + return; + } + } + } + if (r != XTC_OK) w->report_error("Plugin change failed: check module, mono layout, control port/range and JACK format",r); +} + +void MainWindow::project_defaults(xtc_project *project) +{ + (void)xtc_project_set_tempo(project,static_cast(settings_.tempo)); + unsigned int cc[3]={static_cast(settings_.volume_cc),static_cast(settings_.pan_cc),static_cast(settings_.sustain_cc)}; + for(unsigned int channel=0;channel<16;++channel)(void)xtc_project_set_control_map(project,channel,cc); +} +double MainWindow::sample_rate() const +{ + xtc_backend_status status{}; + return ready_ && xtc_backend_get_status(backend_,&status)==XTC_OK ? status.sample_rate : static_cast(settings_.offline_rate); +} +xtc_result MainWindow::configure_loop(const xtc_project *project,const xtc_settings &settings) +{ + if(!ready_)return XTC_OK; + uint64_t start,end;unsigned int rate=static_cast(sample_rate()); + xtc_result r=xtc_project_tick_to_sample(project,static_cast(settings.loop_start),rate,&start); + if(r==XTC_OK)r=xtc_project_tick_to_sample(project,static_cast(settings.loop_end),rate,&end); + if(r==XTC_OK)r=xtc_backend_set_loop(backend_,start,end,settings.loop_enabled!=0); + return r; +} +xtc_result MainWindow::set_loop(uint64_t start,uint64_t end,bool enabled) +{ + if(recorder_)return XTC_ERR_INVALID_STATE; + if(start>=end || end>XTC_PROJECT_MAX_TICK)return XTC_ERR_INVALID_ARGUMENT; + xtc_settings candidate=settings_;candidate.loop_start=static_cast(start);candidate.loop_end=static_cast(end);candidate.loop_enabled=enabled; + bool resume=running_;xtc_result r=ready_ ? xtc_backend_stop(backend_) : XTC_OK; + if(r==XTC_OK)r=configure_loop(project_.get(),candidate); + if(r==XTC_OK) {settings_=candidate;sequencer_->loop_region(start,end,enabled);} + if(ready_ && resume && r==XTC_OK) {xtc_result restarted=start_configured_backend();if(r==XTC_OK)r=restarted;} + refresh_status();return r; +} +void MainWindow::collect_preferences() +{ + settings_.sequencer_detached=editors_[0].detached;settings_.graph_detached=editors_[1].detached;settings_.piano_detached=piano_detached_; + settings_.track_header_width=sequencer_->header_width(); + for(unsigned int i=0;i<2;++i)if(editors_[i].window) { + auto *p=editors_[i].window.get(); + if(i==0){settings_.sequencer_x=p->x();settings_.sequencer_y=p->y();settings_.sequencer_width=p->w();settings_.sequencer_height=p->h();} + else{settings_.graph_x=p->x();settings_.graph_y=p->y();settings_.graph_width=p->w();settings_.graph_height=p->h();} + } + if(piano_window_){settings_.piano_x=piano_window_->x();settings_.piano_y=piano_window_->y();} + settings_.transport_detached=transport_detached_;settings_.mixer_detached=mixer_detached_; + if(transport_window_) {settings_.transport_x=transport_window_->x();settings_.transport_y=transport_window_->y();settings_.transport_width=transport_window_->w();settings_.transport_height=transport_window_->h();} + if(mixer_window_) {settings_.mixer_x=mixer_window_->x();settings_.mixer_y=mixer_window_->y();settings_.mixer_width=mixer_window_->w();settings_.mixer_height=mixer_window_->h();} + settings_.main_x=x();settings_.main_y=y();settings_.inspector_visible=sidebar_->visible(); + settings_.width=w();settings_.height=h();settings_.sequencer_snap=static_cast(sequencer_->grid());settings_.sequencer_zoom=sequencer_->zoom_level(); + if(piano_) { + settings_.piano_width=piano_window_->w();settings_.piano_height=piano_window_->h();settings_.piano_snap=static_cast(piano_->grid()); + settings_.filter_channel=piano_->filter_channel();settings_.filter_low=piano_->filter_low();settings_.filter_high=piano_->filter_high(); + settings_.note_ticks=static_cast(piano_->new_note_ticks);settings_.note_velocity=piano_->new_note_velocity;settings_.note_channel=piano_->new_note_channel; + } +} +void MainWindow::apply_ui_preferences() +{ + bool seq=settings_.sequencer_detached!=0,graph=settings_.graph_detached!=0,piano=settings_.piano_detached!=0; + detach_editor(0,seq);detach_editor(1,graph);detach_piano(piano); + sequencer_->header_width(static_cast(settings_.track_header_width)); + for(unsigned int i=0;i<2;++i)if(editors_[i].window)restore_panel(editors_[i].window.get(),static_cast(i==0 ? settings_.sequencer_x : settings_.graph_x),static_cast(i==0 ? settings_.sequencer_y : settings_.graph_y),static_cast(i==0 ? settings_.sequencer_width : settings_.graph_width),static_cast(i==0 ? settings_.sequencer_height : settings_.graph_height),i==0 ? 1100 : 1000,420); + bool floating_transport=settings_.transport_detached!=0,floating_mixer=settings_.mixer_detached!=0; + detach_transport(floating_transport);detach_mixer(floating_mixer); + if(transport_window_)restore_panel(transport_window_.get(),static_cast(settings_.transport_x),static_cast(settings_.transport_y),static_cast(settings_.transport_width),static_cast(settings_.transport_height),400,130); + if(mixer_window_)restore_panel(mixer_window_.get(),static_cast(settings_.mixer_x),static_cast(settings_.mixer_y),static_cast(settings_.mixer_width),static_cast(settings_.mixer_height),600,420); + follow_->value(static_cast(settings_.follow_playhead)); + restore_panel(this,static_cast(settings_.main_x),static_cast(settings_.main_y),static_cast(settings_.width),static_cast(settings_.height),1220,720); + show_track_inspector(settings_.inspector_visible!=0); + sequencer_->grid(static_cast(settings_.sequencer_snap));sequencer_->set_zoom(static_cast(settings_.sequencer_zoom)); + sequencer_->new_clip_ticks=static_cast(settings_.clip_ticks); + sequencer_->loop_region(static_cast(settings_.loop_start),static_cast(settings_.loop_end),settings_.loop_enabled!=0); + if(piano_) { + restore_panel(piano_window_.get(),static_cast(settings_.piano_x),static_cast(settings_.piano_y),static_cast(settings_.piano_width),static_cast(settings_.piano_height),800,520); + piano_->set_grid(static_cast(settings_.piano_snap));piano_->set_filter(static_cast(settings_.filter_channel),static_cast(settings_.filter_low),static_cast(settings_.filter_high)); + piano_->new_note_ticks=static_cast(settings_.note_ticks);piano_->new_note_velocity=static_cast(settings_.note_velocity);piano_->new_note_channel=static_cast(settings_.note_channel); + } +} +xtc_result MainWindow::apply_preferences(const xtc_settings &candidate) +{ + if(recorder_)return XTC_ERR_INVALID_STATE; + xtc_result r=xtc_settings_validate(&candidate);if(r!=XTC_OK)return r; + if(configuration_.empty())return XTC_ERR_INVALID_STATE; + if(ready_)r=xtc_backend_stop(backend_); + if(r==XTC_OK)r=configure_loop(project_.get(),candidate); + if(r==XTC_OK)r=xtc_settings_save(&candidate,configuration_.c_str()); + if(r!=XTC_OK) {if(ready_)(void)configure_loop(project_.get(),settings_);refresh_status();return r;} + settings_=candidate;configuration_valid_=true;apply_ui_preferences();refresh_status();return XTC_OK; +} +xtc_result MainWindow::save_preferences() +{ + if(configuration_.empty() || !configuration_valid_)return XTC_ERR_INVALID_STATE; + collect_preferences();return xtc_settings_save(&settings_,configuration_.c_str()); +} +std::vector MainWindow::midi_devices(bool input) +{ + std::vector ports; + if(ready_)(void)xtc_backend_visit_midi_ports(backend_,input ? XTC_PORT_OUTPUT : XTC_PORT_INPUT, + [](const char *name,void *c){static_cast *>(c)->emplace_back(name);return 0;},&ports); + return ports; +} +xtc_result MainWindow::start_configured_backend() +{ + midi_route_warning_.clear(); + const char *input=settings_.midi_input_port, *output=settings_.midi_output_port; + // Saved production devices are optional. Keep their names so a later start + // reconnects them, but do not ask the strict backend API to route absent ports. + auto available=[this](const char *name, xtc_port_direction direction, bool &found) { + struct Search { const char *name; bool found; } search{name,false}; + xtc_result result=xtc_backend_visit_midi_ports(backend_,direction, + [](const char *port,void *context) { + auto &search=*static_cast(context); + search.found=std::strcmp(port,search.name)==0; + return search.found ? 1 : 0; + },&search); + found=search.found; + return result; + }; + auto optional_route=[&](const char *&name,xtc_port_direction direction) { + if(!*name)return XTC_OK; + bool found=false; + xtc_result result=available(name,direction,found); + if(result==XTC_OK && !found) { + if(midi_route_warning_.empty())midi_route_warning_="MIDI device unavailable: "; + else midi_route_warning_+=", "; + midi_route_warning_+=name; + name=""; + } + return result; + }; + xtc_result r=xtc_backend_set_midi_options(backend_,static_cast(settings_.midi_input_channel),static_cast(settings_.midi_output_channel),settings_.midi_thru!=0); + if(r==XTC_OK)r=optional_route(input,XTC_PORT_OUTPUT); + if(r==XTC_OK)r=optional_route(output,XTC_PORT_INPUT); + if(r==XTC_OK)r=xtc_backend_start_routed(backend_,input,output); + if(r==XTC_OK) { + if(!midi_route_warning_.empty())midi_route_warning_+=". Playback continues; saved device choices are kept."; + set_label(error_,midi_route_warning_.c_str()); + } + return r; +} +void MainWindow::show_preferences() +{ + if(recorder_) {report_error("Stop recording before preferences",XTC_ERR_INVALID_STATE);return;} + collect_preferences();PreferencesDialog dialog(settings_,configuration_,[this](const xtc_settings &s){return apply_preferences(s);},[this](bool input){return midi_devices(input);});dialog.run(); +} + +xtc_result MainWindow::set_tempo(unsigned int milli_bpm) +{ + if (recorder_) + return XTC_ERR_INVALID_STATE; + if (milli_bpm < 20000 || milli_bpm > 300000) + return XTC_ERR_INVALID_ARGUMENT; + if (milli_bpm == xtc_project_tempo(project_.get())) + return XTC_OK; + uint64_t track = sequencer_->selected_track(), clip = sequencer_->selected_clip(), mix = mixer_->selected_track(); + uint64_t piano_clip = piano_ ? piano_->clip() : UINT64_MAX, note = piano_ ? piano_->selected_note() : UINT64_MAX; + bool piano_visible = piano_window_ && piano_window_->shown(); + xtc_project *candidate = nullptr; + xtc_result r = xtc_project_clone(project_.get(), &candidate); + if (r == XTC_OK) + r = xtc_project_set_tempo(candidate, milli_bpm); + 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(mix); + xtc_clip_info info{}; + if (piano_ && xtc_project_find_clip(project_.get(), piano_clip, &info) == XTC_OK) { + piano_->set_project(project_.get(), piano_clip); + piano_->select_note(note); + if (piano_visible) + piano_window_->show(); + } + mark_dirty(); + } + return r; +} +void MainWindow::inspector_callback(UiWidget *widget, void *context) +{ + auto *w = static_cast(context); + const uint64_t id = w->inspected_track_; + float gain = static_cast(w->inspector_gain_->value()), pan = static_cast(w->inspector_pan_->value()); + int mute = w->inspector_mute_->value(), solo = w->inspector_solo_->value(); + // Editing a single field must not commit another field's unfinished input. + size_t count = 0, clips = 0; + xtc_project_get_counts(w->project_.get(), &count, &clips); + for (size_t i = 0; i < count; ++i) { + xtc_track_info track{}; + xtc_project_get_track(w->project_.get(), i, &track); + if (track.id != id) + continue; + if (widget != w->inspector_gain_) + gain = track.gain; + if (widget != w->inspector_pan_) + pan = track.pan; + if (widget != w->inspector_mute_) + mute = track.mute; + if (widget != w->inspector_solo_) + solo = track.solo; + if (gain != track.gain || pan != track.pan || mute != track.mute || solo != track.solo) + if (w->mixer_->select_track(id)) + w->mixer_->mix(gain, pan, mute, solo); + break; + } + w->inspected_track_ = UINT64_MAX; + w->refresh_status(); +} +xtc_result MainWindow::mix_snapshot(unsigned int slot,bool capture) +{ + if(slot>=2)return XTC_ERR_INVALID_ARGUMENT; + if(capture) { + xtc_mix_snapshot *snapshot=nullptr;xtc_result r=xtc_mix_snapshot_create(project_.get(),&snapshot); + if(r==XTC_OK)mix_snapshots_[slot].reset(snapshot); + return r; + } + if(!mix_snapshots_[slot])return XTC_ERR_NOT_FOUND; + xtc_result r=before_arrangement_edit(this); + if(r==XTC_OK)r=xtc_mix_snapshot_recall(mix_snapshots_[slot].get(),project_.get()); + if(r==XTC_OK)mark_dirty(); + return r; +} +xtc_result MainWindow::navigate_marker(int direction) +{ + xtc_marker marker{};if(recorder_)return XTC_ERR_INVALID_STATE; + xtc_result r=xtc_project_adjacent_marker(project_.get(),sequencer_->position(),direction,&marker); + if(r==XTC_OK && ready_)r=xtc_backend_stop(backend_); + if(r==XTC_OK)r=seek(this,marker.tick); + if(r==XTC_OK)sequencer_->position(marker.tick); + return r; +} +xtc_result MainWindow::loop_section() +{ + uint64_t begin=0,end=0;xtc_result r=xtc_project_section(project_.get(),sequencer_->position(),&begin,&end); + return r==XTC_OK ? set_loop(begin,end,true) : r; +} +xtc_result MainWindow::collect_project(const char *directory) +{ + if(recorder_)return XTC_ERR_INVALID_STATE; + BusyOperation busy(*this,status_,"Collecting project media…"); + return xtc_project_collect(project_.get(),directory); +} +xtc_result MainWindow::adopt_sample_copy(uint64_t id,const xtc_sample *sample,const char *path) +{ + if(!sample||!path)return XTC_ERR_INVALID_ARGUMENT; + auto r=before_arrangement_edit(this);if(r!=XTC_OK)return r; + xtc_clip_info clip{};r=xtc_project_find_clip(project_.get(),id,&clip);if(r!=XTC_OK)return r; + if(clip.midi||!clip.asset_path[0])return XTC_ERR_INVALID_STATE; + xtc_sample *original=nullptr;r=xtc_sample_load(clip.asset_path,&original);if(r!=XTC_OK)return r; + unsigned int old_rate=0,new_rate=0;size_t old_frames=0,new_frames=0; + xtc_sample_info(original,&old_rate,&old_frames);xtc_sample_info(sample,&new_rate,&new_frames);xtc_sample_destroy(original); + if(old_rate!=new_rate||old_frames!=new_frames)return XTC_ERR_INVALID_ARGUMENT; + xtc_project *candidate=nullptr;r=xtc_project_clone(project_.get(),&candidate); + if(r==XTC_OK)r=xtc_project_set_clip_asset(candidate,id,path); + bool written=false; + if(r==XTC_OK){r=xtc_sample_write(sample,0,new_frames,path);written=r==XTC_OK;} + if(r==XTC_OK)r=install_project(candidate,false); + if(r!=XTC_OK){if(written)std::remove(path);xtc_project_destroy(candidate);return r;} + sequencer_->select(clip.track_id,id);mark_dirty();return XTC_OK; +} +xtc_result MainWindow::slice_sample_clip(uint64_t id,unsigned int rate,const std::vector &positions) +{ + auto r=before_arrangement_edit(this);if(r!=XTC_OK)return r; + xtc_project *candidate=nullptr;size_t count=0;r=xtc_project_clone(project_.get(),&candidate); + if(r==XTC_OK)r=xtc_project_slice_source_frames(candidate,id,rate,positions.data(),positions.size(),&count); + if(r==XTC_OK)r=install_project(candidate,false); + if(r!=XTC_OK){xtc_project_destroy(candidate);return r;} + xtc_clip_info clip{};xtc_project_find_clip(project_.get(),id,&clip);sequencer_->select(clip.track_id,id);mark_dirty();return XTC_OK; +} +xtc_result MainWindow::trim_sample_clip(uint64_t id,unsigned int rate,size_t begin,size_t end) +{ + auto r=before_arrangement_edit(this);if(r!=XTC_OK)return r; + xtc_project *candidate=nullptr;r=xtc_project_clone(project_.get(),&candidate); + if(r==XTC_OK)r=xtc_project_trim_source_frames(candidate,id,rate,begin,end); + if(r==XTC_OK)r=install_project(candidate,false); + if(r!=XTC_OK){xtc_project_destroy(candidate);return r;} + xtc_clip_info clip{};xtc_project_find_clip(project_.get(),id,&clip);sequencer_->select(clip.track_id,id);mark_dirty();return XTC_OK; +} +xtc_result MainWindow::open_sample_editor() +{ + if(recorder_)return XTC_ERR_INVALID_STATE; + xtc_clip_info clip{};auto r=xtc_project_find_clip(project_.get(),sequencer_->selected_clip(),&clip); + if(r!=XTC_OK)return r; + if(clip.midi||!clip.asset_path[0])return XTC_ERR_INVALID_STATE; + xtc_sample *sample=nullptr;r=xtc_sample_load(clip.asset_path,&sample);if(r!=XTC_OK)return r; + try { + SampleEditor editor(SampleEditor::Data(sample,xtc_sample_destroy)); + uint64_t first=0,last=0;unsigned int rate;size_t frames;xtc_sample_info(sample,&rate,&frames); + if(xtc_project_tick_to_sample(project_.get(),clip.offset_tick,rate,&first)==XTC_OK && xtc_project_tick_to_sample(project_.get(),clip.offset_tick+clip.length_tick,rate,&last)==XTC_OK) + editor.select(static_cast(std::min(first,static_cast(frames))),static_cast(std::min(last,static_cast(frames)))); + editor.slice=[this,id=clip.id](unsigned int sr,const std::vector &positions){return slice_sample_clip(id,sr,positions);}; + editor.adopt=[this,id=clip.id](const xtc_sample *data,const char *path){return adopt_sample_copy(id,data,path);}; + editor.trim=[this,id=clip.id](unsigned int sr,size_t b,size_t e){return trim_sample_clip(id,sr,b,e);}; + editor.run();return XTC_OK; + } catch(const std::bad_alloc &){return XTC_ERR_OUT_OF_MEMORY;} +} +void MainWindow::show_commands() +{ + std::vector commands; + for (int i = 0; i < menu_->size(); ++i) { + const auto &item = menu_->menu()[i]; + if (!item.label() || item.submenu() || !item.active()) + continue; + char path[512]; + if (menu_->item_pathname(path, sizeof(path), &item) == 0) + commands.emplace_back(path); + } + std::string selected; + { + CommandPalette palette(std::move(commands)); + selected = palette.run(); + } + if (!selected.empty()) { + auto *item = menu_->find_item(selected.c_str()); + if (item && item->active()) + menu_->picked(item); + } +} +void MainWindow::fit_song() +{ + size_t tracks = 0, clips = 0; + uint64_t end = 3840; + xtc_project_get_counts(project_.get(), &tracks, &clips); + for (size_t i = 0; i < clips; ++i) { + xtc_clip_info clip{}; + xtc_project_get_clip(project_.get(), i, &clip); + end = std::max(end, clip.start_tick + clip.length_tick + 960); + } + int scale = 24; + for (int candidate : {24, 48, 96, 192, 384}) + if (end * static_cast(candidate) / 960 <= + static_cast(std::max(1, timeline_scroll_->w() - 204))) + scale = candidate; + sequencer_->set_zoom(scale); + timeline_scroll_->scroll_to(0, 0); + timeline_scroll_->redraw(); +} +void MainWindow::follow_playhead() +{ + if (!running_ || !follow_->value() || tabs_->value() != tabs_->child(0) || Ui::pushed()) + return; + int pixel = static_cast(sequencer_->position() * static_cast(sequencer_->zoom_level()) / 960); + int screen = sequencer_->x() + sequencer_->header_width() + pixel; + if (screen > timeline_scroll_->x() + timeline_scroll_->w() - 48 || screen < timeline_scroll_->x() + sequencer_->header_width()) + timeline_scroll_->scroll_to(std::max(0, pixel - 160), timeline_scroll_->yposition()); +} + +xtc_result MainWindow::set_track_chain(uint64_t track,const xtc_track_chain &chain) +{ + auto r=before_arrangement_edit(this);if(r!=XTC_OK)return r; + xtc_project *candidate=nullptr;r=xtc_project_clone(project_.get(),&candidate); + if(r==XTC_OK)r=xtc_project_set_chain(candidate,track,&chain); + if(r==XTC_OK)r=install_project(candidate,false); + if(r!=XTC_OK){xtc_project_destroy(candidate);return r;} + mark_dirty();track_inserts_->refresh(true);return XTC_OK; +} +xtc_result MainWindow::add_track_effect(uint64_t track,const char *file,unsigned int index) +{ + xtc_track_chain chain{};auto r=xtc_project_get_chain(project_.get(),track,&chain); + if(r!=XTC_OK)return r; + if(chain.count==XTC_TRACK_INSERTS)return XTC_ERR_CAPACITY; + xtc_track_insert slot{};r=xtc_plugin_probe(file,index,sample_rate(),&slot.plugin); + if(r!=XTC_OK)return r; + slot.enabled=1;slot.wet=1;chain.slots[chain.count++]=slot; + return set_track_chain(track,chain); +} + +xtc_result MainWindow::open_track_inserts(uint64_t track) +{ + if(track==UINT64_MAX) { + bool mixer_context=tabs_->value()==mixer_page_ || + (mixer_detached_ && Ui::focus() && Ui::focus()->window()==mixer_window_.get()); + track=mixer_context ? mixer_->selected_track() : sequencer_->selected_track(); + } + size_t count=0,clips=0;xtc_project_get_counts(project_.get(),&count,&clips); + for(size_t i=0;iselect_track(track);tabs_->value(track_inserts_);tabs_->redraw();return XTC_OK; + } + return XTC_ERR_NOT_FOUND; +} + +xtc_result MainWindow::tap_tempo(double seconds) +{ + if(recorder_)return XTC_ERR_INVALID_STATE; + auto state=tap_state_;unsigned int tempo=0;auto r=xtc_tap_tempo_push(&state,seconds,&tempo); + if(r==XTC_ERR_NOT_FOUND){tap_state_=state;return r;} + if(r!=XTC_OK)return r; + r=set_tempo(tempo);if(r==XTC_OK)tap_state_=state;return r; +} diff --git a/src/gui/JUCE/main_window.hpp b/src/gui/JUCE/main_window.hpp new file mode 100644 index 0000000..a700c28 --- /dev/null +++ b/src/gui/JUCE/main_window.hpp @@ -0,0 +1,557 @@ +#ifndef XTC_MAIN_WINDOW_HPP +#define XTC_MAIN_WINDOW_HPP + +#include "ui.hpp" +#include +#include +#include +#include "xtc/xtc_project.h" +#include "xtc/xtc_sample.h" +#include "xtc/xtc_editing.h" +#include "xtc/xtc_backend.h" +#include "xtc/xtc_mixer.h" +#include "xtc/xtc_plugin.h" +#include "xtc/xtc_tempo.h" +#include "xtc/xtc_control.h" +#include "xtc/xtc_settings.h" + +class GraphWidget; +class MixerWidget; +class PianoRollWidget; +class SequencerWidget; +class UiBox; +class UiGroup; +class UiButton; +class UiTabs; +class UiMenu_Bar; +class UiChoice; +class UiValue_Slider; +class UiValue_Input; +class UiScroll; +class UiCheck_Button; + +class TrackInsertsPanel; +class MainWindow : public UiDouble_Window { + public: + xtc_result set_track_chain(uint64_t track,const xtc_track_chain &chain); + xtc_result add_track_effect(uint64_t track,const char *file,unsigned int index); + TrackInsertsPanel *track_inserts() const {return track_inserts_;} + void show_track_inspector(bool visible); + xtc_result open_track_inserts(uint64_t track=UINT64_MAX); + /* 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); + xtc_result set_tempo(unsigned int milli_bpm); + xtc_result tap_tempo(double seconds); + xtc_result collect_project(const char *directory); + xtc_result navigate_marker(int direction); + xtc_result loop_section(); + void show_commands(); + xtc_result mix_snapshot(unsigned int slot,bool capture); + /* Reparent the existing controls on the UI thread; closing a panel docks it. */ + void detach_editor(unsigned int index,bool detached); /* 0 sequencer, 1 graph */ + void detach_piano(bool detached); + void reset_layout(); + UiDouble_Window *editor_window(unsigned int index) const {return index<2 ? editors_[index].window.get() : nullptr;} + void layout_transport(); + void resize(int x,int y,int width,int height) override; + void detach_transport(bool detached); + void detach_mixer(bool detached); + UiDouble_Window *transport_window() const { return transport_window_.get(); } + UiDouble_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); + void show_preferences(); + std::vector midi_devices(bool input); + xtc_result start_configured_backend(); + xtc_result save_preferences(); + xtc_result set_loop(uint64_t start,uint64_t end,bool enabled); + UiWidget *velocity_lane() const { return velocity_lane_; } + /* Control-thread plugin commands, transactional model adoption. */ + xtc_result assign_instrument(uint64_t track, const char *path, unsigned int index); + xtc_result adopt_sample_copy(uint64_t clip,const xtc_sample *sample,const char *path); + xtc_result slice_sample_clip(uint64_t clip,unsigned int rate,const std::vector &positions); + xtc_result trim_sample_clip(uint64_t clip,unsigned int rate,size_t begin,size_t end); + xtc_result open_sample_editor(); + void export_audio(); + xtc_result export_midi_clip(const char *path); + void export_midi_dialog(); + xtc_result import_midi_file(const char *path); + void import_midi_dialog(); + xtc_result load_sampler(uint64_t track, const char *path); + xtc_result set_instrument_spec(uint64_t track, const xtc_plugin_spec &spec); + xtc_result select_live_instrument(uint64_t track); + static void instrument_callback(UiWidget *, void *); + xtc_result set_control_map(unsigned int channel, const unsigned int controllers[3]); + xtc_result add_plugin(const char *path, unsigned int index, size_t *id = nullptr); + xtc_result set_plugin_automation(size_t id, const xtc_plugin_point &point, bool remove = false); + xtc_result replace_plugin(size_t id, const char *path, unsigned int index); + xtc_result reset_plugin(size_t id, unsigned int port = UINT32_MAX); + xtc_result insert_plugin(const xtc_connection_info &wire, const char *path, unsigned int index, size_t *id = nullptr); + xtc_result set_plugin_control(size_t id, unsigned int port, float value); + /** + * @brief Constructs the window, its widgets, and the initial project. + * + * Creates an empty project and undo history, opens the audio backend, and + * schedules the periodic status timer. Throws std::runtime_error when the + * initial project or history cannot be created. + * + * @param prompt_unsaved Whether open, new, and close actions ask before + * discarding unsaved changes. + */ + explicit MainWindow(bool prompt_unsaved = true, const char *configuration = nullptr); + /** + * @brief Removes the status timer and releases backend and recording state. + * + * A backend that fails to close is intentionally retained with its recorder + * until process exit so active callbacks never observe freed state. + */ + ~MainWindow() override; + /** + * @brief Deleted copy constructor; a window uniquely owns its project and backend. + */ + MainWindow(const MainWindow &) = delete; + /** + * @brief Deleted copy assignment; a window uniquely owns its project and backend. + */ + MainWindow &operator=(const MainWindow &) = delete; + using UiDouble_Window::show; + /** + * @brief Handles JUCE events for the window. + * + * Space without Ctrl/Alt/Meta toggles the audio engine and is consumed. + * + * @param event JUCE event code, such as UI_SHORTCUT. + * @return 1 when Space was consumed; otherwise the UiDouble_Window result. + */ + int handle(int event) override; + + /** + * @brief Applies one undo or redo step from the project history. + * + * The selected snapshot is installed without resetting history and then + * accepted, leaving the window marked dirty. + * + * @param redo True to redo, false to undo. + * @return XTC_OK on success, XTC_ERR_INVALID_STATE while recording, + * XTC_ERR_NOT_FOUND when no step is available, or another error from + * checkout, install, or accept. + */ + xtc_result undo(bool redo = false); + + /** + * @brief Returns the mixer tab widget owned by the window. + * + * @return Borrowed mixer widget, or nullptr before construction completes. + */ + MixerWidget *mixer() const + { + return mixer_; + } + + /** + * @brief Shows the piano roll for the selected MIDI clip. + * + * The piano-roll window is created on first use and reused afterwards. + * + * @return XTC_OK on success, or XTC_ERR_INVALID_STATE when the selected clip + * does not exist or is not MIDI. + */ + xtc_result open_piano_roll(); + + /** + * @brief Returns the piano-roll editor widget. + * + * @return Borrowed widget, or nullptr until open_piano_roll() first succeeds. + */ + PianoRollWidget *piano_roll() const + { + return piano_; + } + + /** + * @brief Imports a WAV file as an audio clip in a cloned project. + * + * Stops the engine first, creates an "Audio" track when none is selected, + * and places the clip at the sequencer cursor. The clone replaces the live + * project only after every step succeeds. + * + * @param path Path to the WAV file to import. + * @return XTC_OK on success, or an error from the engine stop, clone, import, + * or backend preparation. + */ + xtc_result import_audio(const char *path); + /** + * @brief Starts recording JACK input to a WAV file. + * + * Requires a connected backend and no active recording. The engine is + * stopped, the current position and selected track are remembered, and a + * stereo recorder at the engine sample rate is attached before starting. + * Failure leaves the window without a recorder. + * + * @param path Destination WAV path chosen by the user. + * @return XTC_OK when recording started, XTC_ERR_INVALID_STATE when + * disconnected or already recording, or another backend or recorder + * error. + */ + xtc_result record_audio(const char *path); + /** + * @brief Saves the current project to a path and updates the window title. + * + * On success the path is remembered and the unsaved marker is cleared. + * + * @param path Destination project path; nullptr is invalid. + * @return XTC_OK on success, XTC_ERR_INVALID_ARGUMENT for nullptr, + * XTC_ERR_INVALID_STATE while recording, or the save error. + */ + xtc_result save_project(const char *path); + /** + * @brief Loads a project from a path and makes it the current project. + * + * The file is loaded into a candidate project and installed only on success. + * + * @param path Source project path; nullptr is invalid. + * @return XTC_OK on success, XTC_ERR_INVALID_ARGUMENT for nullptr, or an + * error from loading or installing the project. + */ + xtc_result open_project(const char *path); + + /** Creates a fresh untitled project; the File menu checks unsaved changes first. + * On failure the current document remains owned by the window. */ + xtc_result new_project(); + /** Requests a playback fade/stop; the timer completes deactivation after + * callback acknowledgement. Recording must use immediate Stop instead. + * Returns request acceptance or the backend failure. */ + xtc_result pause_playback(); + /** Stops playback and seeks to zero. Refuses during recording. A failed + * stop/seek is reported without moving the displayed cursor to zero. */ + xtc_result return_to_start(); + + private: + friend class TrackInsertsPanel; + xtc_tap_tempo tap_state_{}; + TrackInsertsPanel *track_inserts_=nullptr; + using MixSnapshot=std::unique_ptr; + MixSnapshot mix_snapshots_[2]{{nullptr,xtc_mix_snapshot_destroy},{nullptr,xtc_mix_snapshot_destroy}}; + struct EditorPanel { + MainWindow *owner=nullptr;unsigned int index=0; + UiGroup *page=nullptr,*content=nullptr,*placeholder=nullptr; + std::unique_ptr window;bool detached=false; + }; + EditorPanel editors_[2]; + UiGroup *piano_page_=nullptr,*piano_content_=nullptr,*piano_placeholder_=nullptr; + bool piano_detached_=true; + std::unique_ptr transport_window_, mixer_window_; + UiGroup *transport_group_=nullptr, *mixer_page_=nullptr, *sidebar_=nullptr; + UiGroup *mixer_placeholder_=nullptr; + struct TransportItem {UiWidget *widget;int x,y,w,h;}; + std::vector transport_items_; + bool layout_transport_busy_=false; + bool transport_detached_=false, mixer_detached_=false; + xtc_settings settings_{}; + std::string configuration_; + std::string midi_route_warning_; + bool configuration_valid_=true; + UiCheck_Button *loop_button_=nullptr; + xtc_result configure_loop(const xtc_project *project,const xtc_settings &settings); + void apply_ui_preferences(); + void collect_preferences(); + void project_defaults(xtc_project *project); + double sample_rate() const; + void draw() override; + void refresh_workspace(); + UiChoice *snap_ = nullptr, *piano_snap_ = nullptr; + UiValue_Slider *note_velocity_ = nullptr; + UiWidget *velocity_lane_ = nullptr; + UiBox *transport_position_ = nullptr; + UiValue_Input *tempo_display_ = nullptr; + UiValue_Input *inspector_gain_=nullptr,*inspector_pan_=nullptr; + UiCheck_Button *inspector_mute_=nullptr,*inspector_solo_=nullptr,*follow_=nullptr; + UiScroll *timeline_scroll_=nullptr; + UiBox *session_info_=nullptr; + uint64_t inspected_track_=UINT64_MAX; + static void inspector_callback(UiWidget *,void *); + void follow_playhead(); + UiBox *selection_info_ = nullptr; + MixerWidget *mixer_ = nullptr; + UiMenu_Bar *menu_ = nullptr; + void update_history_controls(); + std::unique_ptr history_{nullptr, xtc_history_destroy}; + /** + * @brief JUCE menu callback for the Undo and Redo menu items. + * + * The selected menu label starting with "Redo" chooses redo; all other + * labels undo. Failures other than XTC_ERR_NOT_FOUND are shown in the window. + * + * @param widget UiMenu_Bar whose selected item triggered the callback. + * @param context MainWindow * owning the menu and history. + */ + static void history_callback(UiWidget * widget, void * context); + std::unique_ptr project_{nullptr, xtc_project_destroy}; + std::unique_ptr piano_window_; + PianoRollWidget *piano_ = nullptr; + /** + * @brief JUCE callback for the Piano roll menu item and button. + * + * Refuses to open the editor while recording and asks the user to select a + * MIDI clip when open_piano_roll() fails. + * + * @param widget Unused triggering widget. + * @param context MainWindow * owning the piano roll. + */ + static void piano_callback(UiWidget * widget, void *context); + /** + * @brief JUCE callback for the Panic menu item and button. + * + * When the backend is connected, sends a panic request that silences held + * instrument notes and reports failures. + * + * @param widget Unused triggering widget. + * @param context MainWindow * owning the backend. + */ + static void panic_callback(UiWidget * widget, void *context); + SequencerWidget *sequencer_ = nullptr; + xtc_recorder *recorder_ = nullptr; + std::string recording_path_; + uint64_t recording_tick_ = 0, recording_track_ = UINT64_MAX; + /** + * @brief Finalizes the active recording and adds the take to the project. + * + * Must run only after the engine has stopped, so no audio callback can + * reference the recorder ring. The recorder is detached, finished, and + * destroyed. A failed finalize or empty take is reported and not added. If + * the subsequent import fails, the saved WAV path is reported for recovery. + */ + void finish_recording(); + /** + * @brief Hook called by arrangement widgets before mutating the project. + * + * Refuses edits while recording. When connected, stops the engine so the + * mutation cannot race the audio thread, then refreshes the status display. + * + * @param context MainWindow * passed as the widget owner context. + * @return XTC_OK when disconnected or the stop succeeded, + * XTC_ERR_INVALID_STATE while recording, or the backend stop error. + */ + static xtc_result before_arrangement_edit(void *context); + /** + * @brief JUCE callback for the audio import, record, and relink commands. + * + * The triggering widget supplies the menu label; the Record button uses the + * "Record WAV" default. A native file chooser selects the file, after which + * the selected clip is relinked, recording starts, or a WAV is imported. + * Failures are reported in the window. + * + * @param widget Triggering UiMenu_Bar or UiButton; menu labels select the + * action. + * @param context MainWindow * owning the project and sequencer. + */ + static void audio_file_callback(UiWidget * widget, void *context); + bool dirty_ = false, prompt_unsaved_ = true; + std::string path_; + /** + * @brief Asks whether unsaved changes may be discarded. + * + * @return True when the project is clean, prompting is disabled, or the user + * chose Discard; false when the user cancels. + */ + bool discard_changes(); + /** + * @brief Records the current project in history and marks the window dirty. + * + * Dependent widgets are refreshed with the current project and graph, and + * the title gains the unsaved marker. A history failure is reported. + */ + void mark_dirty(); + /** + * @brief Installs a candidate project as the active project. + * + * Rejected while recording. Optionally builds fresh undo history, stops and + * prepares the connected backend, or validates offline audio and MIDI plans + * when disconnected. On success the window takes ownership of candidate, + * rebinds widgets, resets the graph layout, clears the dirty flag, and shows + * any missing-asset warning. On failure ownership stays with the caller. + * + * @param candidate Project to install; must be non-null. + * @param reset_history True to discard existing undo/redo history and start + * from candidate. + * @return XTC_OK on success, XTC_ERR_INVALID_STATE while recording, or an + * error from history creation, the backend, or offline planning. + */ + xtc_result install_project(xtc_project *candidate, bool reset_history = true); + /** + * @brief Hook used by the sequencer to seek the audio engine. + * + * Does nothing when the backend is disconnected. Otherwise reads the engine + * sample rate, converts the tick position to samples, and seeks the backend. + * + * @param context MainWindow * passed as the sequencer owner context. + * @param tick Target timeline position in project ticks. + * @return XTC_OK when disconnected or the seek succeeded, or the status, + * conversion, or backend seek error. + */ + static xtc_result seek(void *context, uint64_t tick); + /** + * @brief JUCE menu callback for the File/New, Open, Save, and Save As items. + * + * Reads the action from the selected menu label, confirms discarding unsaved + * changes for New and Open, and uses a native file chooser unless Save can + * reuse the remembered path. Refuses file operations while recording and + * reports failures in the window. + * + * @param widget Triggering UiMenu_Bar whose selected label names the action. + * @param context MainWindow * owning the project and file path. + */ + static void control_map_callback(UiWidget *widget, void *context); + static void plugin_callback(UiWidget *widget, void *context); + xtc_result apply_plugin_spec(size_t id, const xtc_plugin_spec &spec, bool replace = false); + xtc_result install_plugin_graph(xtc_graph *graph); + static void file_callback(UiWidget * widget, void *context); + GraphWidget *graph_widget_ = nullptr; + UiBox *graph_state_ = nullptr; + xtc_backend *backend_ = nullptr; + std::string project_issue_; + bool ready_ = false; + bool running_ = false; + bool closing_ = false; + UiButton *record_ = nullptr; + UiButton *play_ = nullptr; + UiButton *stop_ = nullptr; + UiButton *pause_ = nullptr; + UiButton *home_ = nullptr; + UiBox *indicator_ = nullptr; + UiBox *status_ = nullptr; + UiBox *error_ = nullptr; + UiTabs *tabs_ = nullptr; + + /** + * @brief Edit handler passed to the modular graph widget. + * + * Applies the edit to a candidate graph, clones the project around it, and + * submits the new graph to the engine when connected. The live project is + * replaced only after every step succeeds. + * + * @param context MainWindow * passed as the graph widget owner context. + * @param edit Graph edit to apply. + * @param out_added_id Optional output receiving the new node ID when the edit + * adds a node; untouched otherwise or when nullptr. + * @return XTC_OK on success, XTC_ERR_INVALID_STATE while recording, or an + * error from the graph, project, or backend operation. + */ + static xtc_result edit_graph(void *context, const xtc_graph_edit &edit, size_t *out_added_id); + /** + * @brief Opens the JACK backend and submits the current graph. + * + * Uses the default backend configuration and leaves the engine stopped. + * Failure is reported and leaves the backend disconnected; a handle that + * opened but failed to accept the graph is retained for reconnect. + */ + void open_backend(); + /** + * @brief Starts the audio engine with the current project. + * + * Does nothing when already running. Reports an error when disconnected. + * Otherwise prepares the project and starts the engine. Missing or + * unreadable audio is reported as a warning after a successful start. + */ + void start_engine(); + /** + * @brief Requests a graceful playback stop, or immediately finalizes recording. + * + * Does nothing when disconnected. Failures are reported in the window. + */ + void stop_engine(); + /** + * @brief Closes and reopens the JACK backend, leaving the engine stopped. + * + * Refuses to run while recording. If closing fails the existing handle is + * retained for a later retry and the failure is reported. + */ + void reconnect_engine(); + /** + * @brief Polls the backend and updates labels, meters, and button states. + * + * When disconnected, shows offline text and disables transport controls. + * Otherwise updates the sequencer position, mixer peaks, status line, and + * error indicators from the latest engine status, then collects backend + * work. + */ + void refresh_status(); + /** + * @brief Attempts to close the window after discarding unsaved changes. + * + * Refuses while recording. On success closes the backend, removes the status + * timer, hides the piano-roll window, and hides this window. + * + * @return True when the window closed; false when recording is active, the + * user cancels discarding, or the backend cannot close. + */ + bool close_window(); + /** + * @brief Shows an operation failure and a context-sensitive hint. + * + * @param operation Human-readable operation name; names containing + * "Project" select a file and permission hint. + * @param result Failing result code whose name is included in the message. + */ + void report_error(const char *operation, xtc_result result); + /** + * @brief JUCE callback for the Play menu item and button. + * + * @param widget Unused triggering widget. + * @param context MainWindow * whose engine is started. + */ + static void play_callback(UiWidget * widget, void *context); + /** + * @brief JUCE callback for the Stop menu item and button. + * + * @param widget Unused triggering widget. + * @param context MainWindow * whose engine is stopped. + */ + static void stop_callback(UiWidget * widget, void *context); + /** + * @brief JUCE callback for the Reconnect menu item and button. + * + * @param widget Unused triggering widget. + * @param context MainWindow * whose backend is reopened. + */ + static void reconnect_callback(UiWidget * widget, void *context); + /** + * @brief JUCE callback for the window close box, File/Quit, and close events. + * + * @param widget Unused triggering widget or window. + * @param context MainWindow * whose close is requested; a refused close + * simply leaves the window open. + */ + static void close_callback(UiWidget * widget, void *context); + /** + * @brief JUCE menu callback that selects a tab page in the main window. + * + * @param widget Triggering menu item; window() must return the MainWindow. + * @param view UiWidget * tab page to make current, not the MainWindow. + */ + static void view_callback(UiWidget *widget, void *view); + /** + * @brief JUCE help callback that shows the application name and version. + * + * @param widget Unused triggering widget. + * @param context Unused callback context. + */ + static void about_callback(UiWidget * widget, void * context); + /** + * @brief JUCE timeout callback that refreshes status and supervises recording. + * + * While recording, drains the recorder and stops the engine if draining + * fails or the backend reports a lost server, sample-rate change, or + * processing error. Reschedules itself every 0.1 seconds until closing. + * + * @param context MainWindow * registered with Ui::add_timeout(). + */ + static void timer_callback(void *context); +}; + +#endif diff --git a/src/gui/JUCE/mixer_widget.cpp b/src/gui/JUCE/mixer_widget.cpp new file mode 100644 index 0000000..195d0bc --- /dev/null +++ b/src/gui/JUCE/mixer_widget.cpp @@ -0,0 +1,856 @@ +#include "theme.hpp" +#include "mixer_widget.hpp" +#include "xtc/xtc_control.h" +#include "ui.hpp" +#include +#include +#include +#include +#include + +namespace { +constexpr uint64_t lane_ticks = 15360; +} + +class AutomationLane : public UiWidget { + MixerWidget &mixer_; + + public: + /** + * @brief Creates an automation lane bound to its owning mixer. + * + * @param x Left edge in parent coordinates. + * @param y Top edge in parent coordinates. + * @param w Lane width in pixels; tick math requires a value greater than 1. + * @param h Lane height in pixels. + * @param m Mixer that owns the lane; stored by reference and must outlive it. + */ + AutomationLane(int x, int y, int w, int h, MixerWidget &m) : UiWidget(x, y, w, h), mixer_(m) + { + } + + /** + * @brief Draws the lane grid, automation curve, points, and empty hint. + * + * Points for the selected track and parameter are sorted by tick, rendered + * as an interpolated curve, and marked within the visible tick range. + */ + void draw() override + { + ui_push_clip(x(), y(), w(), h()); + ui_color(ui_rgb_color(32, 35, 40)); + ui_rectf(x(), y(), w(), h()); + ui_font(UI_HELVETICA, 11); + for (int i = 0; i <= 16; ++i) { + int px = x() + i * (w() - 1) / 16; + ui_color(ui_rgb_color(i % 4 ? 55 : 95, 60, 65)); + ui_line(px, y(), px, y() + h()); + if (i % 4 == 0) { + char label[32]; + std::snprintf(label, sizeof(label), "%llu", + static_cast(mixer_.lane_start() + static_cast(i) * 960)); + ui_color(UI_WHITE); + ui_draw(label, px + 3, y() + 14); + } + } + size_t count = 0; + xtc_project_automation_count(mixer_.project(), &count); + std::vector points; + for (size_t i = 0; i < count; ++i) { + xtc_automation_point a{}; + xtc_project_get_automation(mixer_.project(), i, &a); + if (a.track_id == mixer_.selected_track() && a.parameter == mixer_.parameter()) + points.push_back(a); + } + std::sort(points.begin(), points.end(), [](const auto &a, const auto &b) { + return a.tick < b.tick; + }); + float min = mixer_.parameter() == XTC_AUTOMATION_GAIN ? 0.0f : -1.0f; + float range = mixer_.parameter() == XTC_AUTOMATION_GAIN ? 4.0f : 2.0f; + auto py = [&](float v) { + return y() + h() - 6 - static_cast((v - min) / range * (h() - 28)); + }; + auto value = [&](uint64_t tick) { + if (points.empty()) + return min; + auto it = std::upper_bound(points.begin(), points.end(), tick, [](uint64_t t, const auto &a) { + return t < a.tick; + }); + if (it == points.begin()) + return it->value; + if (it == points.end()) + return points.back().value; + const auto &a = *(it - 1); + if (a.step) return a.value; + return a.value + + static_cast(static_cast(tick - a.tick) / static_cast(it->tick - a.tick)) * + (it->value - a.value); + }; + ui_color(xtc_ui::accent()); + if (!points.empty()) + for (int px = 1; px < w(); ++px) { + uint64_t a = + mixer_.lane_start() + static_cast(px - 1) * lane_ticks / static_cast(w() - 1); + uint64_t b = + mixer_.lane_start() + static_cast(px) * lane_ticks / static_cast(w() - 1); + ui_line(x() + px - 1, py(value(a)), x() + px, py(value(b))); + } + for (const auto &a : points) + if (a.tick >= mixer_.lane_start() && a.tick <= mixer_.lane_start() + lane_ticks) { + int px = x() + + static_cast((a.tick - mixer_.lane_start()) * static_cast(w() - 1) / lane_ticks); + ui_rectf(px - 4, py(a.value) - 4, 8, 8); + } + if (points.empty()) { + ui_color(UI_WHITE); + ui_draw("Click to add automation; right-click a point to remove. Values replace the " + "static control.", + x() + 12, y() + h() / 2); + } + ui_pop_clip(); + } + + /** + * @brief Handles JUCE input events and delegates unhandled events to the base widget. + * + * @param event JUCE event code. + * @return Nonzero if the event was handled, zero otherwise. + */ + int handle(int event) override + { + if (event != UI_PUSH) + return UiWidget::handle(event); + if (Ui::event_button() != UI_LEFT_MOUSE && Ui::event_button() != UI_RIGHT_MOUSE) + return 0; + uint64_t tick = mixer_.lane_start() + static_cast(std::clamp(Ui::event_x() - x(), 0, w() - 1)) * + lane_ticks / static_cast(w() - 1); + tick = (tick + 120) / 240 * 240; + tick = std::min(tick, XTC_PROJECT_MAX_TICK); + float min = mixer_.parameter() == XTC_AUTOMATION_GAIN ? 0.0f : -1.0f; + float range = mixer_.parameter() == XTC_AUTOMATION_GAIN ? 4.0f : 2.0f; + float value = min + static_cast(std::clamp(y() + h() - 6 - Ui::event_y(), 0, h() - 28)) / + static_cast(h() - 28) * range; + if (Ui::event_button() == UI_RIGHT_MOUSE) { + size_t count = 0; + xtc_project_automation_count(mixer_.project(), &count); + uint64_t distance = static_cast(8) * lane_ticks / static_cast(w() - 1) + 1, + closest = UINT64_MAX; + for (size_t i = 0; i < count; ++i) { + xtc_automation_point a{}; + xtc_project_get_automation(mixer_.project(), i, &a); + uint64_t d = a.tick > tick ? a.tick - tick : tick - a.tick; + if (a.track_id == mixer_.selected_track() && a.parameter == mixer_.parameter() && d < distance) { + distance = d; + closest = a.tick; + } + } + if (closest != UINT64_MAX) + mixer_.point(closest, 0, true); + } else + mixer_.point(tick, value); + return 1; + } +}; + +/* UiMenu_::add merges duplicate labels and interprets menu paths. Append a + * unique temporary entry, then replace its text literally so IDs stay dense. */ +static int literal_item(UiChoice *choice, const char *label) +{ + std::string key="#"; + while (choice->find_item(key.c_str())) key+="#"; + int index=choice->add(key.c_str()); + choice->replace(index,label); + return index; +} + +class VerticalPeak : public UiProgress +{ + public: + VerticalPeak(int x, int y, int w, int h, const char *label) : UiProgress(x, y, w, h, label) + { + labeltype(UI_NO_LABEL); + } + void draw() override + { + ui_color(xtc_ui::background()); + ui_rectf(x(), y(), w(), h()); + int height = static_cast((std::clamp(value(), -90.0f, 6.0f) + 90) / 96 * (h() - 2)); + ui_color(selection_color()); + ui_rectf(x() + 1, y() + h() - 1 - height, w() - 2, height); + ui_color(xtc_ui::border()); + ui_rect(x(), y(), w(), h()); + } +}; + +/* A flat pan dial: no embossed face, bevels or simulated lighting. */ +class PanKnob : public UiDial +{ + bool centering_ = false; + + public: + PanKnob(int x, int y, int w, int h) : UiDial(x, y, w, h, "Pan") + { + bounds(-1, 1); + step(.01); + type(UI_LINE_DIAL); + when(UI_WHEN_RELEASE); + tooltip("Pan: -1 left, 0 center, +1 right. Double-click to center."); + } + +}; + +/* JUCE owns these children. Identity is a model ID, never a dense track index. */ +class MixerStrip : public UiGroup +{ + MixerWidget &owner_; + UiButton *name_; + UiCheck_Button *mute_, *solo_; + PanKnob *pan_; + UiValue_Slider *gain_; + UiChoice *route_; + UiBox *pan_text_, *readout_; + std::vector destinations_; + static void control(UiWidget *widget, void *context) + { + auto *s = static_cast(context); + // Copy values before the owner refreshes dependent widgets. + const auto id = s->id; + xtc_track_info track{}; + size_t count = 0, clips = 0; + xtc_project_get_counts(s->owner_.project(), &count, &clips); + bool found = false; + for (size_t i = 0; i < count; ++i) { + xtc_project_get_track(s->owner_.project(), i, &track); + if (track.id == id) { + found = true; + break; + } + } + if (!found) + return; + const float gain = + widget == s->gain_ + ? (s->gain_->value() <= -60 ? 0.0f + : std::min(4.0f, static_cast(std::pow(10, s->gain_->value() / 20)))) + : track.gain; + const float pan = widget == s->pan_ ? static_cast(s->pan_->value()) : track.pan; + const int mute = widget == s->mute_ ? s->mute_->value() : track.mute; + const int solo = widget == s->solo_ ? s->solo_->value() : track.solo; + auto &owner = s->owner_; + if (owner.select_track(id)) + owner.mix(gain, pan, mute, solo); + } + + public: + uint64_t id; + xtc_peak_meter meter[2]{}; + MixerStrip(MixerWidget &owner, uint64_t track) : UiGroup(0, 0, 148, 350), owner_(owner), id(track) + { + box(xtc_ui::flat); + color(xtc_ui::panel()); + begin(); + name_ = new UiButton(0, 0, 120, 26); + name_->callback( + [](UiWidget *, void *c) { + auto *s = static_cast(c); + s->owner_.select_track(s->id); + }, + this); + mute_ = new UiCheck_Button(0, 0, 64, 24, "Mute"); + solo_ = new UiCheck_Button(0, 0, 64, 24, "Solo"); + pan_ = new PanKnob(0, 0, 48, 48); + pan_text_ = new UiBox(0, 0, 124, 18); + route_ = new UiChoice(0, 0, 124, 24); + route_->tooltip("Output route: Master or project bus"); + route_->callback( + [](UiWidget *, void *c) { + auto *s = static_cast(c); + int i = s->route_->value(); + if (i < 0 || static_cast(i) >= s->destinations_.size()) + return; + auto bus = s->destinations_[static_cast(i)]; + auto &owner = s->owner_; + auto id = s->id; + if (owner.select_track(id)) + owner.route(bus); + }, + this); + gain_ = new UiValue_Slider(0, 0, 42, 150, "dB"); + gain_->align(UI_ALIGN_TOP); + gain_->labelsize(10); + gain_->type(UI_VERT_SLIDER); + gain_->slider(UI_FLAT_BOX); + gain_->slider_size(.10); + gain_->bounds(20 * std::log10(4.0), -60); + gain_->step(.1); + gain_->when(UI_WHEN_RELEASE); + gain_->tooltip( + "Level in dB: bottom is silence, 0 dB is unity, top is +12.04 dB. Automation overrides this static value."); + for (auto *w : {static_cast(gain_), static_cast(pan_), + static_cast(mute_), static_cast(solo_)}) + w->callback(control, this); + readout_ = new UiBox(0, 0, 132, 24); + readout_->labelsize(10); + end(); + xtc_ui::style(this); + pan_->label(""); + reset(); + resize(0, 0, 148, 350); + } + void reset() + { + xtc_peak_meter_reset(meter); + xtc_peak_meter_reset(meter + 1); + } + void sync(const xtc_track_info &track) + { + name_->copy_label((std::string(track.is_bus ? "Bus: " : "") + track.name).c_str()); + name_->tooltip(track.is_bus ? "Bus — select for automation" : "Track — select for automation"); + name_->color(owner_.selected_track() == id ? xtc_ui::track(id) : xtc_ui::panel()); + gain_->value(track.gain > 0 ? std::max(-60.0, 20 * std::log10(static_cast(track.gain))) : -60); + pan_->value(track.pan); + mute_->value(track.mute); + solo_->value(track.solo); + char text[64]; + std::snprintf(text, sizeof(text), "Pan %s %d%%", + track.pan < 0 ? "L" + : track.pan > 0 ? "R" + : "C", + static_cast(std::fabs(track.pan) * 100)); + pan_text_->copy_label(text); + route_->clear(); + destinations_.clear(); + destinations_.push_back(UINT64_MAX); + literal_item(route_,"Master"); + route_->value(0); + size_t tracks = 0, clips = 0; + xtc_project_get_counts(owner_.project(), &tracks, &clips); + for (size_t i = 0; i < tracks; ++i) { + xtc_track_info bus{}; + xtc_project_get_track(owner_.project(), i, &bus); + if (!bus.is_bus) + continue; + // Literal labels, including JUCE menu metacharacters. + int index = literal_item(route_,(std::string("Bus: ")+bus.name).c_str()); + destinations_.push_back(bus.id); + if (bus.id == track.bus_id) + route_->value(index); + } + if (track.is_bus) + route_->deactivate(); + else + route_->activate(); + redraw(); + } + void update(float left, float right, double elapsed) + { + xtc_peak_meter_update(meter, left, elapsed); + xtc_peak_meter_update(meter + 1, right, elapsed); + char text[96]; + std::snprintf(text, sizeof(text), "L %.1f R %.1f dBFS%s", static_cast(meter[0].db), + static_cast(meter[1].db), meter[0].clipped || meter[1].clipped ? " CLIP" : ""); + readout_->copy_label(text); + readout_->labelcolor(meter[0].clipped || meter[1].clipped ? UI_RED : xtc_ui::muted()); + redraw(); + } + void resize(int x, int y, int w, int h) override + { + UiWidget::resize(x, y, w, h); + name_->resize(x + 8, y + 8, w - 16, 26); + mute_->resize(x + 6, y + 42, 50, 24); + solo_->resize(x + 56, y + 42, 50, 24); + pan_->resize(x + 105, y + 38, 38, 38); + pan_text_->resize(x + 8, y + 78, w - 16, 18); + route_->resize(x + 8, y + 104, w - 16, 24); + gain_->resize(x + 10, y + 150, 44, h - 184); + readout_->resize(x + 4, y + h - 30, w - 8, 24); + redraw(); + } + void draw() override + { + UiGroup::draw(); + ui_color(xtc_ui::track(id)); + ui_rectf(x() + 1, y() + 1, w() - 2, 3); + ui_font(UI_HELVETICA, 10); + for (int db : {12, 0, -12, -24, -48, -60}) { + char text[12]; + std::snprintf(text, sizeof(text), "%d", db); + if (h() < 300 && db != 0 && db != -60) + continue; + int py = y() + 175 + static_cast((12.0412 - db) / 72.0412 * (h() - 212)); + ui_color(xtc_ui::muted()); + ui_draw(text, x() + 112, py); + } + int top = y() + 150, height = h() - 184; + for (int side = 0; side < 2; ++side) { + int px = x() + 70 + side * 15; + ui_color(xtc_ui::background()); + ui_rectf(px, top, 11, height); + const float db = std::clamp(meter[side].db, -60.0f, 6.0f); + int pixels = static_cast((db + 60) / 66 * height); + ui_color(meter[side].clipped ? UI_RED : xtc_ui::accent()); + ui_rectf(px, top + height - pixels, 11, pixels); + ui_color(xtc_ui::border()); + ui_rect(px, top, 11, height); + ui_color(meter[side].clipped ? UI_RED : xtc_ui::border()); + ui_rectf(px, top - 6, 11, 3); + } + } +}; + +/** + * @brief Creates the mixer panel and its child controls. + * + * Builds the track chooser, channel strips, pan knobs, mute/solo buttons, output + * meters, automation controls, and lane, then displays the given project. + * + * @param x Left edge in parent coordinates. + * @param y Top edge in parent coordinates. + * @param w Widget width in pixels. + * @param h Widget height in pixels. + * @param p Project to display; borrowed and must outlive its use. + * @param before Callback invoked before each edit; may be nullptr. + * @param context Opaque pointer forwarded to before. + */ +MixerWidget::MixerWidget(int x, int y, int w, int h, xtc_project *p, BeforeEdit before, void *context) + : UiGroup(x, y, w, h), project_(p), before_(before), context_(context) +{ + begin(); + tracks_ = new UiChoice(x + 70, y + 8, 240, 26, "Track"); + tracks_->callback(select, this); + routes_ = new UiChoice(x + 600, y + 8, 180, 26, "Route"); + routes_->callback( + [](UiWidget *, void *c) { + auto *m = static_cast(c); + int index = m->routes_->value(); + if (index >= 0 && static_cast(index) < m->route_ids_.size()) + m->route(m->route_ids_[static_cast(index)]); + }, + this); + bus_ = new UiButton(x + 820, y + 8, 110, 26, "Add bus"); + bus_->callback([](UiWidget *, void *c) { static_cast(c)->add_bus(); }, this); + left_ = new VerticalPeak(x + 70, y + 90, 320, 20, "Output L"); + right_ = new VerticalPeak(x + 480, y + 90, 320, 20, "Output R"); + for (auto *meter : {left_, right_}) { + meter->minimum(-90); + meter->maximum(6); + } + reset_ = new UiButton(x, y, 120, 26, "Reset peaks"); + reset_->callback([](UiWidget *, void *c) { static_cast(c)->reset_meters(); }, this); + bank_ = new UiScroll(x, y + 40, w - 154, h - 172); + bank_->type(UiScroll::HORIZONTAL_ALWAYS); + bank_->end(); + master_panel_ = new UiGroup(x + w - 144, y + 40, 144, h - 172); + master_panel_->box(xtc_ui::flat); + auto *master_name = new UiBox(x + w - 140, y + 48, 136, 26, "MASTER"); + master_name->labelfont(UI_HELVETICA_BOLD); + auto *master_hint = new UiBox(x + w - 140, y + 80, 136, 24, "Output monitor"); + master_hint->labelsize(11); + master_readout_ = new UiBox(x + w - 140, y + 340, 136, 40); + master_readout_->labelsize(10); + master_panel_->add(left_); + master_panel_->add(right_); + master_panel_->end(); + reset_meters(); + parameter_ = new UiChoice(x + 110, y + 146, 150, 26, "Automation"); + parameter_->add("Gain|Pan"); + parameter_->value(0); + parameter_->callback(select, this); + start_ = new UiValue_Input(x + 420, y + 146, 140, 26, "First tick"); + start_->bounds(0, XTC_PROJECT_MAX_TICK - lane_ticks); + start_->step(3840); + start_->callback(select, this); + mode_ = new UiChoice(x + 705, y + 146, 150, 26, "Interpolation"); + mode_->add("Linear|Hold (step)"); + mode_->value(0); + hint_ = new UiBox(x, y, w, 18, + "Select a strip for automation. M/S toggles; arrows adjust gain, Shift+arrows pan. Faders show " + "static dB gain."); + hint_->labelsize(11); + lane_ = new AutomationLane(x + 10, y + h - 80, w - 20, 76, *this); + end(); + set_project(p); + layout(); +} + +/** + * @brief Returns the ID of the track selected in the track chooser. + * + * @return Selected track ID, or UINT64_MAX when the chooser selection does + * not resolve to a project track. + */ +uint64_t MixerWidget::selected_track() const +{ + return selected_id_; +} + +/** + * @brief Returns the automation parameter selected in the chooser. + * + * @return XTC_AUTOMATION_PAN when the chooser selects "Pan", otherwise + * XTC_AUTOMATION_GAIN. + */ +xtc_automation_parameter MixerWidget::parameter() const +{ + return parameter_->value() == 1 ? XTC_AUTOMATION_PAN : XTC_AUTOMATION_GAIN; +} + +/** + * @brief Returns the first tick shown by the automation lane. + * + * @return Clamped first tick in project ticks, or 0 when the input value is + * not finite. + */ +uint64_t MixerWidget::lane_start() const +{ + if (!std::isfinite(start_->value())) + return 0; + return static_cast( + std::clamp(start_->value(), 0.0, static_cast(XTC_PROJECT_MAX_TICK - lane_ticks))); +} + +/** + * @brief Displays a different project and refreshes the controls. + * + * The current track selection is preserved only when the same project + * pointer is passed again. The project is borrowed and must outlive use. + * + * @param p Project to display; must remain valid while shown. + */ +void MixerWidget::set_project(xtc_project *p) +{ + bool reset = project_ != p; + uint64_t selected = project_ == p ? selected_track() : UINT64_MAX; + project_ = p; + tracks_->clear(); + routes_->clear(); routes_->add("Master"); + route_ids_.clear(); route_ids_.push_back(UINT64_MAX); + size_t n = 0, c = 0; + xtc_project_get_counts(p, &n, &c); + int index = 0; + for (size_t i = 0; i < n; ++i) { + xtc_track_info t{}; + xtc_project_get_track(p, i, &t); + literal_item(tracks_,(std::string(t.is_bus?"Bus: ":"Track: ")+t.name).c_str()); + if(t.is_bus){literal_item(routes_,(std::string("Bus: ")+t.name).c_str());route_ids_.push_back(t.id);} + if (t.id == selected) + index = static_cast(i); + } + tracks_->value(n ? index : -1); + selected_id_=UINT64_MAX; + if(n){xtc_track_info track{};xtc_project_get_track(p,static_cast(index),&track);selected_id_=track.id;} + sync_strips(reset); + selection(); + redraw(); +} + +/** + * @brief Synchronizes the child controls with the selected track. + * + * Reconcile strip values and routing with model IDs, highlight the selected + * track, and keep its automation controls in sync. + */ +void MixerWidget::selection() +{ + xtc_track_info t{}; + bool valid = xtc_project_get_track(project_, static_cast(tracks_->value()), &t) == XTC_OK; + if (valid) + lane_->activate(); + else + lane_->deactivate(); + for (auto *s : strips_) { + xtc_track_info track{}; + size_t count = 0, clips = 0; + xtc_project_get_counts(project_, &count, &clips); + for (size_t i = 0; i < count; ++i) { + xtc_project_get_track(project_, i, &track); + if (track.id == s->id) { + s->sync(track); + break; + } + } + } + if (valid && !t.is_bus) + routes_->activate(); + else + routes_->deactivate(); + routes_->value(0); + for (size_t i = 0; i < route_ids_.size(); ++i) + if (route_ids_[i] == t.bus_id) + routes_->value(static_cast(i)); + if (auto *selected = strip(selected_track())) { + int target = bank_->xposition(); + if (selected->x() < bank_->x()) + target -= bank_->x() - selected->x(); + else if (selected->x() + selected->w() > bank_->x() + bank_->w() - 12) + target += selected->x() + selected->w() - (bank_->x() + bank_->w() - 12); + bank_->scroll_to(std::max(0, target), 0); + } + lane_->redraw(); +} + +/** + * @brief Updates the left and right output meters. + * + * Meter values are clamped to 1.0, while the over-range color is chosen + * from the unclamped level. + * + * @param left Left-channel peak level. + * @param right Right-channel peak level. + */ +void MixerWidget::peaks(float left, float right) +{ + auto now = std::chrono::steady_clock::now(); + meter_elapsed_ = std::clamp(std::chrono::duration(now-peak_time_).count(), 0.0, 60.0); + peak_time_ = now; + (void)xtc_peak_meter_update(master_, left, meter_elapsed_); + (void)xtc_peak_meter_update(master_+1, right, meter_elapsed_); + display_peaks(left_, right_, master_, "Master"); + char text[100];std::snprintf(text,sizeof(text),"L %.1f / R %.1f dBFS%s",static_cast(master_[0].db),static_cast(master_[1].db),master_clipped()?" CLIP":""); + master_readout_->copy_label(text);master_readout_->align(UI_ALIGN_WRAP|UI_ALIGN_INSIDE); +} + +/** + * @brief Reports an edit result and refreshes the controls. + * + * A successful result invokes the widget callback so the owner can mark + * the project dirty; a failure shows a modal alert. The controls are + * resynchronized in both cases. + * + * @param r Result of the attempted edit. + * @return The same result passed in. + */ +xtc_result MixerWidget::changed(xtc_result r) +{ + if (r == XTC_OK) + do_callback(); + else + ui_alert("Mixer edit failed: %s", xtc_result_string(r)); + selection(); + return r; +} + +/** + * @brief Applies gain, pan, mute, and solo to the selected track. + * + * The constructor's before-edit callback runs first; the edit is skipped + * if it returns an error. On success the widget callback is invoked and + * the controls are refreshed. + * + * @param gain Linear gain in the range 0..4. + * @param pan Pan position in the range -1 (left)..1 (right). + * @param mute Mute state: 0 or 1. + * @param solo Solo state: 0 or 1. + * @return XTC_OK on success, or the before-edit or project mutation error. + */ +xtc_result MixerWidget::mix(float gain, float pan, int mute, int solo) +{ + xtc_result r = before_ ? before_(context_) : XTC_OK; + if (r == XTC_OK) + r = xtc_project_set_mix(project_, selected_track(), gain, pan, mute, solo); + return changed(r); +} + +/** + * @brief Adds, replaces, or removes an automation point. + * + * The constructor's before-edit callback runs first; the edit is skipped + * if it returns an error. On success the widget callback is invoked and + * the controls are refreshed. + * + * @param tick Automation tick to modify; must be within the project range. + * @param value New value; ignored when remove is true. + * @param remove When true, removes the point instead of setting it. + * @return XTC_OK on success, or the before-edit or project mutation error. + */ +xtc_result MixerWidget::point(uint64_t tick, float value, bool remove) +{ + xtc_result r = before_ ? before_(context_) : XTC_OK; + xtc_parameter_key key{XTC_PARAMETER_TRACK,selected_track(),static_cast(parameter())}; + if (r == XTC_OK) + r = xtc_project_automate(project_,key,48000,tick,mode_->value() == 1 ? 1U : 0U,value,remove ? 1 : 0); + return changed(r); +} + +/** + * @brief JUCE callback for the track, parameter, and first-tick controls. + * + * @param c Opaque MixerWidget pointer supplied as callback context. + */ +void MixerWidget::select(UiWidget *widget, void *c) +{ + auto *m=static_cast(c); + if(widget==m->tracks_){xtc_track_info track{}; + if(xtc_project_get_track(m->project_,static_cast(m->tracks_->value()),&track)==XTC_OK)m->selected_id_=track.id; + } + m->selection(); +} + +/** + * @brief Handles keyboard shortcuts and forwards other events. + * + * M and S toggle mute and solo, Left/Right adjust gain (Shift adjusts pan), + * and Up/Down change the selected track. Modified key presses fall through. + * + * @param e JUCE event code. + * @return 1 when the event was consumed, otherwise the UiGroup result. + */ +int MixerWidget::handle(int e) +{ + if (e == UI_KEYDOWN && dynamic_cast(Ui::focus())) return UiGroup::handle(e); + if (e == UI_KEYDOWN && !(Ui::event_state() & (UI_CTRL | UI_ALT | UI_META))) { + int key = Ui::event_key(); + xtc_track_info t{}; + if (xtc_project_get_track(project_, static_cast(tracks_->value()), &t) == XTC_OK) { + if (key == 'm' || key == 's') { + mix(t.gain, t.pan, key == 'm' ? !t.mute : t.mute, key == 's' ? !t.solo : t.solo); + return 1; + } + if (key == UI_Left || key == UI_Right) { + float d = key == UI_Left ? -0.05f : 0.05f; + bool pan = (Ui::event_state() & UI_SHIFT) != 0; + mix(pan ? t.gain : std::clamp(t.gain + d, 0.0f, 4.0f), pan ? std::clamp(t.pan + d, -1.0f, 1.0f) : t.pan, + t.mute, t.solo); + return 1; + } + if (key == UI_Up || key == UI_Down) { + size_t n = 0, c = 0; + xtc_project_get_counts(project_, &n, &c); + tracks_->value(std::clamp(tracks_->value() + (key == UI_Up ? -1 : 1), 0, static_cast(n) - 1)); + select(tracks_,this); + return 1; + } + } + } + return UiGroup::handle(e); +} + +void MixerWidget::display_peaks(UiProgress *left, UiProgress *right, xtc_peak_meter *meters, const char *prefix) +{ + for (unsigned int side = 0; side < 2; ++side) { + auto *bar = side ? right : left; + char label[80]; + std::snprintf(label, sizeof(label), "%s %c %.1f dBFS%s", prefix, side ? 'R' : 'L', + static_cast(meters[side].db), meters[side].clipped ? " CLIP" : ""); + bar->copy_label(label); + bar->value(std::clamp(meters[side].db, -90.0f, 6.0f)); + bar->selection_color(meters[side].clipped ? UI_RED : xtc_ui::accent()); + } +} +void MixerWidget::track_peaks(float left, float right) +{ + for(auto *s:strips_)if(s->id==selected_track())s->update(left,right,meter_elapsed_); +} +void MixerWidget::reset_meters() +{ + for (unsigned int i = 0; i < 2; ++i) { xtc_peak_meter_reset(master_+i); } + display_peaks(left_, right_, master_, "Master"); + char text[100];std::snprintf(text,sizeof(text),"L %.1f / R %.1f dBFS%s",static_cast(master_[0].db),static_cast(master_[1].db),master_clipped()?" CLIP":""); + master_readout_->copy_label(text);master_readout_->align(UI_ALIGN_WRAP|UI_ALIGN_INSIDE); + for(auto *s:strips_){s->reset();s->update(0,0,0);} +} +xtc_result MixerWidget::add_bus() +{ + uint64_t id; + xtc_result r = before_ ? before_(context_) : XTC_OK; + if (r == XTC_OK) r = xtc_project_add_bus(project_, "Bus", &id); + if (r == XTC_OK) { changed(r); select_track(id); return r; } + return changed(r); +} +xtc_result MixerWidget::route(uint64_t bus) +{ + xtc_result r = before_ ? before_(context_) : XTC_OK; + if (r == XTC_OK) r = xtc_project_route_track(project_, selected_track(), bus); + return changed(r); +} +bool MixerWidget::select_track(uint64_t id) +{ + size_t n = 0, clips = 0; xtc_project_get_counts(project_, &n, &clips); + for (size_t i = 0; i < n; ++i) { + xtc_track_info track{}; xtc_project_get_track(project_, i, &track); + if (track.id == id) { selected_id_=id;tracks_->value(static_cast(i)); selection(); return true; } + } + return false; +} + +UiGroup *MixerWidget::strip(uint64_t id) const +{ + for (auto *s : strips_) + if (s->id == id) + return s; + return nullptr; +} +void MixerWidget::meters(const xtc_meter_snapshot &snapshot) +{ + peaks(snapshot.master_left, snapshot.master_right); + for (auto *s : strips_) { + float left = 0, right = 0; + for (size_t i = 0; i < std::min(snapshot.count, static_cast(XTC_PROJECT_MAX_TRACKS)); ++i) + if (snapshot.tracks[i].id == s->id) { + left = snapshot.tracks[i].left; + right = snapshot.tracks[i].right; + break; + } + s->update(left, right, meter_elapsed_); + } +} +void MixerWidget::sync_strips(bool reset) +{ + size_t n = 0, clips = 0; + xtc_project_get_counts(project_, &n, &clips); + bool rebuild = n != strips_.size(); + for (size_t i = 0; i < n && !rebuild; ++i) { + xtc_track_info t{}; + xtc_project_get_track(project_, i, &t); + rebuild = t.id != strips_[i]->id; + } + if (rebuild) { + auto *previous = UiGroup::current(); + bank_->clear(); + strips_.clear(); + bank_->begin(); + for (size_t i = 0; i < n; ++i) { + xtc_track_info t{}; + xtc_project_get_track(project_, i, &t); + strips_.push_back(new MixerStrip(*this, t.id)); + } + bank_->end(); + UiGroup::current(previous); + layout(); + } + if (reset) + for (auto *s : strips_) + s->reset(); +} +void MixerWidget::layout() +{ + const int bank_height = std::max(250, h() - 172); + tracks_->resize(x() + 48, y() + 4, 210, 26); + routes_->resize(x() + 326, y() + 4, 160, 26); + bus_->resize(x() + 500, y() + 4, 100, 26); + reset_->resize(x() + 612, y() + 4, 112, 26); + bank_->resize(x(), y() + 40, w() - 154, bank_height); + int offset = bank_->xposition(); + for (size_t i = 0; i < strips_.size(); ++i) + strips_[i]->resize(bank_->x() + static_cast(i) * 156 - offset, bank_->y(), 148, bank_height - 16); + master_panel_->resize(x() + w() - 144, y() + 40, 144, bank_height); + // Master is pinned outside the horizontally scrolling bank. + left_->resize(x() + w() - 118, y() + 118, 32, bank_height - 140); + right_->resize(x() + w() - 66, y() + 118, 32, bank_height - 140); + master_readout_->resize(x() + w() - 140, y() + 40 + bank_height - 52, 136, 42); + left_->align(UI_ALIGN_TOP); + right_->align(UI_ALIGN_TOP); + left_->labelsize(10); + right_->labelsize(10); + parameter_->resize(x() + 84, y() + h() - 120, 100, 26); + start_->resize(x() + 264, y() + h() - 120, 116, 26); + mode_->resize(x() + 488, y() + h() - 120, 136, 26); + hint_->resize(x() + 8, y() + h() - 92, w() - 16, 18); + lane_->resize(x() + 8, y() + h() - 72, w() - 16, 68); + redraw(); +} +void MixerWidget::resize(int x, int y, int w, int h) +{ + UiWidget::resize(x, y, w, h); + layout(); +} diff --git a/src/gui/JUCE/mixer_widget.hpp b/src/gui/JUCE/mixer_widget.hpp new file mode 100644 index 0000000..bc187bb --- /dev/null +++ b/src/gui/JUCE/mixer_widget.hpp @@ -0,0 +1,202 @@ +#ifndef XTC_MIXER_WIDGET_HPP +#define XTC_MIXER_WIDGET_HPP +#include "xtc/xtc_mixer.h" +#include "xtc/xtc_backend.h" +#include "ui.hpp" +#include +#include + +class UiChoice; +class UiButton; +class UiValue_Slider; +class UiCheck_Button; +class UiProgress; +class UiValue_Input; +class AutomationLane; +class MixerStrip; +class UiScroll; +class UiBox; + +class MixerWidget : public UiGroup { + public: + /** + * @brief Prepares a model edit using the widget's opaque callback context. + * + * @return XTC_OK to allow the edit, or an error to cancel it. + */ + using BeforeEdit = xtc_result (*)(void *); + + /** + * @brief Creates the mixer panel and its child controls. + * + * Builds the track chooser, channel strips, pan knobs, mute/solo buttons, output + * meters, automation controls, and lane, then displays the given project. + * + * @param x Left edge in parent coordinates. + * @param y Top edge in parent coordinates. + * @param w Widget width in pixels. + * @param h Widget height in pixels. + * @param p Project to display; borrowed and must outlive its use. + * @param before Callback invoked before each edit; may be nullptr. + * @param context Opaque pointer forwarded to before. + */ + MixerWidget(int x, int y, int w, int h, xtc_project * p, BeforeEdit before, void * context); + + /** + * @brief Displays a different project and refreshes the controls. + * + * The current track selection is preserved only when the same project + * pointer is passed again. The project is borrowed and must outlive use. + * + * @param p Project to display; must remain valid while shown. + */ + void set_project(xtc_project * p); + + /** + * @brief Updates the left and right output meters. + * + * Meter values are clamped to 1.0, while the over-range color is chosen + * from the unclamped level. + * + * @param left Left-channel peak level. + * @param right Right-channel peak level. + */ + void peaks(float left, float right); + + /** + * @brief Returns the ID of the track selected in the track chooser. + * + * @return Selected track ID, or UINT64_MAX when the chooser selection does + * not resolve to a project track. + */ + uint64_t selected_track() const; + + /** + * @brief Returns the project currently displayed by the widget. + * + * @return Borrowed project pointer, possibly nullptr; the widget does not + * own it. + */ + xtc_project *project() const + { + return project_; + } + + /** + * @brief Returns the automation parameter selected in the chooser. + * + * @return XTC_AUTOMATION_PAN when the chooser selects "Pan", otherwise + * XTC_AUTOMATION_GAIN. + */ + xtc_automation_parameter parameter() const; + + /** + * @brief Returns the first tick shown by the automation lane. + * + * @return Clamped first tick in project ticks, or 0 when the input value is + * not finite. + */ + uint64_t lane_start() const; + + /** + * @brief Adds, replaces, or removes an automation point. + * + * The constructor's before-edit callback runs first; the edit is skipped + * if it returns an error. On success the widget callback is invoked and + * the controls are refreshed. + * + * @param tick Automation tick to modify; must be within the project range. + * @param value New value; ignored when remove is true. + * @param remove When true, removes the point instead of setting it. + * @return XTC_OK on success, or the before-edit or project mutation error. + */ + xtc_result point(uint64_t tick, float value, bool remove = false); + + /** + * @brief Applies gain, pan, mute, and solo to the selected track. + * + * The constructor's before-edit callback runs first; the edit is skipped + * if it returns an error. On success the widget callback is invoked and + * the controls are refreshed. + * + * @param gain Linear gain in the range 0..4. + * @param pan Pan position in the range -1 (left)..1 (right). + * @param mute Mute state: 0 or 1. + * @param solo Solo state: 0 or 1. + * @return XTC_OK on success, or the before-edit or project mutation error. + */ + xtc_result add_bus(); + xtc_result route(uint64_t bus); + bool select_track(uint64_t id); + void track_peaks(float left, float right); + /* One control-thread snapshot updates all strips; missing taps decay to silence. */ + void meters(const xtc_meter_snapshot &snapshot); + UiGroup *strip(uint64_t id) const; + size_t strip_count() const { return strips_.size(); } + void resize(int x, int y, int w, int h) override; + void reset_meters(); + bool master_clipped() const { return master_[0].clipped || master_[1].clipped; } + xtc_result mix(float gain, float pan, int mute, int solo); + + /** + * @brief Handles keyboard shortcuts and forwards other events. + * + * M and S toggle mute and solo, Left/Right adjust gain (Shift adjusts pan), + * and Up/Down change the selected track. Modified key presses fall through. + * + * @param event JUCE event code. + * @return 1 when the event was consumed, otherwise the UiGroup result. + */ + int handle(int event) override; + + private: + xtc_project *project_; + BeforeEdit before_; + void *context_; + UiChoice *tracks_, *parameter_, *routes_, *mode_; + std::vector route_ids_; + UiProgress *left_, *right_; + UiScroll *bank_; + UiGroup *master_panel_; + UiButton *bus_, *reset_; + UiBox *hint_, *master_readout_; + std::vector strips_; + void sync_strips(bool reset = false); + void layout(); + xtc_peak_meter master_[2]{}; + uint64_t selected_id_ = UINT64_MAX; + std::chrono::steady_clock::time_point peak_time_ = std::chrono::steady_clock::now(); + double meter_elapsed_ = 0.1; + void display_peaks(UiProgress *left, UiProgress *right, xtc_peak_meter *meters, const char *prefix); + + UiValue_Input *start_; + AutomationLane *lane_; + /** + * @brief Reports an edit result and refreshes the controls. + * + * A successful result invokes the widget callback so the owner can mark + * the project dirty; a failure shows a modal alert. The controls are + * resynchronized in both cases. + * + * @param r Result of the attempted edit. + * @return The same result passed in. + */ + xtc_result changed(xtc_result r); + + /** + * @brief Synchronizes the child controls with the selected track. + * + * Reconcile strip controls with stable model IDs and the selected + * automation target. + */ + void selection(); + + /** + * @brief JUCE callback for the track, parameter, and first-tick controls. + * + * @param widget Track chooser or automation control that changed. + * @param c Opaque MixerWidget pointer supplied as callback context. + */ + static void select(UiWidget * widget, void * c); +}; +#endif diff --git a/src/gui/JUCE/piano_roll.cpp b/src/gui/JUCE/piano_roll.cpp new file mode 100644 index 0000000..c16f7ed --- /dev/null +++ b/src/gui/JUCE/piano_roll.cpp @@ -0,0 +1,455 @@ +#include "theme.hpp" +#include "snap.hpp" +#include "piano_roll.hpp" +#include "ui.hpp" +#include +#include +#include + +namespace { +constexpr int key_width = 64, row_height = 16, scale = 96, header = 28; +template bool parse(const char *text, Values &...values) +{ + std::istringstream input(text); + (input >> ... >> values); + return !input.fail() && (input >> std::ws).eof(); +} +} + +/** + * @brief Creates a piano-roll editor for a MIDI clip. + * + * The pre-edit guard, when non-NULL, is called before every project mutation + * and can veto the edit by returning an error. + * + * @param left Left edge of the widget in the parent's coordinate system. + * @param top Top edge of the widget in the parent's coordinate system. + * @param project Borrowed project that must outlive the widget. + * @param clip ID of the MIDI clip to edit. + * @param before Optional pre-edit guard; NULL means no guard is used. + * @param context Opaque pointer passed to @p before on every edit. + */ +PianoRollWidget::PianoRollWidget(int left, int top, xtc_project *project, uint64_t clip, BeforeEdit before, + void *context) + : UiWidget(left, top, 900, header + 128 * row_height), project_(project), clip_(clip), before_(before), + context_(context) +{ + set_project(project, clip); +} + +/** + * @brief Points the editor at a different project or clip. + * + * Clears any in-progress drag when the project or clip changes and resizes the + * widget to fit the clip's source length. Selection is cleared on a new model or clip. + * + * @param project Borrowed project that must outlive the widget. + * @param clip ID of the MIDI clip to display. + */ +bool PianoRollWidget::select_note(uint64_t id) +{ + size_t count = 0; + xtc_project_note_count(project_,&count); + for (size_t i = 0; i < count; ++i) { + xtc_note_info note{}; + if (xtc_project_get_note(project_,i,¬e) == XTC_OK && note.id == id && visible(note)) { + selected_ = id; all_ = false; mode_ = 0; redraw(); return true; + } + } + return false; +} + +void PianoRollWidget::set_project(xtc_project *project, uint64_t clip) +{ + if (project != project_ || clip != clip_) { + mode_ = 0; + selected_ = UINT64_MAX; + all_ = false; + } + project_ = project; + clip_ = clip; + xtc_clip_info c{}; + int width = xtc_project_find_clip(project_, clip_, &c) == XTC_OK + ? static_cast(c.source_length_tick * scale / 960) + key_width + 20 + : 900; + size(std::max(900, width), header + 128 * row_height); + redraw(); +} + +/** + * @brief Converts a horizontal pixel position to a snapped tick. + * + * The position is measured in absolute window coordinates and snapped down to + * the current configured grid. + * + * @param px Horizontal position in window coordinates. + * @return Tick offset within the clip source, or 0 for positions on or left of + * the keyboard. + */ +uint64_t PianoRollWidget::tick(int px) const +{ + return static_cast(std::max(0, px - x() - key_width)) * 960U / scale / grid_ * grid_; +} + +/** + * @brief Reports an edit result and notifies listeners on success. + * + * On success the widget callback runs and the editor is redrawn; on failure an + * alert shows the error and the project is left unchanged. + * + * @param result Result of the attempted project mutation. + * @return The same result code. + */ +xtc_result PianoRollWidget::change(xtc_result result) +{ + if (result == XTC_OK) { + do_callback(); + redraw(); + } else + ui_alert("Note unchanged: %s", xtc_result_string(result)); + return result; +} + +/** + * @brief Adds a note to the current clip and selects the new note. + * + * Invokes the pre-edit guard, if any, before mutating the project. + * + * @param start Note start in clip source ticks. + * @param length Note length in ticks. + * @param pitch MIDI pitch from 0 to 127. + * @param velocity MIDI velocity from 1 to 127. + * @param channel MIDI channel from 0 to 15. + * @return XTC_OK on success, the guard's error, or the project error. + */ +xtc_result PianoRollWidget::add_note(uint64_t start, uint64_t length, unsigned int pitch, unsigned int velocity, + unsigned int channel) +{ + xtc_result r = before_ ? before_(context_) : XTC_OK; + uint64_t id; + if (r == XTC_OK) + r = xtc_project_add_note(project_, clip_, start, length, pitch, velocity, channel, &id); + if (r == XTC_OK) { + selected_ = id; + all_ = false; + } + return change(r); +} + +/** + * @brief Removes the selected note from the current clip. + * + * Invokes the pre-edit guard, if any, before mutating the project. The + * selection is cleared only when the removal succeeds. + * + * @return XTC_OK on success, the guard's error, or the project error. + */ +xtc_result PianoRollWidget::delete_selected() +{ + xtc_note_edit edit{}; + edit.action = XTC_NOTES_DELETE; + return apply(edit); +} + +/** + * @brief Draws the keyboard, beat grid, and notes of the current clip. + * + * Paints only the background when the clip is missing or is not a MIDI clip. + */ +void PianoRollWidget::draw() +{ + ui_push_clip(x(), y(), w(), h()); + ui_color(ui_rgb_color(30, 33, 38)); + ui_rectf(x(), y(), w(), h()); + xtc_clip_info clip{}; + if (xtc_project_find_clip(project_, clip_, &clip) != XTC_OK || !clip.midi) { + ui_pop_clip(); + return; + } + int cx, cy, cw, ch; + ui_clip_box(x(), y(), w(), h(), cx, cy, cw, ch); + ui_font(UI_HELVETICA, 11); + for (int pitch = 0; pitch < 128; ++pitch) { + int py = y() + header + (127 - pitch) * row_height; + if (py + row_height < cy || py > cy + ch) + continue; + int pc = pitch % 12; + bool black = pc == 1 || pc == 3 || pc == 6 || pc == 8 || pc == 10; + ui_color(black ? ui_rgb_color(29, 31, 35) : ui_rgb_color(43, 46, 52)); + ui_rectf(cx, py, cw, row_height - 1); + ui_color(black ? ui_rgb_color(60, 63, 70) : ui_rgb_color(130, 135, 140)); + ui_rectf(x(), py, key_width - 2, row_height - 1); + if (!pc) { + char text[20]; + std::snprintf(text, sizeof(text), "C%d", pitch / 12 - 1); + ui_color(UI_WHITE); + ui_draw(text, x() + 6, py + 12); + } + } + // At 1-tick snap, show at least a 120-tick visual grid to avoid pixel overdraw. + uint64_t visual = std::max(120, grid_); + int spacing = static_cast(visual * scale / 960); + int first = std::max(0, (cx - x() - key_width) / spacing); + int last = (cx + cw - x() - key_width) / spacing + 1; + for (int step = first; step <= last; ++step) { + int px = x() + key_width + static_cast(static_cast(step) * visual * scale / 960); + ui_color((static_cast(step) * visual) % 3840 == 0 ? ui_rgb_color(95, 98, 105) : ui_rgb_color(52, 55, 62)); + ui_line(px, cy, px, cy + ch); + } + size_t count = 0; + xtc_project_note_count(project_, &count); + for (size_t i = 0; i < count; ++i) { + xtc_note_info n{}; + xtc_project_get_note(project_, i, &n); + if (!visible(n)) + continue; + int left = x() + key_width + static_cast(n.start_tick * scale / 960); + int right = left + std::max(3, static_cast(n.length_tick * scale / 960)); + int py = y() + header + (127 - static_cast(n.pitch)) * row_height; + int a = std::max(cx, left), b = std::min(cx + cw, right); + if (a >= b || py + row_height < cy || py > cy + ch) + continue; + ui_color(all_ || n.id == selected_ ? xtc_ui::accent() + : ui_rgb_color(50, static_cast(70 + n.velocity), 110)); + ui_rectf(a, py + 1, b - a, row_height - 3); + } + ui_color(ui_rgb_color(30, 33, 38)); + ui_rectf(x(), y(), w(), header); + char status[160]; + std::snprintf(status, sizeof(status), "Snap %llu ticks | Channel %d (-1 all) | Pitches %u..%u | %s", + static_cast(grid_), channel_filter_, low_, high_, + all_ ? "All filtered notes selected" : "Click selects; Ctrl+A selects filtered notes"); + ui_color(UI_WHITE); + ui_draw(status, x() + 6, y() + 18); + ui_pop_clip(); +} + +/** + * @brief Handles mouse, keyboard, and focus events for the editor. + * + * Left-click on empty space adds a note, clicking a note selects it, and + * dragging moves or resizes it. Right-click and Delete remove the selected + * note; Escape cancels an in-progress drag. + * + * @param event JUCE event code. + * @return 1 when the event was handled, otherwise the result of the base + * class handler. + */ +int PianoRollWidget::handle(int event) +{ + if (event == UI_PUSH) { + take_focus(); + mode_ = 0; + size_t count = 0; + xtc_project_note_count(project_, &count); + selected_ = UINT64_MAX; + all_ = false; + for (size_t i = count; i > 0; --i) { + xtc_note_info n{}; + xtc_project_get_note(project_, i - 1, &n); + int left = x() + key_width + static_cast(n.start_tick * scale / 960); + int right = left + std::max(3, static_cast(n.length_tick * scale / 960)); + int top = y() + header + (127 - static_cast(n.pitch)) * row_height; + if (visible(n) && Ui::event_x() >= left && Ui::event_x() < right && Ui::event_y() >= top && + Ui::event_y() < top + row_height) { + selected_ = n.id; + original_ = n; + drag_x_ = Ui::event_x(); + drag_y_ = Ui::event_y(); + mode_ = right - Ui::event_x() <= 6 ? 2 : 1; + break; + } + } + if (selected_ != UINT64_MAX && Ui::event_button() == UI_RIGHT_MOUSE) { + mode_ = 0; + UiMenu_Item items[5]{}; + items[0].text = "Velocity and channel (V)"; + items[1].text = "Quantize (Q)"; + items[2].text = "Repeat (Ctrl+D)"; + items[3].text = "Delete (undoable)"; + const auto *picked = items->popup(Ui::event_x(),Ui::event_y()); + if (picked == items) command(2); + else if (picked == items+1) command(3); + else if (picked == items+2) command(5); + else if (picked == items+3) delete_selected(); + } else if (Ui::event_button() == UI_LEFT_MOUSE && selected_ == UINT64_MAX && Ui::event_x() >= x() + key_width && + Ui::event_y() >= y() + header) { + int pitch = 127 - (Ui::event_y() - y() - header) / row_height; + xtc_clip_info c{}; + uint64_t start = tick(Ui::event_x()); + if (pitch >= static_cast(low_) && pitch <= static_cast(high_) && xtc_project_find_clip(project_, clip_, &c) == XTC_OK && + start < c.source_length_tick) + add_note(start, std::min(new_note_ticks, c.source_length_tick - start), + static_cast(pitch), new_note_velocity, channel_filter_ < 0 ? new_note_channel : static_cast(channel_filter_)); + } + redraw(); + return 1; + } + if (event == UI_DRAG) + return mode_ != 0; + if (event == UI_RELEASE && mode_) { + int mode = mode_; + mode_ = 0; + int64_t delta = static_cast(Ui::event_x() - drag_x_) * 960 / scale / static_cast(grid_) * static_cast(grid_); + xtc_clip_info c{}; + if (xtc_project_find_clip(project_, clip_, &c) != XTC_OK) + return 1; + int64_t start = static_cast(original_.start_tick), + length = static_cast(original_.length_tick); + unsigned int pitch = original_.pitch; + if (mode == 1) { + start = std::clamp(start + delta, 0, static_cast(c.source_length_tick) - length); + pitch = static_cast( + std::clamp(static_cast(pitch) - (Ui::event_y() - drag_y_) / row_height, 0, 127)); + } else { + int64_t maximum = static_cast(c.source_length_tick) - start; + length = std::clamp(length + delta, std::min(static_cast(grid_), maximum), maximum); + } + xtc_result r = before_ ? before_(context_) : XTC_OK; + if (r == XTC_OK) + r = xtc_project_edit_note(project_, selected_, static_cast(start), static_cast(length), + pitch, original_.velocity, original_.channel); + change(r); + return 1; + } + if (event == UI_KEYDOWN) { + int key = Ui::event_key(); + if (key == 'a' && (Ui::event_state() & UI_CTRL)) { select_filtered(); return 1; } + if (!Ui::event_state(UI_CTRL|UI_ALT|UI_META) && key == 'q') { command(3); return 1; } + if (!Ui::event_state(UI_CTRL|UI_ALT|UI_META) && key == 'v') { command(2); return 1; } + if (!Ui::event_state(UI_CTRL|UI_ALT|UI_META) && key == 'g') { command(0); return 1; } + if (!Ui::event_state(UI_CTRL|UI_ALT|UI_META) && key == 'f') { command(1); return 1; } + if (!Ui::event_state(UI_CTRL|UI_ALT|UI_META) && key == 't') { command(4); return 1; } + if (key == 'd' && (Ui::event_state() & UI_CTRL)) { command(5); return 1; } + if (key == UI_Up || key == UI_Down) { + xtc_note_edit edit{}; + edit.velocity = edit.channel = -1; + edit.transpose = key == UI_Up ? 1 : -1; + apply(edit); + return 1; + } + } + if (event == UI_KEYDOWN && Ui::event_key() == UI_Delete) { + delete_selected(); + return 1; + } + if (event == UI_KEYDOWN && Ui::event_key() == UI_Escape) { + mode_ = 0; + return 1; + } + if (event == UI_FOCUS || event == UI_UNFOCUS) + return 1; + return UiWidget::handle(event); +} + +bool PianoRollWidget::visible(const xtc_note_info &n) const +{ + return n.clip_id == clip_ && (channel_filter_ < 0 || n.channel == static_cast(channel_filter_)) && + n.pitch >= low_ && n.pitch <= high_; +} + +xtc_result PianoRollWidget::set_grid(uint64_t ticks) +{ + if (xtc_ui::snap_index(ticks) < 0) + return XTC_ERR_INVALID_ARGUMENT; + grid_ = ticks; + redraw(); + return XTC_OK; +} + +xtc_result PianoRollWidget::set_filter(int channel, unsigned int low, unsigned int high) +{ + if (channel < -1 || channel > 15 || low > high || high > 127) return XTC_ERR_INVALID_ARGUMENT; + channel_filter_ = channel; + low_ = low; + high_ = high; + selected_ = UINT64_MAX; + all_ = false; + mode_ = 0; + redraw(); + return XTC_OK; +} + +void PianoRollWidget::select_filtered() +{ + selected_ = UINT64_MAX; + all_ = true; + mode_ = 0; + redraw(); +} + +xtc_result PianoRollWidget::apply(const xtc_note_edit &edit) +{ + if (!all_ && selected_ == UINT64_MAX) return XTC_ERR_NOT_FOUND; + xtc_note_filter filter{clip_, all_ ? UINT64_MAX : selected_, channel_filter_, low_, high_}; + size_t count; + xtc_result r = before_ ? before_(context_) : XTC_OK; + if (r == XTC_OK) r = xtc_project_edit_notes(project_, &filter, &edit, &count); + if (r == XTC_OK && edit.action == XTC_NOTES_DELETE) { + selected_ = UINT64_MAX; + all_ = false; + } + return change(r); +} + +void PianoRollWidget::command(int action) +{ + const char *text; + xtc_note_edit edit{}; + edit.velocity = edit.channel = -1; + if (action == 0) { + text = ui_input("Snap ticks: 1, 120, 160 (1/16 triplet), 240, 320 (1/8 triplet), 480, 960, 1920, 3840", "240"); + unsigned int value; + if (text && (!parse(text, value) || set_grid(value) != XTC_OK)) + ui_alert("Choose one of the listed grids."); + return; + } + if (action == 1) { + text = ui_input("Channel (-1 all, 0..15), lowest pitch, highest pitch", "-1 0 127"); + int channel; unsigned int low, high; + if (text && (!parse(text, channel, low, high) || + set_filter(channel, low, high) != XTC_OK)) ui_alert("Invalid note filter."); + return; + } + if (action == 6) { select_filtered(); return; } + if (action == 7) { delete_selected(); return; } + if (action == 2) { + text = ui_input("Velocity (1..127, -1 preserve), channel (0..15, -1 preserve)", "100 -1"); + if (!text) return; + if (!parse(text, edit.velocity, edit.channel)) { + ui_alert("Enter velocity and channel."); return; + } + } else if (action == 3) edit.grid_tick = grid_; + else if (action == 4) { + text = ui_input("Transpose semitones (-127..127)", "12"); + if (!text) return; + if (!parse(text, edit.transpose)) { + ui_alert("Enter a semitone count."); return; + } + } else if (action == 5) { + text = ui_input("Additional copies (1..4096), spacing in source ticks (clip must have room)", "1 960"); + unsigned long long interval; + if (!text) return; + if (!parse(text, edit.copies, interval)) { + ui_alert("Enter copy count and spacing."); return; + } + edit.action = XTC_NOTES_REPEAT; + edit.interval_tick = static_cast(interval); + } else return; + apply(edit); +} + +xtc_result PianoRollWidget::set_velocity(uint64_t id, unsigned int velocity) +{ + if (velocity < 1 || velocity > 127) return XTC_ERR_INVALID_ARGUMENT; + size_t count = 0; xtc_project_note_count(project_,&count); + for (size_t i = 0; i < count; ++i) { + xtc_note_info note{}; xtc_project_get_note(project_,i,¬e); + if (note.id != id || !visible(note)) continue; + if (note.velocity == velocity) return XTC_OK; + xtc_result r = before_ ? before_(context_) : XTC_OK; + if (r == XTC_OK) r = xtc_project_edit_note(project_,id,note.start_tick,note.length_tick,note.pitch,velocity,note.channel); + return change(r); + } + return XTC_ERR_NOT_FOUND; +} diff --git a/src/gui/JUCE/piano_roll.hpp b/src/gui/JUCE/piano_roll.hpp new file mode 100644 index 0000000..944d186 --- /dev/null +++ b/src/gui/JUCE/piano_roll.hpp @@ -0,0 +1,150 @@ +#ifndef XTC_PIANO_ROLL_HPP +#define XTC_PIANO_ROLL_HPP +#include "ui.hpp" +#include "xtc/xtc_midi.h" + +class PianoRollWidget : public UiWidget { + public: + /** + * @brief Prepares a model edit using the widget's opaque callback context. + * + * @return XTC_OK to allow the edit, or an error to cancel it. + */ + using BeforeEdit = xtc_result (*)(void *); + /** + * @brief Creates a piano-roll editor for a MIDI clip. + * + * The pre-edit guard, when non-NULL, is called before every project + * mutation and can veto the edit by returning an error. + * + * @param x Left edge of the widget in the parent's coordinate system. + * @param y Top edge of the widget in the parent's coordinate system. + * @param project Borrowed project that must outlive the widget. + * @param clip ID of the MIDI clip to edit. + * @param before Optional pre-edit guard; NULL means no guard is used. + * @param context Opaque pointer passed to @p before on every edit. + */ + PianoRollWidget(int x, int y, xtc_project *project, uint64_t clip, BeforeEdit before, void *context); + /** + * @brief Points the editor at a different project or clip. + * + * Clears any in-progress drag when the project or clip changes and resizes + * the widget to fit the clip's source length. Selection is cleared on a new + * model or clip. + * + * @param project Borrowed project that must outlive the widget. + * @param clip ID of the MIDI clip to display. + */ + void set_project(xtc_project *project, uint64_t clip); + + /** + * @brief Returns the ID of the clip currently being edited. + * + * @return Clip identifier supplied to the constructor or set_project(). + */ + uint64_t clip() const + { + return clip_; + } + + /** + * @brief Adds a note to the current clip and selects the new note. + * + * Invokes the pre-edit guard, if any, before mutating the project. + * + * @param start Note start in clip source ticks. + * @param length Note length in ticks. + * @param pitch MIDI pitch from 0 to 127. + * @param velocity MIDI velocity from 1 to 127. + * @param channel MIDI channel from 0 to 15. + * @return XTC_OK on success, the guard's error, or the project error. + */ + xtc_result add_note(uint64_t start, uint64_t length, unsigned int pitch, unsigned int velocity = 100, + unsigned int channel = 0); + /** + * @brief Removes the selected note from the current clip. + * + * Invokes the pre-edit guard, if any, before mutating the project. The + * selection is cleared only when the removal succeeds. + * + * @return XTC_OK on success, the guard's error, or the project error. + */ + xtc_project *project() const { return project_; } + bool note_visible(const xtc_note_info ¬e) const { return visible(note); } + int note_x(uint64_t tick) const { return x()+64+static_cast(tick*96/960); } + uint64_t new_note_ticks=960; + unsigned int new_note_velocity=100,new_note_channel=0; + int filter_channel() const { return channel_filter_; } + unsigned int filter_low() const { return low_; } + unsigned int filter_high() const { return high_; } + uint64_t grid() const { return grid_; } + xtc_result set_velocity(uint64_t note, unsigned int velocity); + uint64_t selected_note() const { return selected_; } + bool select_note(uint64_t id); + xtc_result delete_selected(); + /* UI preferences; independent of persisted project state. */ + xtc_result set_grid(uint64_t ticks); + xtc_result set_filter(int channel, unsigned int low, unsigned int high); + void select_filtered(); + xtc_result apply(const xtc_note_edit &edit); + /* Menu/keyboard commands: grid, filter, properties, quantize, transpose, + repeat, select all, delete. */ + void command(int action); + + /** + * @brief Handles mouse, keyboard, and focus events for the editor. + * + * Left-click on empty space adds a note, clicking a note selects it, and + * dragging moves or resizes it. Right-click and Delete remove the selected + * note; Escape cancels an in-progress drag. + * + * @param event JUCE event code. + * @return 1 when the event was handled, otherwise the result of the base + * class handler. + */ + int handle(int event) override; + + protected: + /** + * @brief Draws the keyboard, beat grid, and notes of the current clip. + * + * Paints only the background when the clip is missing or is not a MIDI clip. + */ + void draw() override; + + private: + xtc_project *project_; + uint64_t clip_, selected_ = UINT64_MAX; + BeforeEdit before_; + void *context_; + xtc_note_info original_{}; + int mode_ = 0, drag_x_ = 0, drag_y_ = 0; + uint64_t grid_ = 240; + int channel_filter_ = -1; + unsigned int low_ = 0, high_ = 127; + bool all_ = false; + bool visible(const xtc_note_info &n) const; + + /** + * @brief Reports an edit result and notifies listeners on success. + * + * On success the widget callback runs and the editor is redrawn; on failure + * an alert shows the error and the project is left unchanged. + * + * @param result Result of the attempted project mutation. + * @return The same result code. + */ + xtc_result change(xtc_result result); + /** + * @brief Converts a horizontal pixel position to a snapped tick. + * + * The position is measured in absolute window coordinates and snapped down + * to the current configured grid. + * + * @param px Horizontal position in window coordinates. + * @return Tick offset within the clip source, or 0 for positions on or left + * of the keyboard. + */ + uint64_t tick(int px) const; +}; +#endif diff --git a/src/gui/JUCE/plugin_dialog.cpp b/src/gui/JUCE/plugin_dialog.cpp new file mode 100644 index 0000000..678396d --- /dev/null +++ b/src/gui/JUCE/plugin_dialog.cpp @@ -0,0 +1,102 @@ +#include "theme.hpp" +#include "plugin_dialog.hpp" +#include "ui.hpp" +#include +#include +#include +#include + +namespace { +struct Dialog : UiDouble_Window { + bool accepted = false; + Dialog(int width, int height, const char *title) : UiDouble_Window(width,height,title) { set_modal(); } + static void accept(UiWidget *, void *data) { auto *d = static_cast(data); d->accepted = true; d->hide(); } + static void cancel(UiWidget *, void *data) { static_cast(data)->hide(); } + bool run() { callback(cancel,this); end(); xtc_ui::style(this); show(); while (shown()) Ui::wait(); return accepted; } +}; +} +bool xtc_choose_plugin(const char *path, double rate, unsigned int &index, bool instrument) +{ + Dialog dialog(760,450,"Plugin descriptors"); + auto *heading = new UiBox(15,10,730,40); + heading->copy_label(path); heading->align(UI_ALIGN_LEFT|UI_ALIGN_INSIDE|UI_ALIGN_CLIP); + auto *list = new UiHold_Browser(15,55,730,325); + list->format_char(0); // Plugin names are data, never browser formatting. + std::vector indices; + size_t rejected = 0; + for (unsigned int i = 0; i < 1024; ++i) { + xtc_plugin_spec spec{}; xtc_plugin_details details{}; + xtc_result r = xtc_plugin_probe(path,i,rate,&spec); + if (r == XTC_ERR_NOT_FOUND) break; + if (r != XTC_OK) { ++rejected; continue; } + if (!!spec.instrument != instrument) { ++rejected; continue; } + if (xtc_plugin_describe(&spec,rate,&details) != XTC_OK) { ++rejected; continue; } + std::string row = std::to_string(i) + ": " + details.name + " (" + spec.label + ") — " + details.maker + + (spec.inputs ? " [effect]" : " [source]"); + list->add(row.c_str()); indices.push_back(i); + } + auto *status = new UiBox(15,385,520,50); + std::string summary = std::to_string(indices.size()) + " compatible; " + std::to_string(rejected) + + " unsupported/unavailable. Select a descriptor."; + status->copy_label(summary.c_str()); status->align(UI_ALIGN_LEFT|UI_ALIGN_INSIDE|UI_ALIGN_WRAP); + auto *cancel = new UiButton(540,400,90,30,"Cancel"); cancel->callback(Dialog::cancel,&dialog); + auto *choose = new UiReturn_Button(645,400,100,30,"Choose"); choose->callback(Dialog::accept,&dialog); + if (indices.empty()) choose->deactivate(); else list->select(1); + if (!dialog.run() || list->value() < 1) return false; + index = indices[static_cast(list->value()-1)]; return true; +} +bool xtc_edit_plugin(const xtc_plugin_spec &spec, const xtc_plugin_details &details, xtc_plugin_spec &edited) +{ + Dialog dialog(800,600,"Plugin identity and controls"); + auto *identity = new UiBox(15,10,770,125); + std::string text = std::string(details.name) + " — " + details.maker + "\n" + spec.label + + " | " + std::string(xtc_plugin_format_name(spec.format)) + " ID " + std::to_string(spec.unique_id) + " | descriptor " + std::to_string(spec.index) + " | identity " + spec.identifier + + "\n" + spec.path + "\n" + details.copyright + + "\nMetadata verified; Apply validates initialization. Adapter may not expose units or editable controls."; + identity->copy_label(text.c_str()); identity->align(UI_ALIGN_LEFT|UI_ALIGN_INSIDE|UI_ALIGN_WRAP); + auto *scroll = new UiScroll(10,140,780,370); + std::array inputs{}; + for (unsigned int i = 0; i < spec.control_count; ++i) { + int y = 150 + static_cast(i)*65; + const auto &p = details.parameters[i]; + std::string name = std::to_string(p.id) + ": " + p.name; + auto *label = new UiBox(25,y,425,25); label->copy_label(name.c_str()); label->align(UI_ALIGN_LEFT|UI_ALIGN_INSIDE); + inputs[i] = new UiValue_Input(460,y,150,25); inputs[i]->value(spec.values[i]); + inputs[i]->step((details.flags[i] & (XTC_PLUGIN_INTEGER|XTC_PLUGIN_TOGGLED)) ? 1 : 0.001); + std::string hint = "Range " + std::string(details.flags[i] & XTC_PLUGIN_BOUNDED_BELOW ? std::to_string(p.minimum) : "unbounded") + + " .. " + (details.flags[i] & XTC_PLUGIN_BOUNDED_ABOVE ? std::to_string(p.maximum) : "unbounded") + + "; default " + std::to_string(p.default_value) + + ((details.flags[i] & XTC_PLUGIN_TOGGLED) ? "; toggle 0/1" : (details.flags[i] & XTC_PLUGIN_INTEGER) ? "; integer" : "; continuous") + + ((details.flags[i] & XTC_PLUGIN_LOGARITHMIC) ? "; logarithmic hint" : ""); + auto *range = new UiBox(25,y+27,715,25); range->copy_label(hint.c_str()); range->align(UI_ALIGN_LEFT|UI_ALIGN_INSIDE); + } + if(!spec.control_count) { + auto *unavailable=new UiBox(25,160,730,75,"This adapter exposes no editable parameters. The plugin runs with its default configuration.\nNative plugin editor windows are not available in this host."); + unavailable->align(UI_ALIGN_LEFT|UI_ALIGN_INSIDE|UI_ALIGN_WRAP); + } + scroll->end(); + auto *note = new UiBox(15,515,770,30,"Automation overrides static controls. Reset defaults also clears all plugin automation."); + note->labelsize(12); + struct Reset { const xtc_plugin_details *details; std::array *inputs; unsigned int count; bool used; } reset{&details,&inputs,spec.control_count,false}; + auto *defaults = new UiButton(20,555,170,30,"Reset defaults"); + if(!spec.control_count)defaults->deactivate(); + defaults->callback([](UiWidget *, void *data) { + auto *r = static_cast(data); r->used = true; + for (unsigned int i = 0; i < r->count; ++i) (*r->inputs)[i]->value(r->details->parameters[i].default_value); + },&reset); + auto *cancel = new UiButton(570,555,90,30,"Cancel"); cancel->callback(Dialog::cancel,&dialog); + auto *apply = new UiReturn_Button(675,555,105,30,"Apply"); apply->callback(Dialog::accept,&dialog); + if (!dialog.run()) return false; + xtc_plugin_spec candidate = spec; + for (unsigned int i = 0; i < spec.control_count; ++i) { + double value = inputs[i]->value(); const auto &p = details.parameters[i]; unsigned int flags = details.flags[i]; + if (!std::isfinite(value) || value < p.minimum || value > p.maximum || + ((flags & XTC_PLUGIN_INTEGER) && value != std::floor(value)) || + ((flags & XTC_PLUGIN_TOGGLED) && value != 0 && value != 1)) { + ui_alert("Invalid value for port %u (%s); no controls were changed.",p.id,p.name); return false; + } + candidate.values[i] = static_cast(value); + } + if (reset.used) candidate.point_count = 0; + edited = candidate; return true; +} diff --git a/src/gui/JUCE/plugin_dialog.hpp b/src/gui/JUCE/plugin_dialog.hpp new file mode 100644 index 0000000..a1aad8f --- /dev/null +++ b/src/gui/JUCE/plugin_dialog.hpp @@ -0,0 +1,9 @@ +#ifndef XTC_PLUGIN_DIALOG_HPP +#define XTC_PLUGIN_DIALOG_HPP +#include "xtc/xtc_plugin.h" +/* Modal, control-thread-only views. They own copied metadata and widget state, + * never engine/module handles. Cancel/close leaves caller configuration intact. */ +bool xtc_choose_plugin(const char *path, double rate, unsigned int &index, bool instrument = false); +bool xtc_edit_plugin(const xtc_plugin_spec &spec, const xtc_plugin_details &details, + xtc_plugin_spec &edited); +#endif diff --git a/src/gui/JUCE/plugin_library.hpp b/src/gui/JUCE/plugin_library.hpp new file mode 100644 index 0000000..c80dd44 --- /dev/null +++ b/src/gui/JUCE/plugin_library.hpp @@ -0,0 +1,90 @@ +#ifndef XTC_PLUGIN_LIBRARY_HPP +#define XTC_PLUGIN_LIBRARY_HPP +#include "xtc/xtc_settings.h" +#include "theme.hpp" +#include "ui.hpp" +#include +#include +#include +#include +#include +#include + +inline std::vector xtc_plugin_files(const xtc_settings &settings) +{ + namespace fs=std::filesystem; + std::set files; + size_t visited=0; + for(const char *list : {settings.clap_paths,settings.vst2_paths,settings.vst3_paths,settings.ladspa_paths}) { + std::string paths=list; size_t start=0; + while(start<=paths.size()) { + size_t end=paths.find(':',start); std::string root=paths.substr(start,end==std::string::npos ? end : end-start); + if(root.rfind("~/",0)==0) { char config[4096]; if(xtc_settings_path(config,sizeof(config))==XTC_OK) { + std::string home=config; home.resize(home.size()-10); root=home+root.substr(1); + } } + std::error_code error; + if(!root.empty() && fs::path(root).is_absolute()) { + fs::recursive_directory_iterator it(root,fs::directory_options::skip_permission_denied,error), finish; + while(!error && it!=finish && visited<4096 && files.size()<512) { + ++visited; + if(it.depth()>=6) it.disable_recursion_pending(); + if(it->is_regular_file(error)) { + auto extension=it->path().extension().string(); + if(extension==".so" || extension==".clap") files.insert(it->path().lexically_normal().string()); + } + error.clear(); it.increment(error); + } + } + if(end==std::string::npos) break; + start=end+1; + } + } + return {files.begin(),files.end()}; +} +/* Paths are a discovery hint, never a compatibility claim. Ambiguous .so files + * remain visible under both LADSPA and VST2; probing identifies the real ABI. */ +inline bool xtc_plugin_file_matches(const std::string &file,const std::string &query,int format) +{ + auto lower=[](std::string value){for(char &c:value)c=static_cast(std::tolower(static_cast(c)));return value;}; + const auto name=lower(file);bool vst3=name.find(".vst3/")!=std::string::npos; + bool clap=std::filesystem::path(name).extension()==".clap"; + if(format==3 && !vst3)return false; + if(format==4 && !clap)return false; + if((format==1 || format==2) && (vst3 || clap))return false; + return name.find(lower(query))!=std::string::npos; +} +inline bool xtc_choose_plugin_file(const xtc_settings &settings,std::string &path) +{ + struct Library : UiDouble_Window { + bool accepted=false,browse=false; + UiInput *search;UiChoice *format;UiHold_Browser *list; + UiButton *choose;std::vector files,visible; + explicit Library(const xtc_settings &settings):UiDouble_Window(900,560,"Plugin library — effects and instruments"),files(xtc_plugin_files(settings)) { + set_modal(); + auto *hint=new UiBox(16,8,868,42,"Search installed binaries; VST3 bundles are discovered recursively. Modules load only when inspected.\nLADSPA/VST2 share .so candidates; inspection verifies the format and compatible mono layout.");hint->align(UI_ALIGN_LEFT|UI_ALIGN_INSIDE); + search=new UiInput(78,60,510,28,"Search");search->when(UI_WHEN_CHANGED); + format=new UiChoice(666,60,218,28,"Format");format->add("All formats|LADSPA|VST2|VST3|CLAP");format->value(0); + for(auto *w:{static_cast(search),static_cast(format)})w->callback([](UiWidget *,void *c){static_cast(c)->filter();},this); + list=new UiHold_Browser(16,102,868,370);list->format_char(0); + auto *browse_button=new UiButton(16,506,130,32,"Browse files...");browse_button->callback([](UiWidget *,void *c){auto *d=static_cast(c);d->browse=true;d->hide();},this); + auto *cancel=new UiButton(650,506,100,32,"Cancel");cancel->callback([](UiWidget *,void *c){static_cast(c)->hide();},this); + choose=new UiButton(764,506,120,32,"Inspect plugin");choose->callback([](UiWidget *,void *c){auto *d=static_cast(c);d->accepted=true;d->hide();},this); + callback([](UiWidget *,void *c){static_cast(c)->hide();},this); + end();filter();xtc_ui::style(this); + } + void filter() { + list->clear();visible.clear(); + for(const auto &file:files)if(xtc_plugin_file_matches(file,search->value(),format->value())){visible.push_back(file);list->add(file.c_str());} + if(visible.empty())choose->deactivate();else{choose->activate();list->select(1);} + } + } dialog(settings); + dialog.show();while(dialog.shown())Ui::wait(); + if(dialog.browse) { + UiNative_File_Chooser chooser;chooser.title("Choose trusted plugin binary");chooser.filter("Plugin binaries\t*.{so,clap}\nAll files\t*"); + if(chooser.show()!=0 || !chooser.filename())return false; + path=chooser.filename();return true; + } + if(!dialog.accepted || dialog.list->value()<1)return false; + path=dialog.visible[static_cast(dialog.list->value()-1)];return true; +} +#endif diff --git a/src/gui/JUCE/preferences_dialog.hpp b/src/gui/JUCE/preferences_dialog.hpp new file mode 100644 index 0000000..3de0f2e --- /dev/null +++ b/src/gui/JUCE/preferences_dialog.hpp @@ -0,0 +1,103 @@ +#ifndef XTC_PREFERENCES_DIALOG_HPP +#define XTC_PREFERENCES_DIALOG_HPP +#include "xtc/xtc_settings.h" +#include "theme.hpp" +#include "ui.hpp" +#include +#include +#include +#include +#include + +class PreferencesDialog : public UiDouble_Window { + xtc_settings original_; + std::function apply_; + std::vector inputs_; + UiBox *message_; + std::function(bool)> devices_; + std::vector midi_names_[2]; + UiInput_Choice *midi_input_=nullptr,*midi_output_=nullptr; + void refresh_devices() { + for(auto pair:{std::pair{midi_input_,true},{midi_output_,false}}){ + if(!pair.first)continue; + std::string value=pair.first->value(); + // copy() owns the menu array but borrows its label strings. + // Release old items before replacing their backing storage. + pair.first->menubutton()->clear(); + auto &names=midi_names_[pair.second ? 0 : 1]; + names=devices_ ? devices_(pair.second) : std::vector{}; + names.insert(names.begin(),"");std::vector items(names.size()+1); + for(size_t i=0;imenubutton()->copy(items.data());pair.first->value(value.c_str()); + } + } + + static void apply_callback(UiWidget *,void *context) { + auto *d=static_cast(context); + xtc_settings candidate=d->original_; size_t count=0; + const xtc_setting_info *schema=xtc_settings_schema(&count); + for(size_t i=0;iinputs_[i]->value())!=XTC_OK) { + std::string text=std::string("Invalid value: ")+schema[i].label; + d->message_->copy_label(text.c_str()); return; + } + xtc_result r=xtc_settings_validate(&candidate); + if(r==XTC_OK) r=d->apply_(candidate); + if(r==XTC_OK) { + d->original_=candidate; + d->message_->copy_label("Saved and applied. Audio options take effect on Reconnect; project defaults on New."); + } else { + std::string text=std::string("Preferences unchanged: ")+xtc_result_string(r)+". Check values, loop range and configuration file permissions."; + d->message_->copy_label(text.c_str()); + } + d->redraw(); + } + public: + PreferencesDialog(const xtc_settings &settings,const std::string &path, + std::function apply, + std::function(bool)> devices={}) + : UiDouble_Window(1050,660,"xtc — Preferences"),original_(settings),apply_(std::move(apply)),devices_(std::move(devices)) { + set_modal(); begin(); + auto *location=new UiBox(16,12,1018,30); location->copy_label(path.c_str()); location->align(UI_ALIGN_LEFT|UI_ALIGN_INSIDE); + auto *tabs=new UiTabs(12,50,1026,510); tabs->begin(); + size_t count=0; const auto *schema=xtc_settings_schema(&count); + inputs_.resize(count); std::string section; UiGroup *group=nullptr; UiScroll *scroll=nullptr; int row=0; + for(size_t i=0;iend(); group->end(); } + section=schema[i].section; tabs->begin(); + group=new UiGroup(12,82,1026,478); group->copy_label(section.c_str()); group->begin(); + scroll=new UiScroll(22,94,1000,450); scroll->begin(); row=0; + } + UiInput *input; + if(!strcmp(schema[i].key,"midi_input_port") || !strcmp(schema[i].key,"midi_output_port")){ + auto *choice=new UiInput_Choice(510,108+row*48,480,30,schema[i].label); + choice->callback([](UiWidget *w,void *){auto *c=static_cast(w);auto *item=c->menubutton()->mvalue();if(item && !strcmp(item->label(),"(None)"))c->value("");}); + input=choice->input();if(!strcmp(schema[i].key,"midi_input_port"))midi_input_=choice;else midi_output_=choice; + }else input=new UiInput(510,108+row*48,480,30,schema[i].label); + input->align(UI_ALIGN_LEFT); input->maximum_size(schema[i].capacity ? static_cast(schema[i].capacity-1) : 22); + char value[4096]; xtc_settings_get(&settings,i,value,sizeof(value)); input->value(value); + inputs_[i]=input; ++row; + } + if(scroll) { scroll->end(); group->end(); } tabs->end(); + tabs->begin();auto *midi_info=new UiGroup(12,82,1026,478,"MIDI help"); + auto *help=new UiBox(42,110,950,190,"Choose JACK/PipeWire MIDI ports on the midi tab. Refresh to see newly connected devices.\nYou can also type a remembered port name while it is offline; blank means no automatic route.\nRouting is restored on Play/Record; missing ports are skipped with a warning; playback continues and choices stay saved.\nInput/output channels use 0..15 for MIDI channels 1..16. MIDI thru defaults off.\nNote length/velocity/channel defaults are on editing; CC defaults are on project_defaults.\nHardware MIDI devices must be exposed by your host JACK/PipeWire MIDI bridge."); + help->align(UI_ALIGN_LEFT|UI_ALIGN_INSIDE|UI_ALIGN_WRAP);midi_info->end();tabs->end(); + refresh_devices(); + auto *refresh=new UiButton(16,614,200,30,"Refresh MIDI devices"); + refresh->callback([](UiWidget *,void *c){static_cast(c)->refresh_devices();},this); + message_=new UiBox(16,568,1018,36,"Apply saves every tab. Existing project contents are not overwritten by defaults."); + message_->align(UI_ALIGN_LEFT|UI_ALIGN_INSIDE|UI_ALIGN_WRAP); + auto *apply_button=new UiButton(812,614,100,30,"Apply"); apply_button->callback(apply_callback,this); + auto *close=new UiButton(924,614,110,30,"Close"); + close->callback([](UiWidget *,void *c){static_cast(c)->hide();},this); + callback([](UiWidget *,void *c){static_cast(c)->hide();},this); + end(); xtc_ui::style(this); + } + ~PreferencesDialog() override { + // Destroy borrowed-label menus before the member strings are released. + if(midi_input_)midi_input_->menubutton()->clear(); + if(midi_output_)midi_output_->menubutton()->clear(); + } + void run() { show(); while(shown()) Ui::wait(); } +}; +#endif diff --git a/src/gui/JUCE/sample_editor.cpp b/src/gui/JUCE/sample_editor.cpp new file mode 100644 index 0000000..dc2cbed --- /dev/null +++ b/src/gui/JUCE/sample_editor.cpp @@ -0,0 +1,78 @@ +#include "sample_editor.hpp" +#include "theme.hpp" +#include "ui.hpp" +#include +#include +#include +#include +#include +class SampleCanvas : public UiWidget { + SampleEditor &editor_;size_t anchor_=0; + size_t frame(int px) const {size_t width=std::max(size_t(1),editor_.frames()/editor_.zoom());return std::min(editor_.frames()-1,editor_.origin()+static_cast(std::max(0,std::min(w()-1,px-x())))*width/static_cast(w()));} +public: + SampleCanvas(int x,int y,int w,int h,SampleEditor &e):UiWidget(x,y,w,h),editor_(e){} + int handle(int event) override { + if(event==UI_PUSH && Ui::event_button()==UI_LEFT_MOUSE){anchor_=frame(Ui::event_x());editor_.select(anchor_,anchor_+1);return 1;} + if(event==UI_DRAG){size_t f=frame(Ui::event_x());editor_.select(std::min(f,anchor_),std::max(f,anchor_)+1);return 1;} + return UiWidget::handle(event); + } + void draw() override { + ui_push_clip(x(),y(),w(),h());ui_color(xtc_ui::background());ui_rectf(x(),y(),w(),h()); + size_t width=std::max(size_t(1),editor_.frames()/editor_.zoom()); + for(int px=0;px(px)*width/static_cast(w());size_t e=std::min(editor_.frames(),editor_.origin()+static_cast(px+1)*width/static_cast(w()));if(b>=editor_.frames())break;if(e<=b)e=b+1; + if(beditor_.begin()){ui_color(ui_rgb_color(45,76,80));ui_rectf(x()+px,y(),1,h());} + xtc_sample_stats stats{};if(xtc_sample_analyze(editor_.sample(),b,e,&stats)==XTC_OK){ui_color(ui_rgb_color(95,204,182));int a=static_cast(std::max(-1.f,std::min(1.f,stats.maximum))*(h()/2-8));int z=static_cast(std::max(-1.f,std::min(1.f,stats.minimum))*(h()/2-8));ui_line(x()+px,y()+h()/2-a,x()+px,y()+h()/2-z);} + } + ui_color(ui_rgb_color(220,164,92));for(size_t frame:editor_.attacks()){if(frame=width)continue;int px=x()+static_cast((frame-editor_.origin())*static_cast(w())/width);ui_line(px,y(),px,y()+h());} + ui_color(xtc_ui::border());ui_line(x(),y()+h()/2,x()+w(),y()+h()/2);ui_pop_clip(); + } +}; +SampleEditor::SampleEditor(Data sample):UiDouble_Window(1100,650,"xtc — Sample editor"),sample_(std::move(sample)) +{ + if(!sample_)throw std::invalid_argument("Sample editor requires a loaded source"); + xtc_sample_info(sample_.get(),&rate_,&frames_);end_=frames_;set_modal();UiGroup::begin(); + auto *hint=new UiBox(16,12,1068,34,"Source frames / mono downmix — drag a range; numeric endpoints are exclusive. Original file unchanged.");hint->align(UI_ALIGN_LEFT|UI_ALIGN_INSIDE); + begin_input_=new UiValue_Input(90,58,140,28,"Start frame");end_input_=new UiValue_Input(330,58,140,28,"End frame");origin_input_=new UiValue_Input(580,58,140,28,"View start"); + for(auto *input:{begin_input_,end_input_,origin_input_}){input->step(1);input->bounds(0,static_cast(frames_));input->when(UI_WHEN_RELEASE);input->callback([](UiWidget *,void *c){auto *e=static_cast(c);double b=e->begin_input_->value(),z=e->end_input_->value(),o=e->origin_input_->value();if(std::isfinite(b)&&std::isfinite(z)&&std::isfinite(o)&&b>=0&&z>=0&&o>=0&&b<=e->frames_&&z<=e->frames_&&oframes_){e->select(static_cast(b),static_cast(z));e->view(static_cast(o),e->zoom_);}e->refresh();},this);} + auto *plus=new UiButton(750,58,100,28,"Zoom in");plus->callback([](UiWidget *,void *c){auto *e=static_cast(c);e->view(e->begin_,std::min(64U,e->zoom_*2));},this); + auto *minus=new UiButton(860,58,100,28,"Zoom out");minus->callback([](UiWidget *,void *c){auto *e=static_cast(c);e->view(e->origin_,std::max(1U,e->zoom_/2));},this); + canvas_=new SampleCanvas(16,104,1068,340,*this);stats_=new UiBox(16,452,1068,50);stats_->align(UI_ALIGN_LEFT|UI_ALIGN_INSIDE); + auto *trim_button=new UiButton(16,520,180,30,"Trim clip to selection");trim_button->callback([](UiWidget *,void *c){auto r=static_cast(c)->trim_clip();if(r!=XTC_OK)ui_alert("Clip trim unchanged: %s",xtc_result_string(r));},this); + auto *save=new UiButton(210,520,210,30,"Save full copy and relink...");save->callback([](UiWidget *,void *c){auto *e=static_cast(c);const char *path=ui_input("NEW absolute WAV path (full source; project undo keeps the new file)","");if(path){auto r=e->save_copy(path,true);if(r!=XTC_OK)ui_alert("Copy not adopted: %s",xtc_result_string(r));}},this); + auto *export_button=new UiButton(434,520,200,30,"Export selected frames...");export_button->callback([](UiWidget *,void *c){auto *e=static_cast(c);const char *path=ui_input("NEW absolute WAV path (selection only)","");if(path){auto r=e->export_selection(path);if(r!=XTC_OK)ui_alert("Export failed: %s",xtc_result_string(r));}},this); + auto *reverse=new UiButton(16,562,160,30,"Reverse selection");reverse->callback([](UiWidget *,void *c){auto r=static_cast(c)->process(XTC_SAMPLE_REVERSE);if(r!=XTC_OK)ui_alert("Reversal failed: %s",xtc_result_string(r));},this); + auto *invert=new UiButton(188,562,160,30,"Invert polarity");invert->callback([](UiWidget *,void *c){auto r=static_cast(c)->process(XTC_SAMPLE_INVERT);if(r!=XTC_OK)ui_alert("Polarity edit failed: %s",xtc_result_string(r));},this); + auto *dc=new UiButton(360,562,160,30,"Remove DC offset");dc->callback([](UiWidget *,void *c){auto r=static_cast(c)->process(XTC_SAMPLE_REMOVE_DC);if(r!=XTC_OK)ui_alert("DC removal failed: %s",xtc_result_string(r));},this); + auto *fade=new UiButton(532,562,160,30,"Sample fades...");fade->callback([](UiWidget *,void *c){auto *e=static_cast(c);const char *text=ui_input("Fade: 0 linear in, 1 linear out, 2 equal-power in, 3 equal-power out","0");if(!text)return;unsigned int mode;char extra;if(std::sscanf(text,"%u %c",&mode,&extra)!=1||mode>3){ui_alert("Enter a fade mode 0..3.");return;}auto r=e->process(static_cast(XTC_SAMPLE_FADE_IN+mode));if(r!=XTC_OK)ui_alert("Fade failed: %s",xtc_result_string(r));},this); + auto *gain=new UiButton(704,562,160,30,"Gain / normalize...");gain->callback([](UiWidget *,void *c){auto *e=static_cast(c);const char *text=ui_input("Mode (0 gain, 1 normalize), linear gain 0..16 or target peak >0..1","1 0.9");if(!text)return;unsigned int mode;double value;char extra;if(std::sscanf(text,"%u %lf %c",&mode,&value,&extra)!=2||mode>1){ui_alert("Enter mode and linear value.");return;}auto r=e->process(mode ? XTC_SAMPLE_NORMALIZE : XTC_SAMPLE_GAIN,value);if(r!=XTC_OK)ui_alert("Gain edit failed: %s",xtc_result_string(r));},this); + auto *silence=new UiButton(876,562,200,30,"Select audible bounds...");silence->callback([](UiWidget *,void *c){auto *e=static_cast(c);const char *text=ui_input("Silence threshold dBFS (-120..0), padding ms (0..10000)","-60 2");if(!text)return;double db,ms;char extra;if(std::sscanf(text,"%lf %lf %c",&db,&ms,&extra)!=2||!std::isfinite(db)||!std::isfinite(ms)||db< -120||db>0||ms<0||ms>10000){ui_alert("Enter a threshold and padding within the shown bounds.");return;}auto r=e->select_audible(std::pow(10.,db/20),static_cast(std::min(static_cast(e->frames_),ms*e->rate_/1000)));if(r!=XTC_OK)ui_alert("Audible bounds not found: %s",xtc_result_string(r));},this); + auto *zero=new UiButton(16,604,180,30,"Snap to zero crossings...");zero->callback([](UiWidget *,void *c){auto *e=static_cast(c);const char *text=ui_input("Search radius in source frames (0..65536)","256");if(!text)return;unsigned int radius;char extra;if(std::sscanf(text,"%u %c",&radius,&extra)!=1||radius>65536){ui_alert("Enter a radius 0..65536.");return;}auto r=e->snap_zero_crossings(radius);if(r!=XTC_OK)ui_alert("Selection unchanged: %s",xtc_result_string(r));},this); + auto *attacks=new UiButton(210,604,180,30,"Detect attacks...");attacks->callback([](UiWidget *,void *c){auto *e=static_cast(c);const char *text=ui_input("Attack threshold dBFS (-120..0), minimum gap ms (>0..10000)","-12 80");if(!text)return;double db,ms;char extra;if(std::sscanf(text,"%lf %lf %c",&db,&ms,&extra)!=2||!std::isfinite(db)||!std::isfinite(ms)||db< -120||db>0||ms<=0||ms>10000){ui_alert("Enter a threshold and positive minimum gap.");return;}auto r=e->detect_attacks(std::pow(10.,db/20),std::max(size_t(1),static_cast(std::min(static_cast(e->frames_),ms*e->rate_/1000))));if(r!=XTC_OK)ui_alert("Attack detection failed: %s",xtc_result_string(r));},this); + auto *slices=new UiButton(404,604,180,30,"Slice clip at attacks");slices->callback([](UiWidget *,void *c){auto r=static_cast(c)->slice_clip();if(r!=XTC_OK)ui_alert("Clip unchanged: %s",xtc_result_string(r));else static_cast(c)->hide();},this); + auto *undo=new UiButton(850,604,110,30,"Undo / redo");undo->callback([](UiWidget *,void *c){auto r=static_cast(c)->undo_sample();if(r!=XTC_OK)ui_alert("No local sample edit to undo.");},this); + auto *close=new UiButton(970,604,110,30,"Close");close->callback([](UiWidget *,void *c){static_cast(c)->hide();},this);callback([](UiWidget *,void *c){static_cast(c)->hide();},this); + UiGroup::end();xtc_ui::style(this);refresh(); +} +xtc_result SampleEditor::select(size_t b,size_t e){if(b>=e||e>frames_)return XTC_ERR_INVALID_ARGUMENT;if(begin_!=b||end_!=e)attacks_.clear();begin_=b;end_=e;refresh();return XTC_OK;} +xtc_result SampleEditor::view(size_t b,unsigned int zoom){if(b>=frames_||!zoom||zoom>64)return XTC_ERR_INVALID_ARGUMENT;origin_=b;zoom_=zoom;refresh();return XTC_OK;} +void SampleEditor::refresh(){begin_input_->value(static_cast(begin_));end_input_->value(static_cast(end_));origin_input_->value(static_cast(origin_));xtc_sample_stats s{};xtc_sample_analyze(sample_.get(),begin_,end_,&s);char text[256];std::snprintf(text,sizeof(text),"%u Hz / %zu source frames / selection %zu..%zu (%0.6f s) / zoom %ux\nMin %.6f Max %.6f RMS %.6f DC %.6f",rate_,frames_,begin_,end_,static_cast(end_-begin_)/rate_,zoom_,s.minimum,s.maximum,s.rms,s.mean);stats_->copy_label(text);std::string attacks="Detected source frames (nearest tick used for slicing):";for(size_t frame:attacks_)attacks+=" "+std::to_string(frame);stats_->copy_tooltip(attacks.c_str());canvas_->redraw();} +void SampleEditor::run(){show();while(shown())Ui::wait();} + +xtc_result SampleEditor::trim_clip(){return trim ? trim(rate_,begin_,end_) : XTC_ERR_INVALID_STATE;} + +xtc_result SampleEditor::save_copy(const char *path,bool use_in_project){if(use_in_project)return adopt ? adopt(sample_.get(),path) : XTC_ERR_INVALID_STATE;return xtc_sample_write(sample_.get(),0,frames_,path);} +xtc_result SampleEditor::export_selection(const char *path){return xtc_sample_write(sample_.get(),begin_,end_,path);} + +xtc_result SampleEditor::process(xtc_sample_operation operation,double amount) +{ + xtc_sample *next=nullptr;auto r=xtc_sample_process(sample_.get(),begin_,end_,operation,amount,&next);if(r!=XTC_OK)return r; + previous_=std::move(sample_);sample_.reset(next);attacks_.clear();copy_label("xtc — Sample editor / local sample changes; save a copy to keep");refresh();return XTC_OK; +} +xtc_result SampleEditor::undo_sample(){if(!previous_)return XTC_ERR_NOT_FOUND;sample_.swap(previous_);attacks_.clear();refresh();return XTC_OK;} + +xtc_result SampleEditor::select_audible(double threshold,size_t padding){size_t b=0,e=0;auto r=xtc_sample_audible_bounds(sample_.get(),begin_,end_,threshold,padding,&b,&e);return r==XTC_OK ? select(b,e) : r;} + +xtc_result SampleEditor::snap_zero_crossings(size_t radius){size_t b=0,e=0;auto r=xtc_sample_zero_crossing(sample_.get(),begin_,radius,&b);if(r==XTC_OK)r=xtc_sample_zero_crossing(sample_.get(),end_,radius,&e);return r==XTC_OK ? select(b,e) : r;} + +xtc_result SampleEditor::detect_attacks(double threshold,size_t gap){size_t positions[XTC_SAMPLE_MAX_TRANSIENTS],count=0;auto r=xtc_sample_transients(sample_.get(),begin_,end_,threshold,gap,positions,XTC_SAMPLE_MAX_TRANSIENTS,&count);if(r!=XTC_OK)return r;try{std::vector next(positions,positions+count);attacks_.swap(next);}catch(const std::bad_alloc &){return XTC_ERR_OUT_OF_MEMORY;}refresh();return XTC_OK;} +xtc_result SampleEditor::slice_clip(){return slice&&!attacks_.empty() ? slice(rate_,attacks_) : XTC_ERR_INVALID_STATE;} diff --git a/src/gui/JUCE/sample_editor.hpp b/src/gui/JUCE/sample_editor.hpp new file mode 100644 index 0000000..c2b35ac --- /dev/null +++ b/src/gui/JUCE/sample_editor.hpp @@ -0,0 +1,44 @@ +#ifndef XTC_SAMPLE_EDITOR_HPP +#define XTC_SAMPLE_EDITOR_HPP +#include "xtc/xtc_sample.h" +#include "ui.hpp" +#include +#include +#include +class UiValue_Input; +class UiBox; +class SampleCanvas; +class SampleEditor : public UiDouble_Window { +public: + /* Takes ownership of loaded snapshot. Modal edits never touch audio callbacks. */ + using Data=std::unique_ptr; + explicit SampleEditor(Data sample); + std::function trim; + xtc_result trim_clip(); + std::function adopt; + xtc_result save_copy(const char *path,bool use_in_project); + xtc_result export_selection(const char *path); + xtc_result process(xtc_sample_operation operation,double amount=0); + xtc_result undo_sample(); + xtc_result select_audible(double threshold,size_t padding); + xtc_result snap_zero_crossings(size_t radius); + std::function &)> slice; + xtc_result detect_attacks(double threshold,size_t gap); + xtc_result slice_clip(); + const std::vector &attacks() const {return attacks_;} + void run(); + xtc_result select(size_t begin,size_t end); + xtc_result view(size_t begin,unsigned int zoom); + const xtc_sample *sample() const {return sample_.get();} + size_t begin() const {return begin_;} size_t end() const {return end_;} + size_t frames() const {return frames_;} size_t origin() const {return origin_;} + unsigned int zoom() const {return zoom_;} + void refresh(); +private: + std::unique_ptr sample_{nullptr,xtc_sample_destroy}; + std::unique_ptr previous_{nullptr,xtc_sample_destroy}; + std::vector attacks_; + size_t frames_=0,begin_=0,end_=0,origin_=0;unsigned int rate_=0,zoom_=1; + UiValue_Input *begin_input_,*end_input_,*origin_input_;UiBox *stats_;SampleCanvas *canvas_; +}; +#endif diff --git a/src/gui/JUCE/sequencer_clip_widget.cpp b/src/gui/JUCE/sequencer_clip_widget.cpp new file mode 100644 index 0000000..c493c43 --- /dev/null +++ b/src/gui/JUCE/sequencer_clip_widget.cpp @@ -0,0 +1,78 @@ +#include "sequencer_clip_widget.hpp" +#include "sequencer_widget.hpp" +#include "theme.hpp" +#include "xtc/xtc_midi.h" +#include "ui.hpp" +#include + +SequencerClipWidget::SequencerClipWidget(SequencerWidget &owner, const xtc_clip_info &clip) + : UiWidget(0,0,1,46), owner_(owner), info_(clip) {} +void SequencerClipWidget::sync(const xtc_clip_info &clip, int top) +{ + info_=clip; + int left=owner_.x()+owner_.header_width_+static_cast(clip.start_tick*static_cast(owner_.scale_)/960U); + int width=std::max(4,static_cast(clip.length_tick*static_cast(owner_.scale_)/960U)); + resize(left,top+5,width,46); +} +void SequencerClipWidget::draw() +{ + const auto &c=info_; + int left=x(),py=y()-5,cx,cy,cw,ch; + ui_clip_box(x(),y(),w(),h(),cx,cy,cw,ch); + int a=std::max(left,cx),b=std::min(x()+w(),cx+cw); + if(b<=a) return; + size_t notes=0; xtc_project_note_count(owner_.project_,¬es); + UiColor color=xtc_ui::track(c.track_id); + ui_color(ui_color_average(color,xtc_ui::background(),owner_.clip_selected(c.id) ? .75f : .45f)); + ui_rectf(a,py+5,b-a,46); + ui_color(owner_.clip_selected(c.id) ? xtc_ui::text() : color); ui_rect(a,py+5,b-a,46); + ui_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(owner_.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; + ui_color(ui_color_average(color,UI_WHITE,.55f)); + for (size_t n = 0; n < notes; ++n) { + xtc_note_info note{}; xtc_project_get_note(owner_.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(owner_.scale_)/960U); + int nw = std::max(2,static_cast((end-begin)*static_cast(owner_.scale_)/960U)); + ui_rectf(nx,py+26+static_cast((high-note.pitch)*18/range),nw,2); + } + } else owner_.draw_waveform(c,left,py); + ui_color(ui_color_average(color,xtc_ui::background(),.35f)); ui_rectf(a+1,py+6,std::max(0,b-a-2),15); + ui_font(UI_HELVETICA,11); ui_color(xtc_ui::text()); + ui_draw(c.name,left+7,py+18); + ui_pop_clip(); +} + +int SequencerClipWidget::handle(int event) +{ + if(event==UI_PUSH && (Ui::event_button()==UI_LEFT_MOUSE || Ui::event_button()==UI_RIGHT_MOUSE)) { + owner_.take_focus(); + uint64_t previous=owner_.clip_; + owner_.track_=info_.track_id; owner_.clip_=info_.id; + owner_.original_=info_; owner_.drag_x_=Ui::event_x(); + owner_.mode_=Ui::event_x()-x()<7 ? 2 : x()+w()-Ui::event_x()<7 ? 3 : 1; + if(Ui::event_state(UI_SHIFT)) { + owner_.clip_=previous; owner_.toggle_clip(info_.id); owner_.mode_=0; return 1; + } + owner_.selection_.clear(); + if(Ui::event_button()==UI_LEFT_MOUSE && Ui::event_clicks()) { + owner_.mode_=0; + if(owner_.open_clip) owner_.open_clip(owner_.context_); + return 1; + } + if(Ui::event_button()==UI_RIGHT_MOUSE) { owner_.mode_=0; owner_.context_menu(); } + owner_.redraw(); return 1; + } + if(event==UI_DRAG || event==UI_RELEASE) return owner_.handle(event); + return 0; +} diff --git a/src/gui/JUCE/sequencer_clip_widget.hpp b/src/gui/JUCE/sequencer_clip_widget.hpp new file mode 100644 index 0000000..7aea906 --- /dev/null +++ b/src/gui/JUCE/sequencer_clip_widget.hpp @@ -0,0 +1,17 @@ +#ifndef XTC_SEQUENCER_CLIP_WIDGET_HPP +#define XTC_SEQUENCER_CLIP_WIDGET_HPP +#include "ui.hpp" +#include "xtc/xtc_project.h" +class SequencerWidget; +class SequencerClipWidget : public UiWidget { + SequencerWidget &owner_; + xtc_clip_info info_{}; + public: + SequencerClipWidget(SequencerWidget &owner, const xtc_clip_info &clip); + uint64_t clip_id() const { return info_.id; } + void sync(const xtc_clip_info &clip, int track_top); + int handle(int event) override; + protected: + void draw() override; +}; +#endif diff --git a/src/gui/JUCE/sequencer_commands.cpp b/src/gui/JUCE/sequencer_commands.cpp new file mode 100644 index 0000000..8fe10a9 --- /dev/null +++ b/src/gui/JUCE/sequencer_commands.cpp @@ -0,0 +1,412 @@ +#include "sequencer_widget.hpp" +#include "snap.hpp" +#include "xtc/xtc_midi.h" +#include "ui.hpp" +#include +#include +#include +#include +#include + +// User-facing editing commands use the master's single history/publication path. + +xtc_result SequencerWidget::add_track() +{ + if (before_edit) { + xtc_result r = before_edit(context_); + if (r != XTC_OK) { + changed(r); + return r; + } + } + uint64_t id; + xtc_result r = xtc_project_add_track(project_, "Track", &id); + if (r == XTC_OK) { + track_ = id; + clip_ = UINT64_MAX;selection_.clear(); + } + changed(r); + return r; +} + +xtc_result SequencerWidget::add_region() +{ + if (before_edit) { + xtc_result r = before_edit(context_); + if (r != XTC_OK) { + changed(r); + return r; + } + } + size_t tracks, clips; + xtc_project_get_counts(project_, &tracks, &clips); + if (track_ == UINT64_MAX && tracks) { + xtc_track_info t{}; + xtc_project_get_track(project_, 0, &t); + track_ = t.id; + } + uint64_t id; + xtc_result r = xtc_project_add_clip(project_, track_, "Region (silent)", cursor_, new_clip_ticks, &id); + if (r == XTC_OK) {clip_ = id;selection_.clear();} + changed(r); + return r; +} + +xtc_result SequencerWidget::add_midi_clip() +{ + xtc_result r = before_edit ? before_edit(context_) : XTC_OK; + uint64_t id; + if (r == XTC_OK) { + if (track_ == UINT64_MAX) { + xtc_track_info t{}; + r = xtc_project_get_track(project_, 0, &t); + track_ = r == XTC_OK ? t.id : UINT64_MAX; + } + if (r == XTC_OK) + r = xtc_project_add_midi_clip(project_, track_, cursor_, new_clip_ticks, &id); + if (r == XTC_OK) {clip_ = id;selection_.clear();} + } + changed(r); + return r; +} + +xtc_result SequencerWidget::delete_selection() +{ + if(!selected_clips().empty())return transform_selection(XTC_CLIPS_DELETE,0); + xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_remove_track(project_,track_); + if(r==XTC_OK)track_=clip_=UINT64_MAX; + changed(r);return r; +} + +xtc_result SequencerWidget::merge_midi() +{ + auto ids=selected_clips();uint64_t id=0;xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_merge_midi(project_,ids.data(),ids.size(),&id); + if(r==XTC_OK){xtc_clip_info c{};xtc_project_find_clip(project_,id,&c);select(c.track_id,id);} + changed(r);return r; +} + +xtc_result SequencerWidget::duplicate_track(const char *name) +{ + const uint64_t source=track_;uint64_t id=0;xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_duplicate_track(project_,source,name,&id); + if(r==XTC_OK)select(id); + changed(r);return r; +} + +void SequencerWidget::duplicate_track_dialog() +{ + const char *name=ui_input("Name for complete track duplicate","Track copy");if(name)duplicate_track(name); +} + +xtc_result SequencerWidget::ripple_time(uint64_t length,bool remove) +{ + const uint64_t at=cursor_;xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_ripple_time(project_,at,length,remove); + changed(r);return r; +} + +void SequencerWidget::ripple_dialog(bool remove) +{ + const char *input=ui_input(remove ? "Remove time from cursor: ticks (split crossing clips first)" : "Insert time at cursor: ticks (split crossing clips first)","3840");if(!input)return; + uint64_t length=0;char extra;std::istringstream values(input); + if(!(values>>length) || (values>>extra) || !length || length>XTC_PROJECT_MAX_TICK){ui_alert("Enter a positive bounded tick duration.");return;} + ripple_time(length,remove); +} + +xtc_result SequencerWidget::marker(const char *name) +{ + const uint64_t at=cursor_;xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=name ? xtc_project_set_marker(project_,at,name) : xtc_project_remove_marker(project_,at); + changed(r);return r; +} + +void SequencerWidget::marker_dialog() +{ + const char *name=ui_input("Marker at cursor (1..63 UTF-8 bytes)","Section");if(name)marker(name); +} + +std::vector SequencerWidget::selected_clips() const +{ + std::vector ids;xtc_clip_info c{}; + for(auto id:selection_)if(xtc_project_find_clip(project_,id,&c)==XTC_OK)ids.push_back(id); + if(ids.empty() && clip_!=UINT64_MAX && xtc_project_find_clip(project_,clip_,&c)==XTC_OK)ids.push_back(clip_); + return ids; +} + +bool SequencerWidget::clip_selected(uint64_t id) const +{ + return selection_.empty() ? id==clip_ : std::find(selection_.begin(),selection_.end(),id)!=selection_.end(); +} + +bool SequencerWidget::toggle_clip(uint64_t id) +{ + xtc_clip_info c{};if(xtc_project_find_clip(project_,id,&c)!=XTC_OK)return false; + if(selection_.empty() && clip_!=UINT64_MAX)selection_.push_back(clip_); + auto found=std::find(selection_.begin(),selection_.end(),id); + if(found==selection_.end())selection_.push_back(id);else selection_.erase(found); + clip_=selection_.empty() ? UINT64_MAX : selection_.back(); + if(clip_!=UINT64_MAX)xtc_project_find_clip(project_,clip_,&c); + track_=c.track_id;redraw();return true; +} + +void SequencerWidget::select_all_clips() +{ + selection_.clear();size_t tracks=0,clips=0;xtc_project_get_counts(project_,&tracks,&clips); + for(size_t i=0;i output(ids.size()); + xtc_result r=ids.empty() ? XTC_ERR_NOT_FOUND : XTC_OK; + if(r==XTC_OK && before_edit)r=before_edit(context_); + if(r==XTC_OK)r=xtc_project_transform_clips(project_,ids.data(),ids.size(),op,amount,output.data()); + if(r==XTC_OK) { + selection_=op==XTC_CLIPS_DELETE ? std::vector{} : output; + clip_=selection_.empty() ? UINT64_MAX : selection_.back(); + } + changed(r);return r; +} + +xtc_result SequencerWidget::transform_midi(const xtc_midi_transform &edit) +{ + auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_transform_midi(project_,ids.data(),ids.size(),&edit); + changed(r);return r; +} + +void SequencerWidget::fades_dialog() +{ + const char *input=ui_input("Symmetric fade length in ticks (0 removes; at most half each clip)","10");if(!input)return; + int64_t ticks=0;char extra;std::istringstream values(input); + if(!(values>>ticks) || (values>>extra) || ticks<0 || ticks>static_cast(XTC_PROJECT_MAX_TICK)){ui_alert("Enter a valid nonnegative tick length.");return;} + transform_selection(XTC_CLIPS_FADE,ticks); +} + +xtc_result SequencerWidget::make_chord(const std::vector &intervals) +{ + auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_chordify(project_,ids.data(),ids.size(),intervals.data(),intervals.size()); + changed(r);return r; +} + +xtc_result SequencerWidget::arpeggiate(uint64_t step,unsigned int gate,bool descending) +{ + auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_arpeggiate(project_,ids.data(),ids.size(),step,gate,descending); + changed(r);return r; +} + +xtc_result SequencerWidget::normalize_audio(float target) +{ + auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_normalize_audio(project_,ids.data(),ids.size(),target); + changed(r);return r; +} + +void SequencerWidget::normalize_dialog() +{ + const char *input=ui_input("Source sample peak target in dBFS (-60..0; excludes fades and mixer)","-1");if(!input)return; + float db=0;char extra;std::istringstream values(input); + if(!(values>>db) || (values>>extra) || !std::isfinite(db) || db < -60 || db>0){ui_alert("Enter a finite sample peak target from -60 to 0 dBFS.");return;} + normalize_audio(std::pow(10.0f,db/20.0f)); +} + +void SequencerWidget::arpeggio_dialog() +{ + const char *input=ui_input("Step ticks (1..3840), gate percent (1..100), direction (0=up, 1=down)","240 80 0");if(!input)return; + uint64_t step=0;unsigned int gate=0;int down=0;char extra;std::istringstream values(input); + if(!(values>>step>>gate>>down) || (values>>extra) || !step || step>3840 || !gate || gate>100 || down<0 || down>1){ui_alert("Enter a valid step, gate and direction.");return;} + arpeggiate(step,gate,down!=0); +} + +void SequencerWidget::chord_dialog() +{ + const char *input=ui_input("Additional semitone intervals: 1..8 unique nonzero values (-127..127)","4 7");if(!input)return; + std::istringstream values(input);std::vector intervals;int interval; + while(values>>interval){intervals.push_back(interval);if(intervals.size()>8)break;} + if(!values.eof() || intervals.empty() || intervals.size()>8){ui_alert("Enter 1 to 8 semitone intervals.");return;} + make_chord(intervals); +} + +void SequencerWidget::humanize_dialog() +{ + const char *input=ui_input("Timing variation ticks (0..960), velocity variation (0..127), seed","12 8 1");if(!input)return; + unsigned int timing=0,velocity=0,seed=0;char extra;std::istringstream values(input); + if(!(values>>timing>>velocity>>seed) || (values>>extra) || timing>960 || velocity>127){ui_alert("Enter bounded timing/velocity variation and a 32-bit seed.");return;} + xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_HUMANIZE;edit.timing_variation=timing;edit.velocity_variation=velocity;edit.seed=seed;transform_midi(edit); +} + +void SequencerWidget::velocity_dialog() +{ + const char *input=ui_input("Velocity percentage (0..400), then offset (-127..127)","100 0");if(!input)return; + unsigned int percent=0;int offset=0;char extra;std::istringstream values(input); + if(!(values>>percent>>offset) || (values>>extra) || percent>400 || offset < -127 || offset>127){ui_alert("Enter a valid percentage and velocity offset.");return;} + xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_VELOCITY;edit.velocity_percent=percent;edit.velocity_offset=offset;transform_midi(edit); +} + +void SequencerWidget::scale_dialog() +{ + const char *input=ui_input("Root (0=C..11=B), scale (0=chromatic, 1=major, 2=natural minor)","0 1");if(!input)return; + unsigned int root=0,scale=0;char extra;std::istringstream values(input); + if(!(values>>root>>scale) || (values>>extra) || root>11 || scale>2){ui_alert("Enter a root from 0 to 11 and scale from 0 to 2.");return;} + xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_SCALE;edit.root=root;edit.scale=scale;transform_midi(edit); +} + +void SequencerWidget::gate_dialog() +{ + const char *input=ui_input("Note length percentage (1..400)","50");if(!input)return; + unsigned int percent=0;char extra;std::istringstream values(input); + if(!(values>>percent) || (values>>extra) || percent<1 || percent>400){ui_alert("Enter a whole percentage from 1 to 400.");return;} + xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_GATE;edit.gate_percent=percent;transform_midi(edit); +} + +void SequencerWidget::swing_dialog() +{ + const char *input=ui_input("Grid ticks (1..3840), swing (0..100), strength (0..100)","240 50 100");if(!input)return; + int grid=0,swing=-1,strength=-1;char extra; + std::istringstream values(input); + if(!(values>>grid>>swing>>strength) || (values>>extra) || grid<1 || grid>3840 || swing<0 || swing>100 || strength<0 || strength>100){ui_alert("Enter a valid grid, swing and strength.");return;} + xtc_midi_transform edit{};edit.type=XTC_MIDI_TRANSFORM_SWING;edit.grid=static_cast(grid);edit.swing=static_cast(swing);edit.strength=static_cast(strength);transform_midi(edit); +} + +xtc_result SequencerWidget::repeat_selection(unsigned int copies) +{ + auto ids=selected_clips();xtc_result r=before_edit ? before_edit(context_) : XTC_OK; + if(r==XTC_OK)r=xtc_project_repeat_clips(project_,ids.data(),ids.size(),copies); + changed(r);return r; +} + +void SequencerWidget::repeat_dialog() +{ + const char *input=ui_input("Additional phrase copies (1..1024)","3");if(!input)return; + char *end=nullptr;unsigned long copies=std::strtoul(input,&end,10); + if(end==input || *end || copies<1 || copies>1024){ui_alert("Enter a whole number from 1 to 1024.");return;} + repeat_selection(static_cast(copies)); +} + +xtc_result SequencerWidget::copy_selection() +{ + auto ids=selected_clips();xtc_clipboard *copy=nullptr; + xtc_result r=xtc_clipboard_create(project_,ids.data(),ids.size(),©); + if(r==XTC_OK)clipboard_.reset(copy); + else ui_alert("Copy unchanged: %s",xtc_result_string(r)); + return r; +} + +xtc_result SequencerWidget::paste_selection() +{ + const uint64_t start=cursor_,track=track_; + std::vector output(xtc_clipboard_count(clipboard_.get())); + xtc_result r=output.empty() ? XTC_ERR_INVALID_STATE : XTC_OK; + if(r==XTC_OK && before_edit)r=before_edit(context_); + if(r==XTC_OK)r=xtc_clipboard_paste(clipboard_.get(),project_,track,start,output.data()); + if(r==XTC_OK){selection_=output;clip_=selection_.back();xtc_clip_info c{};xtc_project_find_clip(project_,clip_,&c);track_=c.track_id;} + changed(r);return r; +} + +xtc_result SequencerWidget::nudge_selection(int direction) +{ + return transform_selection(XTC_CLIPS_MOVE,direction<0 ? -static_cast(grid_) : static_cast(grid_)); +} + +xtc_result SequencerWidget::duplicate_selection() +{ + auto ids=selected_clips();uint64_t begin=XTC_PROJECT_MAX_TICK,end=0; + for(auto id:ids){xtc_clip_info c{};xtc_project_find_clip(project_,id,&c);begin=std::min(begin,c.start_tick);end=std::max(end,c.start_tick+c.length_tick);} + return transform_selection(XTC_CLIPS_DUPLICATE,static_cast(end)-static_cast(begin)); +} + +xtc_result SequencerWidget::rename_selection(const char *name) +{ + xtc_result r = before_edit ? before_edit(context_) : XTC_OK; + if (r == XTC_OK) r = clip_ != UINT64_MAX ? xtc_project_rename_clip(project_,clip_,name) : xtc_project_rename_track(project_,track_,name); + changed(r); return r; +} + +xtc_result SequencerWidget::move_track(int direction) +{ + size_t tracks, clips, i; xtc_project_get_counts(project_,&tracks,&clips); + xtc_track_info track{}; + for (i = 0; i < tracks; ++i) { xtc_project_get_track(project_,i,&track); if (track.id == track_) break; } + if (i == tracks || (direction < 0 ? i == 0 : i+1 == tracks)) return XTC_ERR_NOT_FOUND; + xtc_result r = before_edit ? before_edit(context_) : XTC_OK; + if (r == XTC_OK) r = xtc_project_move_track(project_,track_,direction < 0 ? i-1 : i+1); + changed(r); return r; +} + +xtc_result SequencerWidget::split_selection() +{ + uint64_t right; + const uint64_t split_tick = cursor_; // Stop/status refresh may move the playhead. + xtc_result result = before_edit ? before_edit(context_) : XTC_OK; + if (result == XTC_OK) { + xtc_clip_info c{};result=xtc_project_find_clip(project_,clip_,&c); + if(result==XTC_OK)result=c.midi ? xtc_project_split_midi(project_,clip_,split_tick,&right) : xtc_project_split_audio(project_,clip_,split_tick,&right); + } + if (result == XTC_OK) {clip_ = right;selection_.clear();} + changed(result); + return result; +} + +xtc_result SequencerWidget::audio_properties(float gain, uint64_t in, uint64_t out) +{ + xtc_result result = before_edit ? before_edit(context_) : XTC_OK; + if (result == XTC_OK) result = xtc_project_set_clip_audio(project_, clip_, gain, in, out); + changed(result); + return result; +} + +void SequencerWidget::audio_properties_dialog() +{ + xtc_clip_info clip{}; + if (xtc_project_find_clip(project_, clip_, &clip) != XTC_OK || clip.midi) { + ui_alert("Select an audio clip or silent region first."); + return; + } + char defaults[128]; + std::snprintf(defaults, sizeof(defaults), "%.9g %llu %llu", static_cast(clip.gain), + static_cast(clip.fade_in_tick), static_cast(clip.fade_out_tick)); + const char *text = ui_input("Linear gain (0..4), fade-in ticks, fade-out ticks (0 disables)", defaults); + if (!text) return; + std::istringstream input(text); + float gain; uint64_t in, out; + if (!(input >> gain >> in >> out) || !(input >> std::ws).eof()) { + ui_alert("Enter gain and two fade durations in ticks."); return; + } + audio_properties(gain, in, out); +} + +xtc_result SequencerWidget::crossfade_selection() +{ + auto ids=selected_clips();xtc_result r=ids.size()==2 ? XTC_OK : XTC_ERR_INVALID_ARGUMENT; + if(r==XTC_OK && before_edit)r=before_edit(context_); + if(r==XTC_OK)r=xtc_project_crossfade(project_,ids[0],ids[1]); + changed(r);return r; +} + +xtc_result SequencerWidget::strum_chords(uint64_t spacing,bool descending) +{ + auto ids=selected_clips();xtc_result r=XTC_OK; + if(before_edit)r=before_edit(context_); + if(r==XTC_OK)r=xtc_project_strum(project_,ids.data(),ids.size(),spacing,descending ? 1 : 0); + changed(r);return r; +} + +void SequencerWidget::strum_dialog() +{ + const char *input=ui_input("Spacing ticks (1..960), direction (0=up, 1=down)","30 0");if(!input)return; + uint64_t spacing=0;int down=0;char extra;std::istringstream values(input); + if(!(values>>spacing>>down) || (values>>extra) || !spacing || spacing>960 || down<0 || down>1){ui_alert("Enter a valid spacing and direction.");return;} + strum_chords(spacing,down!=0); +} diff --git a/src/gui/JUCE/sequencer_group_scope.hpp b/src/gui/JUCE/sequencer_group_scope.hpp new file mode 100644 index 0000000..dabcb02 --- /dev/null +++ b/src/gui/JUCE/sequencer_group_scope.hpp @@ -0,0 +1,12 @@ +#ifndef XTC_SEQUENCER_GROUP_SCOPE_HPP +#define XTC_SEQUENCER_GROUP_SCOPE_HPP +#include "ui.hpp" +class SequencerGroupScope { + UiGroup *previous_; + public: + explicit SequencerGroupScope(UiGroup *group) : previous_(UiGroup::current()) { UiGroup::current(group); } + ~SequencerGroupScope() { UiGroup::current(previous_); } + SequencerGroupScope(const SequencerGroupScope &) = delete; + SequencerGroupScope &operator=(const SequencerGroupScope &) = delete; +}; +#endif diff --git a/src/gui/JUCE/sequencer_instruments.cpp b/src/gui/JUCE/sequencer_instruments.cpp new file mode 100644 index 0000000..bd5c330 --- /dev/null +++ b/src/gui/JUCE/sequencer_instruments.cpp @@ -0,0 +1,63 @@ +#include "sequencer_widget.hpp" +#include "sequencer_track_widget.hpp" +#include +#include + +UiChoice *SequencerWidget::instrument_choice(uint64_t track) const +{ + auto *view=track_widget(track); + return view && !view->is_bus() ? view->instrument_choice() : nullptr; +} + +void SequencerWidget::refresh_instruments() try +{ + sync_views(); + size_t tracks = 0, clips = 0; + xtc_project_get_counts(project_,&tracks,&clips); + catalog_.clear(); + for (unsigned int i=0;i<2;++i) { xtc_plugin_spec s{}; if (xtc_plugin_probe(XTC_BUILTIN_PATH,i,48000,&s)==XTC_OK) catalog_.push_back(s); } + for (size_t i = 0; i < tracks; ++i) { + xtc_track_info t{}; xtc_plugin_spec spec{}; + xtc_project_get_track(project_,i,&t); + if (xtc_project_get_instrument(project_,t.id,&spec) != XTC_OK) continue; + bool present = false; + for (const auto &item : catalog_) if (!std::strcmp(item.path,spec.path) && item.index == spec.index) present = true; + if (!present) catalog_.push_back(spec); + } + for(auto *view:track_widgets_) { + auto *choice=view->instrument_choice(); + xtc_track_info t{}; + for(size_t i=0;itrack_id()) break;} + if(t.is_bus) {choice->hide(); continue;} + choice->clear(); choice->add("Built-in sine"); + xtc_plugin_spec current{}; int selected = 0; + bool assigned = xtc_project_get_instrument(project_,t.id,¤t) == XTC_OK; + for (size_t j = 0; j < catalog_.size(); ++j) { + std::string name = std::string(xtc_plugin_format_name(catalog_[j].format))+": "+catalog_[j].label; + int item = choice->add("Instrument"); choice->replace(item,name.c_str()); + if (assigned && !std::strcmp(current.path,catalog_[j].path) && current.index == catalog_[j].index) selected = static_cast(j)+1; + } + choice->add("Load plugin..."); choice->value(selected); choice->show(); + } +} catch (const std::bad_alloc &) { + catalog_.clear(); + for(auto *view:track_widgets_) view->instrument_choice()->hide(); +} + +void SequencerWidget::instrument_callback(UiWidget *widget, void *context) +{ + auto *owner = static_cast(context); + auto *choice = static_cast(widget); + for (auto *view : owner->track_widgets_) if (view->instrument_choice() == choice) { + uint64_t track = view->track_id(); + int index = choice->value(); + bool browse = index == static_cast(owner->catalog_.size())+1; + xtc_plugin_spec spec{}; + if (index > 0 && !browse) { + if (static_cast(index) > owner->catalog_.size()) return; + spec = owner->catalog_[static_cast(index)-1]; + } + if (owner->instrument_select) owner->instrument_select(owner->context_,track,index > 0 && !browse ? &spec : nullptr,browse); + owner->refresh_instruments(); owner->redraw(); return; + } +} diff --git a/src/gui/JUCE/sequencer_ruler_widget.cpp b/src/gui/JUCE/sequencer_ruler_widget.cpp new file mode 100644 index 0000000..2370367 --- /dev/null +++ b/src/gui/JUCE/sequencer_ruler_widget.cpp @@ -0,0 +1,66 @@ +#include "sequencer_ruler_widget.hpp" +#include "sequencer_widget.hpp" +#include "theme.hpp" +#include "ui.hpp" +#include +#include + +SequencerRulerWidget::SequencerRulerWidget(SequencerWidget &owner) + : UiWidget(owner.x(),owner.y(),owner.w(),28), owner_(owner) {} +void SequencerRulerWidget::draw() +{ + int cx,cy,cw,ch; ui_clip_box(x(),y(),w(),h(),cx,cy,cw,ch); + ui_push_clip(x(),y(),w(),h()); + ui_font(UI_HELVETICA,11); + int first=std::max(0,(cx-owner_.x()-owner_.header_width_)/(owner_.scale_*4)); + int last=(cx+cw-owner_.x()-owner_.header_width_)/(owner_.scale_*4)+1; + for(int bar=first;bar<=last;++bar) { + char label[32]; std::snprintf(label,sizeof(label),"%d",bar+1); + ui_color(xtc_ui::muted()); ui_draw(label,owner_.x()+owner_.header_width_+bar*owner_.scale_*4+6,y()+19); + } + uint64_t begin=owner_.mode_==4 ? std::min(owner_.loop_anchor_,owner_.loop_preview_) : owner_.loop_begin_; + uint64_t end=owner_.mode_==4 ? std::max(owner_.loop_anchor_,owner_.loop_preview_) : owner_.loop_end_; + int left=owner_.x()+owner_.header_width_+static_cast(begin*static_cast(owner_.scale_)/960U); + int right=owner_.x()+owner_.header_width_+static_cast(end*static_cast(owner_.scale_)/960U); + ui_color(owner_.loop_enabled_ ? xtc_ui::accent() : xtc_ui::muted()); ui_rectf(left,y()+23,std::max(0,right-left),4); + size_t markers=0; xtc_project_marker_count(owner_.project_,&markers); + for(size_t i=0;i(marker.tick*static_cast(owner_.scale_)/960U); + if(pxcx+cw) continue; + ui_push_clip(px,y(),160,12); ui_font(UI_HELVETICA,10); ui_color(xtc_ui::accent()); ui_draw(marker.name,px+3,y()+10); ui_pop_clip(); + } + int playhead=owner_.x()+owner_.header_width_+static_cast(owner_.cursor_*static_cast(owner_.scale_)/960U); + ui_color(xtc_ui::accent()); ui_polygon(playhead-4,y(),playhead+4,y(),playhead,y()+6); + int pinned=owner_.header_left(); + ui_color(xtc_ui::background()); ui_rectf(pinned,y(),owner_.header_width_,28); + ui_color(xtc_ui::muted()); ui_font(UI_HELVETICA,10); ui_draw("TRACKS / drag edge to resize",pinned+10,y()+19); + ui_pop_clip(); +} +int SequencerRulerWidget::handle(int event) +{ + if (event==UI_DRAG && owner_.mode_==4) {owner_.loop_preview_=owner_.tick(Ui::event_x());owner_.redraw();return 1;} + if (event==UI_RELEASE && owner_.mode_==4) { + owner_.loop_preview_=owner_.tick(Ui::event_x()); owner_.mode_=0; + uint64_t begin=std::min(owner_.loop_anchor_,owner_.loop_preview_),end=std::max(owner_.loop_anchor_,owner_.loop_preview_); + if(end>begin && owner_.loop_select) { + xtc_result r=owner_.loop_select(owner_.context_,begin,end); + if(r!=XTC_OK) ui_alert("Loop unchanged: %s",xtc_result_string(r)); + } + owner_.redraw();return 1; + } + + if(event==UI_PUSH && (Ui::event_button()==UI_LEFT_MOUSE || Ui::event_button()==UI_RIGHT_MOUSE)) { + owner_.take_focus(); owner_.mode_=0; + if(Ui::event_x() +#include + +SequencerTrackWidget::SequencerTrackWidget(SequencerWidget &owner, const xtc_track_info &track) + : UiGroup(owner.x(),owner.y()+28,owner.w(),56), owner_(owner), info_(track) +{ + resizable(nullptr); + clips_.reserve(XTC_PROJECT_MAX_CLIPS); + velocity_=new SequencerVelocityWidget(owner,track.id); + header_=new SequencerTrackHeader(*this); + end(); +} + +UiChoice *SequencerTrackWidget::instrument_choice() const { return header_->instrument_choice(); } +UiGroup *SequencerTrackWidget::header() const { return header_; } +SequencerClipWidget *SequencerTrackWidget::clip_widget(uint64_t clip) const +{ + for(auto *view:clips_) if(view->clip_id()==clip) return view; + return nullptr; +} + +void SequencerTrackWidget::sync(size_t row, const xtc_track_info &track) +{ + row_=row; info_=track; + resize(owner_.x(),owner_.track_top(row),owner_.w(),owner_.track_height(row)); + header_->resize(owner_.header_left(),y(),owner_.header_width_,h()); + instrument_choice()->resize(header_->x()+10,y()+30,header_->w()-22,20); + if(info_.is_bus) instrument_choice()->hide(); + velocity_->resize(owner_.x()+owner_.header_width_,y()+56,owner_.w()-owner_.header_width_,88); + if(owner_.track_expanded(info_.id)) velocity_->show(); else velocity_->hide(); + for(auto it=clips_.begin();it!=clips_.end();) { + xtc_clip_info c{}; + if(xtc_project_find_clip(owner_.project_,(*it)->clip_id(),&c)!=XTC_OK || c.track_id!=info_.id) { + auto *removed=*it; it=clips_.erase(it); + removed->hide(); remove(removed); Ui::delete_widget(removed); + } else ++it; + } + size_t tracks=0,clips=0; xtc_project_get_counts(owner_.project_,&tracks,&clips); + SequencerGroupScope scope(this); + for(size_t i=0;isync(c,y()); + // Match dense model order for overlapping clip drawing and hit testing. + insert(*view,header_); + } +} + +void SequencerTrackWidget::draw() +{ + ui_push_clip(x(),y(),w(),h()); + draw_children(); + ui_pop_clip(); +} + +int SequencerTrackWidget::handle(int event) +{ + if(event==UI_PUSH) { + if(Ui::event_inside(header_)) return header_->handle(event); + if(velocity_->visible() && Ui::event_inside(velocity_)) return velocity_->handle(event); + // Preserve the full-row clip hit area, including its small vertical margins. + for(int i=children()-1;i>=0;--i) { + auto *view=dynamic_cast(child(i)); + if(view && Ui::event_x()>=view->x() && Ui::event_x()x()+view->w()) return view->handle(event); + } + owner_.take_focus(); owner_.track_=info_.id; owner_.clip_=UINT64_MAX; + owner_.selection_.clear(); owner_.mode_=0; + if(Ui::event_button()==UI_RIGHT_MOUSE) owner_.context_menu(); + owner_.redraw(); return 1; + } + if(event==UI_DRAG || event==UI_RELEASE) return owner_.handle(event); + return UiGroup::handle(event); +} + +SequencerTrackHeader::SequencerTrackHeader(SequencerTrackWidget &track) + : UiGroup(track.x(),track.y(),180,56), track_(track) +{ + resizable(nullptr); + instrument_=new UiChoice(x()+10,y()+30,w()-22,20); + xtc_ui::style(instrument_); instrument_->textsize(10); instrument_->textcolor(xtc_ui::text()); + instrument_->tooltip("Track instrument: built-in sine, an instrument used in this project, or Load plugin..."); + instrument_->callback(SequencerWidget::instrument_callback,&track.owner_); + end(); +} + +void SequencerTrackHeader::draw() +{ + auto &owner=track_.owner_; const auto &t=track_.info_; + ui_push_clip(x(),y(),w(),h()); + ui_color(t.id==owner.track_ ? ui_rgb_color(49,62,70) : xtc_ui::panel()); + ui_rectf(x(),y(),w()-2,h()-1); + ui_color(xtc_ui::track(t.id)); ui_rectf(x(),y(),3,h()-1); + ui_push_clip(x()+9,y(),w()-18,55); + ui_color(xtc_ui::text()); ui_font(UI_HELVETICA_BOLD,12); + if(owner.track_expanded(t.id)) ui_polygon(x()+10,y()+10,x()+20,y()+10,x()+15,y()+16); + else ui_polygon(x()+12,y()+8,x()+12,y()+18,x()+18,y()+13); + ui_draw(t.name,x()+26,y()+18); + if(t.is_bus) { + char detail[64]; std::snprintf(detail,sizeof(detail),"%02zu BUS%s%s",track_.row_+1,t.mute ? " MUTE" : "",t.solo ? " SOLO" : ""); + ui_font(UI_HELVETICA,10); ui_color(xtc_ui::muted()); ui_draw(detail,x()+12,y()+42); + } + if(t.mute || t.solo) { + ui_font(UI_HELVETICA,10); ui_color(xtc_ui::muted()); ui_draw(t.mute ? "M" : "S",x()+w()-16,y()+18); + } + ui_pop_clip(); + if(owner.track_expanded(t.id)) { + ui_color(xtc_ui::muted()); ui_font(UI_HELVETICA,11); + bool audio=track_.velocity_->audio(); + ui_draw(audio ? "AUDIO VELOCITY" : "VELOCITY 1..127",x()+12,y()+76); + ui_font(UI_HELVETICA,10); + ui_draw(audio ? "Level envelope 0..400%" : "Drag bars to edit notes",x()+12,y()+94); + char value[32]; if(track_.velocity_->preview_label(value,sizeof(value))) ui_draw(value,x()+12,y()+114); + } + draw_children(); + ui_pop_clip(); +} + +int SequencerTrackHeader::handle(int event) +{ + auto &owner=track_.owner_; + if(event==UI_PUSH) { + if(instrument_->visible() && Ui::event_inside(instrument_)) return UiGroup::handle(event); + owner.take_focus(); + if(Ui::event_button()==UI_LEFT_MOUSE && Ui::event_x()>=x()+7 && Ui::event_x()=y()+56) return 1; + owner.track_=track_.info_.id; owner.clip_=UINT64_MAX; owner.selection_.clear(); owner.mode_=0; + if(Ui::event_button()==UI_RIGHT_MOUSE) owner.context_menu(); + owner.redraw(); return 1; + } + return UiGroup::handle(event); +} diff --git a/src/gui/JUCE/sequencer_track_widget.hpp b/src/gui/JUCE/sequencer_track_widget.hpp new file mode 100644 index 0000000..45900a8 --- /dev/null +++ b/src/gui/JUCE/sequencer_track_widget.hpp @@ -0,0 +1,41 @@ +#ifndef XTC_SEQUENCER_TRACK_WIDGET_HPP +#define XTC_SEQUENCER_TRACK_WIDGET_HPP +#include "ui.hpp" +#include "xtc/xtc_project.h" +#include +class SequencerWidget; +class SequencerClipWidget; +class SequencerVelocityWidget; +class SequencerTrackHeader; +class SequencerTrackWidget : public UiGroup { + SequencerWidget &owner_; + xtc_track_info info_{}; + size_t row_ = 0; + SequencerTrackHeader *header_; + SequencerVelocityWidget *velocity_; + std::vector clips_; // Borrowed; JUCE owns children. + friend class SequencerTrackHeader; + public: + SequencerTrackWidget(SequencerWidget &owner, const xtc_track_info &track); + uint64_t track_id() const { return info_.id; } + bool is_bus() const { return info_.is_bus != 0; } + void sync(size_t row, const xtc_track_info &track); + UiChoice *instrument_choice() const; + SequencerClipWidget *clip_widget(uint64_t clip) const; + SequencerVelocityWidget *velocity_lane() const { return velocity_; } + UiGroup *header() const; + int handle(int event) override; + protected: + void draw() override; +}; +class SequencerTrackHeader : public UiGroup { + SequencerTrackWidget &track_; + UiChoice *instrument_; + public: + explicit SequencerTrackHeader(SequencerTrackWidget &track); + UiChoice *instrument_choice() const { return instrument_; } + int handle(int event) override; + protected: + void draw() override; +}; +#endif diff --git a/src/gui/JUCE/sequencer_velocity_widget.cpp b/src/gui/JUCE/sequencer_velocity_widget.cpp new file mode 100644 index 0000000..0b519a5 --- /dev/null +++ b/src/gui/JUCE/sequencer_velocity_widget.cpp @@ -0,0 +1,188 @@ +#include "sequencer_velocity_widget.hpp" +#include "sequencer_widget.hpp" +#include "theme.hpp" +#include "ui.hpp" +#include +#include +#include +#include + +SequencerVelocityWidget::SequencerVelocityWidget(SequencerWidget &owner, uint64_t track) + : UiWidget(0,0,1,88), owner_(owner), track_(track) {} +bool SequencerVelocityWidget::preview_label(char *text, size_t size) const +{ + if(owner_.active_velocity_!=this) return false; + if(owner_.mode_==7) std::snprintf(text,size,"Level: %.0f%%",audio_preview_.value*100); + else if(owner_.mode_==6) std::snprintf(text,size,"Velocity: %u",preview_); + else return false; + return true; +} +bool SequencerVelocityWidget::note_position(const xtc_note_info ¬e, int &px) const +{ + xtc_clip_info c{}; + if(xtc_project_find_clip(owner_.project_,note.clip_id,&c)!=XTC_OK || c.track_id!=track_ || !c.midi || + note.start_tick=c.length_tick) return false; + px=owner_.x()+owner_.header_width_+static_cast((c.start_tick+note.start_tick-c.offset_tick)*static_cast(owner_.scale_)/960U); + return true; +} + +bool SequencerVelocityWidget::audio() const +{ + size_t tracks=0, clips=0; xtc_project_get_counts(owner_.project_,&tracks,&clips); + bool midi=false; + for(size_t i=0;i(y()+h()-8-py)*4.0f/(h()-28),0.0f,4.0f); +} + +void SequencerVelocityWidget::draw_audio(const xtc_track_info &track, int left, int right) +{ + std::array points{}; + size_t count=0,total=0; xtc_project_automation_count(owner_.project_,&total); + for(size_t i=0;i(t*static_cast(owner_.scale_)/960U);}; + auto py=[&](float v){return y()+h()-8-static_cast(v*(h()-28)/4.0f);}; + ui_color(xtc_ui::track(track.id)); + if(!count) ui_line(left,py(track.gain),right,py(track.gain)); + else { + ui_line(left,py(points[0].value),px(points[0].tick),py(points[0].value)); + for(size_t i=1;iright+4) continue; + ui_rectf(point_x-4,py(points[i].value)-3,8,6); + } + } +} + +unsigned int SequencerVelocityWidget::note_value(int py) const +{ + return static_cast(std::clamp((y()+h()-8-py)*127/(h()-28),1,127)); +} + +void SequencerVelocityWidget::draw() +{ + int cx,cy,cw,ch; ui_clip_box(x(),y(),w(),h(),cx,cy,cw,ch); + int left=std::max(cx,owner_.header_left()+owner_.header_width_); + ui_push_clip(left,y(),std::max(0,cx+cw-left),h()); + ui_color(xtc_ui::border()); ui_line(x(),y(),x()+w(),y()); + ui_font(UI_HELVETICA,10); ui_color(xtc_ui::muted()); + bool is_audio=audio(); + ui_draw(is_audio ? "Click anywhere to add a level point; drag vertically; right-click a point to remove" : "Drag a note bar to change velocity",left+8,y()+14); + xtc_track_info track{}; + size_t tracks=0,clips=0; xtc_project_get_counts(owner_.project_,&tracks,&clips); + for(size_t i=0;i(value)*(h()-28)/127; + ui_color(editing ? xtc_ui::text() : xtc_ui::track(track_)); + ui_rectf(px-2,ny,4,bottom-ny); ui_rectf(px-4,ny-2,8,4); + } + ui_pop_clip(); +} + +int SequencerVelocityWidget::handle(int event) +{ + if(owner_.mode_==7) { + if(event==UI_KEYDOWN && Ui::event_key()==UI_Escape) { owner_.mode_=0; owner_.redraw(); return 1; } + if(event==UI_DRAG) { audio_preview_.value=audio_value(Ui::event_y()); owner_.redraw(); return 1; } + if(event==UI_RELEASE) { + owner_.mode_=0; + auto point=audio_preview_; + point.value=audio_value(Ui::event_y()); + if(audio_existing_) { + size_t count=0; xtc_project_automation_count(owner_.project_,&count); + for(size_t i=0;i(std::max(0,Ui::event_x()-owner_.x()-owner_.header_width_))*960/static_cast(owner_.scale_)); + audio_preview_={track_,position,XTC_AUTOMATION_GAIN,audio_value(Ui::event_y()),0}; + audio_existing_=false; + size_t count=0; xtc_project_automation_count(owner_.project_,&count); int distance=12; + for(size_t i=0;i(a.tick*static_cast(owner_.scale_)/960U); + int py=y()+h()-8-static_cast(a.value*(h()-28)/4.0f); + int d=std::abs(Ui::event_x()-px)+std::abs(Ui::event_y()-py); + if(d(note.velocity)*(h()-28)/127; + int d=std::abs(Ui::event_x()-px)+std::abs(Ui::event_y()-ny); + if(d +#include + +void SequencerWidget::refresh_waveforms() try +{ + // Control-thread loading only. Keep cached failures as well as successes; + // removed/relinked paths are evicted. Reopening rebuilds externally changed files. + size_t tracks, clips; + xtc_project_get_counts(project_, &tracks, &clips); + std::vector next; + for (size_t i = 0; i < clips && next.size() < 64; ++i) { + xtc_clip_info c{}; + xtc_project_get_clip(project_, i, &c); + if (!c.asset_path[0]) continue; + bool present = false; + for (const auto &wave : next) if (wave.path == c.asset_path) { present = true; break; } + if (present) continue; + Wave entry; + entry.path = c.asset_path; + for (auto &wave : waves_) if (wave.path == entry.path) { + entry.data = std::move(wave.data); present = true; break; + } + if (!present) { + xtc_waveform *summary = nullptr; + (void)xtc_waveform_create(c.asset_path, &summary); + entry.data.reset(summary); + } + next.push_back(std::move(entry)); + } + waves_ = std::move(next); +} catch (const std::bad_alloc &) { + waves_.clear(); // Preview failure must not interrupt the model/history edit. +} + +void SequencerWidget::draw_waveform(const xtc_clip_info &c, int left, int top) +{ + const xtc_waveform *summary = nullptr; + for (const auto &wave : waves_) if (wave.path == c.asset_path) { summary = wave.data.get(); break; } + if (!summary) return; + unsigned int rate; size_t frames, bins; + if (xtc_waveform_info(summary, &rate, &frames, &bins) != XTC_OK) return; + double ticks_per_frame = static_cast(xtc_project_tempo(project_)) * 960 / (rate * 60000.0); + ui_color(ui_rgb_color(30, 65, 80)); + for (size_t bin = 0; bin < bins; ++bin) { + double start = static_cast(bin * frames / bins) * ticks_per_frame; + double end = static_cast((bin+1) * frames / bins) * ticks_per_frame; + double offset = static_cast(c.offset_tick); + if (end <= offset || start >= offset + static_cast(c.length_tick)) continue; + int px = left + static_cast((std::max(start, offset) - offset) * scale_ / 960); + int width = std::max(1, static_cast((end - std::max(start, offset)) * scale_ / 960)); + float low, high; + if (xtc_waveform_peak(summary, bin, &low, &high) != XTC_OK) continue; + int y1 = top + 28 - static_cast(std::clamp(high, -1.0f, 1.0f) * 19); + int y2 = top + 28 - static_cast(std::clamp(low, -1.0f, 1.0f) * 19); + ui_rectf(px, y1, width, std::max(1, y2-y1+1)); + } +} diff --git a/src/gui/JUCE/sequencer_widget.cpp b/src/gui/JUCE/sequencer_widget.cpp new file mode 100644 index 0000000..d3ae57e --- /dev/null +++ b/src/gui/JUCE/sequencer_widget.cpp @@ -0,0 +1,382 @@ +#include "sequencer_widget.hpp" +#include "sequencer_track_widget.hpp" +#include "sequencer_velocity_widget.hpp" +#include "sequencer_ruler_widget.hpp" +#include "sequencer_group_scope.hpp" +#include "theme.hpp" +#include "snap.hpp" +#include "ui.hpp" +#include +#include + +SequencerWidget::SequencerWidget(int left, int top, xtc_project *project, Seek seek, void *context) + : UiGroup(left, top, 6300, 3700), project_(project), seek_(seek), context_(context) +{ + expanded_tracks_.fill(UINT64_MAX); + resizable(nullptr); + track_widgets_.reserve(XTC_PROJECT_MAX_TRACKS); + ruler_=new SequencerRulerWidget(*this); + extent(); + end(); +} + +bool SequencerWidget::select(uint64_t track, uint64_t clip) +{ + size_t tracks = 0, clips = 0; + xtc_project_get_counts(project_,&tracks,&clips); + bool found = false; + for (size_t i = 0; i < tracks; ++i) { + xtc_track_info info{}; + if (xtc_project_get_track(project_,i,&info) == XTC_OK && info.id == track) found = true; + } + if (!found) return false; + if (clip != UINT64_MAX) { + xtc_clip_info info{}; + if (xtc_project_find_clip(project_,clip,&info) != XTC_OK || info.track_id != track) return false; + } + selection_.clear(); + track_ = track; clip_ = clip; mode_ = 0; redraw(); + return true; +} + +void SequencerWidget::set_project(xtc_project *project, bool reset, bool preserve_expansion) +{ + if(project_ != project) { mode_=0; active_velocity_=nullptr; } + project_ = project; + if (reset) { + if(!preserve_expansion) expanded_tracks_.fill(UINT64_MAX); + waves_.clear(); + selection_.clear(); + track_ = clip_ = UINT64_MAX; + cursor_ = 0; + mode_ = 0; + } + refresh_waveforms(); + extent(); + redraw(); +} + +int SequencerWidget::header_left() const +{ + auto *scroll=dynamic_cast(parent());return scroll ? std::max(x(),scroll->x()) : x(); +} + +void SequencerWidget::header_width(int width) +{ + width=std::clamp(width,180,360);if(width==header_width_)return; + auto *scroll=dynamic_cast(parent());int sx=scroll ? scroll->xposition() : 0,sy=scroll ? scroll->yposition() : 0; + header_width_=width;extent();if(scroll)scroll->scroll_to(sx,sy);redraw(); +} + +uint64_t SequencerWidget::tick(int px) const +{ + int64_t raw = static_cast(std::max(0,px-x()-header_width_))*960/scale_; + return std::min(XTC_PROJECT_MAX_TICK,static_cast(xtc_ui::snap_position(raw,grid_))); +} + +bool SequencerWidget::track_expanded(uint64_t track) const +{ + return track != UINT64_MAX && std::find(expanded_tracks_.begin(),expanded_tracks_.end(),track) != expanded_tracks_.end(); +} + +bool SequencerWidget::expand_track(uint64_t track, bool expanded) +{ + size_t tracks=0, clips=0; + xtc_project_get_counts(project_,&tracks,&clips); + bool found=false; + for(size_t i=0;i=top && py(i); + top=bottom; + } + return -1; +} + +void SequencerWidget::extent() +{ + size_t tracks, clips; + xtc_project_get_counts(project_, &tracks, &clips); + for(auto &id:expanded_tracks_) { + if(id==UINT64_MAX) continue; + bool found=false; + for(size_t i=0;i(end * static_cast(scale_) / 960U + static_cast(header_width_+20)), + std::max(400, track_top(tracks)-y()+72)); + sync_views(); + refresh_instruments(); + if (parent()) + parent()->redraw(); +} + +void SequencerWidget::changed(xtc_result result) +{ + if (result != XTC_OK) + ui_alert("%s", xtc_result_string(result)); + else { + refresh_waveforms(); + extent(); + do_callback(); + } + redraw(); +} + +void SequencerWidget::zoom(int direction) +{ + scale_ = std::clamp(direction > 0 ? scale_ * 2 : scale_ / 2, 24, 384); + extent(); + redraw(); +} + +xtc_result SequencerWidget::set_zoom(int scale) +{ + if(scale!=24 && scale!=48 && scale!=96 && scale!=192 && scale!=384) return XTC_ERR_INVALID_ARGUMENT; + scale_=scale;extent();redraw();return XTC_OK; +} + +void SequencerWidget::draw() +{ + // Reconcile on project/layout changes and input, never during JUCE painting. + ui_push_clip(x(),y(),w(),h()); + int cx, cy, cw, ch; + ui_clip_box(x(),y(),w(),h(),cx,cy,cw,ch); + ui_color(xtc_ui::background()); ui_rectf(cx,cy,cw,ch); + size_t tracks = 0, clips = 0; + xtc_project_get_counts(project_,&tracks,&clips); + for (size_t i = 0; i < tracks; ++i) { + int py = track_top(i); + if (py+track_height(i) < cy || py > cy+ch) continue; + ui_color(i%2 ? ui_rgb_color(30,34,40) : ui_rgb_color(33,37,43)); + ui_rectf(x()+header_width_,py,w()-header_width_,track_height(i)-1); + } + ui_font(UI_HELVETICA,11); + uint64_t visual = grid_ == 1 ? 960 : grid_; + while (visual*static_cast(scale_)/960 < 6) visual *= 2; + int spacing = static_cast(visual*static_cast(scale_)/960); + int first = std::max(0,(cx-x()-header_width_)/spacing); + int last = (cx+cw-x()-header_width_)/spacing+1; + for (int step = first; step <= last; ++step) { + uint64_t tick = static_cast(step)*visual; + int px = x()+header_width_+static_cast(tick*static_cast(scale_)/960); + ui_color(tick%3840 == 0 ? ui_rgb_color(76,84,96) : tick%960 == 0 ? ui_rgb_color(56,63,73) : ui_rgb_color(42,48,56)); + ui_line(px,y()+28,px,y()+h()); + } + if (!tracks) { + ui_font(UI_HELVETICA_BOLD,18); ui_color(xtc_ui::text()); + ui_draw("Your next track starts here",x()+220,y()+105); + ui_font(UI_HELVETICA,13); ui_color(xtc_ui::muted()); + ui_draw("Create a MIDI track or import audio from the panel on the left.",x()+220,y()+135); + ui_draw("Arrange clips here. Open the piano roll to draw and edit MIDI notes.",x()+220,y()+161); + } + ui_pop_clip(); +} +void SequencerWidget::draw_overlay() +{ + int cx,cy,cw,ch; + ui_clip_box(x(),y(),w(),h(),cx,cy,cw,ch); + ui_push_clip(x(),y(),w(),h()); + size_t markers=0;xtc_project_marker_count(project_,&markers); + for(size_t i=0;i(marker.tick*static_cast(scale_)/960U); + if(pxcx+cw)continue; + ui_color(xtc_ui::accent());ui_line(px,y()+24,px,y()+h()); + } + int playhead = x()+header_width_+static_cast(cursor_*static_cast(scale_)/960U); + if (playhead >= std::max(cx,header_left()+header_width_) && playhead <= cx+cw) { + ui_color(xtc_ui::accent()); ui_line(playhead,y(),playhead,y()+h()); + } + ui_color(xtc_ui::muted()); ui_rectf(header_left()+header_width_-3,cy,3,ch); + ui_pop_clip(); +} + +int SequencerWidget::handle(int event) +{ + if((mode_==6 || mode_==7) && active_velocity_ && + (event==UI_DRAG || event==UI_RELEASE || (event==UI_KEYDOWN && Ui::event_key()==UI_Escape))) + return active_velocity_->handle(event); + if(mode_==4 && (event==UI_DRAG || event==UI_RELEASE)) return ruler_->handle(event); + if(event==UI_PUSH && Ui::event_button()==UI_LEFT_MOUSE && Ui::event_x()>=header_left()+header_width_-5 && Ui::event_x()instrument_choice(); + if(((event==UI_DRAG || event==UI_RELEASE) && Ui::pushed()==choice) || + (event==UI_KEYDOWN && Ui::focus()==choice)) return choice->handle(event); + } + if(event==UI_PUSH) { + sync_views(); + if(Ui::event_y()handle(event); + mode_=0; + UiGroup::handle(event); + return 1; + } + if (event == UI_DRAG) + return mode_ != 0; + if (event == UI_RELEASE && mode_) { + int mode = mode_; + mode_ = 0; + int64_t delta = static_cast(Ui::event_x() - drag_x_) * 960 / scale_; + int64_t start = static_cast(original_.start_tick); + int64_t length = static_cast(original_.length_tick); + int64_t offset = static_cast(original_.offset_tick); + uint64_t target = original_.track_id; + if (mode == 1) { + start = xtc_ui::snap_position(start+delta,grid_); + int row = track_row(Ui::event_y()); + xtc_track_info t{}; + if (row >= 0 && xtc_project_get_track(project_, static_cast(row), &t) == XTC_OK) + target = t.id; + } else if (mode == 2) { + delta = xtc_ui::snap_position(start+delta,grid_)-start; + start += delta; + offset += delta; + length -= delta; + } else + length = xtc_ui::snap_position(start+length+delta,grid_)-start; + if (start < 0 || offset < 0 || length <= 0) + return 1; + if (before_edit) { + xtc_result r = before_edit(context_); + if (r != XTC_OK) { + changed(r); + return 1; + } + } + changed(xtc_project_edit_clip(project_, clip_, target, static_cast(start), + static_cast(length), static_cast(offset))); + return 1; + } + // A child may forward a release after its gesture was cancelled. Do not + // send that release back through UiGroup to the same captured child. + if(event==UI_RELEASE) return 0; + if (event == UI_KEYDOWN && !Ui::event_state(UI_CTRL | UI_ALT)) { + if (Ui::event_key() == 's') { split_selection(); return 1; } + if (Ui::event_key() == 'a') { audio_properties_dialog(); return 1; } + } + if(event==UI_KEYDOWN && !Ui::event_state(UI_CTRL|UI_ALT|UI_SHIFT) && (Ui::event_key()==UI_Left || Ui::event_key()==UI_Right)) {nudge_selection(Ui::event_key()==UI_Left ? -1 : 1);return 1;} + if(event==UI_KEYDOWN && Ui::event_state(UI_CTRL) && Ui::event_key()=='c'){copy_selection();return 1;} + if(event==UI_KEYDOWN && Ui::event_state(UI_CTRL) && Ui::event_key()=='v'){paste_selection();return 1;} + if (event == UI_KEYDOWN && Ui::event_state(UI_CTRL) && Ui::event_key() == 'a') {select_all_clips();return 1;} + if (event == UI_KEYDOWN && Ui::event_state(UI_CTRL) && Ui::event_key() == 'd') { duplicate_selection(); return 1; } + if (event == UI_KEYDOWN && Ui::event_key() == UI_F+2) { + const char *name = ui_input("Name (1..63 UTF-8 bytes)",""); if (name) rename_selection(name); return 1; + } + if (event == UI_KEYDOWN && Ui::event_state(UI_ALT) && + (Ui::event_key() == UI_Up || Ui::event_key() == UI_Down)) { move_track(Ui::event_key() == UI_Up ? -1 : 1); return 1; } + if (event == UI_KEYDOWN && Ui::event_key() == UI_Escape) { + mode_ = 0; + return 1; + } + if (event == UI_KEYDOWN && Ui::event_key() == UI_Delete) { + delete_selection(); + return 1; + } + if (event == UI_FOCUS || event == UI_UNFOCUS) + return 1; + return UiGroup::handle(event); +} + +void SequencerWidget::context_menu() +{ + UiMenu_Item items[6]{}; + items[0].text = "Duplicate clip (Ctrl+D)"; items[1].text = "Rename (F2)"; items[2].text = "Delete (undoable)"; + items[3].text = "Split clip at cursor (S)"; items[4].text = "Audio gain and fades (A)"; + const UiMenu_Item *picked = items->popup(Ui::event_x(),Ui::event_y()); + if (picked == items) duplicate_selection(); + else if (picked == items+1) { const char *name = ui_input("Name (1..63 UTF-8 bytes)",""); if (name) rename_selection(name); } + else if (picked == items+2) delete_selection(); + else if (picked == items+3) split_selection(); + else if (picked == items+4) audio_properties_dialog(); +} + +SequencerTrackWidget *SequencerWidget::track_widget(uint64_t track) const +{ + for(auto *view:track_widgets_) if(view->track_id()==track) return view; + return nullptr; +} + +void SequencerWidget::sync_views() try +{ + size_t tracks=0,clips=0; xtc_project_get_counts(project_,&tracks,&clips); + ruler_->resize(x(),y(),w(),28); + for(auto it=track_widgets_.begin();it!=track_widgets_.end();) { + bool found=false; + for(size_t i=0;itrack_id()) { found=true; break; } + } + if(!found) { + auto *removed=*it; it=track_widgets_.erase(it); + if(active_velocity_==removed->velocity_lane()) { active_velocity_=nullptr; mode_=0; } + removed->hide(); remove(removed); Ui::delete_widget(removed); + } else ++it; + } + SequencerGroupScope scope(this); + for(size_t i=0;isync(i,t); view->show(); + } +} catch(const std::bad_alloc &) { + // Rendering allocation failure must not invalidate an accepted C model edit. + active_velocity_=nullptr; mode_=0; + for(auto *view:track_widgets_) view->hide(); +} + +void SequencerWidget::resize(int left,int top,int width,int height) +{ + UiGroup::resize(left,top,width,height); + if(ruler_)sync_views(); // Includes pinned headers after native scrolling/reparenting. +} diff --git a/src/gui/JUCE/sequencer_widget.hpp b/src/gui/JUCE/sequencer_widget.hpp new file mode 100644 index 0000000..ad5cd4a --- /dev/null +++ b/src/gui/JUCE/sequencer_widget.hpp @@ -0,0 +1,260 @@ +#ifndef XTC_SEQUENCER_WIDGET_HPP +#define XTC_SEQUENCER_WIDGET_HPP +#include "ui.hpp" +#include "xtc/xtc_plugin.h" +#include "xtc/xtc_editing.h" +#include "xtc/xtc_mixer.h" +#include +#include "xtc/xtc_audio.h" +#include +#include +#include + +class SequencerTrackWidget; +class SequencerTrackHeader; +class SequencerClipWidget; +class SequencerVelocityWidget; +class SequencerRulerWidget; + +class SequencerWidget : public UiGroup { + public: + /** + * @brief Prepares a model edit using the widget's opaque callback context. + * + * @return XTC_OK to allow the edit, or an error to cancel it. + */ + using LoopSelect = xtc_result (*)(void *,uint64_t,uint64_t); + LoopSelect loop_select = nullptr; + uint64_t new_clip_ticks = 3840; + int header_width() const {return header_width_;} + void header_width(int width); + int header_left() const; + int zoom_level() const { return scale_; } + xtc_result set_zoom(int scale); + void loop_region(uint64_t start,uint64_t end,bool enabled) { loop_begin_=start; loop_end_=end; loop_enabled_=enabled; redraw(); } + using OpenClip = void (*)(void *); + using InstrumentSelect = void (*)(void *, uint64_t, const xtc_plugin_spec *, bool); + OpenClip open_clip = nullptr; + InstrumentSelect instrument_select = nullptr; + UiChoice *instrument_choice(uint64_t track) const; + SequencerTrackWidget *track_widget(uint64_t track) const; + void refresh_instruments(); + bool track_expanded(uint64_t track) const; + bool expand_track(uint64_t track, bool expanded); + int track_top(size_t row) const; + bool select(uint64_t track, uint64_t clip = UINT64_MAX); + xtc_result merge_midi(); + xtc_result duplicate_track(const char *name); + void duplicate_track_dialog(); + xtc_result ripple_time(uint64_t length,bool remove); + void ripple_dialog(bool remove); + xtc_result marker(const char *name); /* NULL removes at cursor. */ + void marker_dialog(); + bool toggle_clip(uint64_t clip); + void select_all_clips(); + bool clip_selected(uint64_t clip) const; + std::vector selected_clips() const; + xtc_result transform_selection(xtc_clip_operation operation,int64_t amount); + xtc_result transform_midi(const xtc_midi_transform &edit); + void swing_dialog(); + void gate_dialog(); + void scale_dialog(); + void velocity_dialog(); + void humanize_dialog(); + void strum_dialog(); + xtc_result strum_chords(uint64_t spacing,bool descending); + xtc_result make_chord(const std::vector &intervals); + void chord_dialog(); + xtc_result arpeggiate(uint64_t step,unsigned int gate,bool descending); + void arpeggio_dialog(); + xtc_result normalize_audio(float target); + void normalize_dialog(); + void fades_dialog(); + xtc_result crossfade_selection(); + xtc_result repeat_selection(unsigned int copies); + void repeat_dialog(); + xtc_result copy_selection(); + xtc_result paste_selection(); + xtc_result nudge_selection(int direction); + xtc_result duplicate_selection(); + xtc_result split_selection(); + xtc_result audio_properties(float gain, uint64_t fade_in, uint64_t fade_out); + void audio_properties_dialog(); + void refresh_waveforms(); + size_t waveform_count() const { return waves_.size(); } + + xtc_result rename_selection(const char *name); + xtc_result move_track(int direction); + xtc_result grid(uint64_t ticks); + uint64_t grid() const { return grid_; } + using BeforeEdit = xtc_result (*)(void *); + BeforeEdit before_edit = nullptr; + + /** + * @brief Returns the selected track identifier, or UINT64_MAX when none is selected. + * @return Stable track identifier, or UINT64_MAX if no track is selected. + */ + uint64_t selected_track() const + { + return track_; + } + + /** + * @brief Returns the selected clip identifier, or UINT64_MAX when none is selected. + * @return Stable clip identifier, or UINT64_MAX if no clip is selected. + */ + uint64_t selected_clip() const + { + return clip_; + } + + /** + * @brief Requests a transport seek using an opaque context and musical tick. + * + * @return XTC_OK on success, or a transport error. + */ + using Seek = xtc_result (*)(void *, uint64_t); + /** + * @brief Constructs the timeline widget with a borrowed project and seek callback. + * + * @param x Left coordinate in pixels. + * @param y Top coordinate in pixels. + * @param project Borrowed project; must outlive the widget's use of it. + * @param seek Callback used to request a transport seek. + * @param context Opaque callback context. + */ + SequencerWidget(int x, int y, xtc_project *project, Seek seek, void *context); + /** + * @brief Replaces the borrowed project and refreshes the widget's displayed state. + * + * @param project Borrowed project; must outlive the widget's use of it. + * @param reset Whether to clear selection, cursor, and drag state. + * @param preserve_expansion Keep disclosure state when installing history snapshots. + */ + void set_project(xtc_project *project, bool reset = true, bool preserve_expansion = false); + + /** + * @brief Returns the timeline cursor position in musical ticks. + * @return Cursor position in musical ticks. + */ + uint64_t position() const + { + return cursor_; + } + + /** + * @brief Moves the timeline cursor to the supplied musical tick and requests a redraw. + * + * @param tick Musical position in ticks. + */ + void position(uint64_t tick) + { + cursor_ = tick; + redraw(); + } + + /** + * @brief Creates and selects a track through the arrangement edit hook. + * @return XTC_OK on success, or an error code describing the failure. + */ + xtc_result add_track(); + /** + * @brief Creates and selects a one-bar silent region at the timeline cursor. + * @return XTC_OK on success, or an error code describing the failure. + */ + xtc_result add_region(); + /** + * @brief Creates and selects a one-bar MIDI clip at the timeline cursor. + * @return XTC_OK on success, or an error code describing the failure. + */ + xtc_result add_midi_clip(); + /** + * @brief Removes the selected clip, or the selected track when no clip is selected. + * @return XTC_OK on success, or an error code describing the failure. + */ + xtc_result delete_selection(); + /** + * @brief Adjusts timeline zoom within its supported bounds and updates the scroll extent. + * + * @param direction Zoom direction; positive zooms in, nonpositive zooms out. + */ + void zoom(int direction); + + /** + * @brief Returns the borrowed project currently displayed by the widget. + * @return Borrowed project pointer. + */ + xtc_project *project() const + { + return project_; + } + + /** + * @brief Handles JUCE input events and delegates unhandled events to the base widget. + * + * @param event JUCE event code. + * @return Nonzero if the event was handled, zero otherwise. + */ + int handle(int event) override; + + protected: + /** + * @brief Draws the widget's current model, selection, and interaction feedback. + */ + void resize(int,int,int,int)override; + void draw() override; + void draw_overlay() override; + + private: + static constexpr int velocity_height_ = 88; + std::array expanded_tracks_{}; + int track_height(size_t row) const; + int track_row(int py) const; + friend class SequencerTrackWidget; + friend class SequencerTrackHeader; + friend class SequencerClipWidget; + friend class SequencerVelocityWidget; + friend class SequencerRulerWidget; + std::vector track_widgets_; // JUCE owns children. + SequencerRulerWidget *ruler_ = nullptr; + SequencerVelocityWidget *active_velocity_ = nullptr; // Captured gesture, not ownership. + void sync_views(); + void context_menu(); + std::vector catalog_; + static void instrument_callback(UiWidget *, void *); + struct Wave { + std::string path; + std::unique_ptr data{nullptr, xtc_waveform_destroy}; + }; + std::vector waves_; + std::vector selection_; + std::unique_ptr clipboard_{nullptr,xtc_clipboard_destroy}; + void draw_waveform(const xtc_clip_info &clip, int left, int top); + xtc_project *project_; + Seek seek_; + void *context_; + uint64_t track_ = UINT64_MAX, clip_ = UINT64_MAX, cursor_ = 0, grid_ = 240; + uint64_t loop_begin_=0,loop_end_=3840,loop_anchor_=0,loop_preview_=0; + bool loop_enabled_=false; + int scale_ = 48, drag_x_ = 0, mode_ = 0; + int header_width_=180,header_drag_x_=0,header_drag_original_=180; + xtc_clip_info original_{}; + /** + * @brief Converts a screen x coordinate to a bounded timeline tick snapped to the selected grid. + * + * @param px Screen x coordinate in pixels. + * @return Snapped tick within the supported project range. + */ + uint64_t tick(int px) const; + /** + * @brief Reports an edit result, refreshes the widget, and notifies listeners on success. + * + * @param result Result of the attempted operation. + */ + void changed(xtc_result result); + /** + * @brief Resizes the timeline's scrollable area to fit its tracks and clip ends. + */ + void extent(); +}; +#endif diff --git a/src/gui/JUCE/snap.hpp b/src/gui/JUCE/snap.hpp new file mode 100644 index 0000000..4743e01 --- /dev/null +++ b/src/gui/JUCE/snap.hpp @@ -0,0 +1,19 @@ +#ifndef XTC_JUCE_GUI_SNAP_HPP +#define XTC_JUCE_GUI_SNAP_HPP +#include +#include +namespace xtc_ui { +constexpr uint64_t snap_ticks[] = {1,120,160,240,320,480,960,1920,3840}; +constexpr const char *snap_labels[] = {"Off","1/32","1/16 triplet","1/16","1/8 triplet","1/8","1/4","1/2","1 bar"}; +constexpr size_t snap_count = sizeof(snap_ticks)/sizeof(snap_ticks[0]); +inline int snap_index(uint64_t ticks) { + for (size_t i = 0; i < snap_count; ++i) if (snap_ticks[i] == ticks) return static_cast(i); + return -1; +} +inline int64_t snap_position(int64_t ticks, uint64_t grid) { + if (ticks <= 0) return 0; + int64_t step = static_cast(grid); + return (ticks+step/2)/step*step; +} +} +#endif diff --git a/src/gui/JUCE/theme.hpp b/src/gui/JUCE/theme.hpp new file mode 100644 index 0000000..0c61cd3 --- /dev/null +++ b/src/gui/JUCE/theme.hpp @@ -0,0 +1,65 @@ +#ifndef XTC_JUCE_GUI_THEME_HPP +#define XTC_JUCE_GUI_THEME_HPP + +#include +#include "ui.hpp" + +/* Shared flat palette. No gradients, shadows, textures or external assets. */ +namespace xtc_ui { +constexpr UiBoxtype flat = static_cast(UI_FREE_BOXTYPE); +constexpr UiBoxtype pressed = static_cast(UI_FREE_BOXTYPE+1); +inline UiColor background() { return ui_rgb_color(25,28,33); } +inline UiColor panel() { return ui_rgb_color(34,38,44); } +inline UiColor border() { return ui_rgb_color(58,64,73); } +inline UiColor text() { return ui_rgb_color(231,235,240); } +inline UiColor muted() { return ui_rgb_color(163,174,188); } +inline UiColor accent() { return ui_rgb_color(92,211,184); } +inline UiColor 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 ui_rgb_color(c[0],c[1],c[2]); +} +inline void flat_box(int x, int y, int w, int h, UiColor color) { + ui_color(color); ui_rectf(x,y,w,h); + ui_color(border()); ui_rect(x,y,w,h); +} +inline void pressed_box(int x, int y, int w, int h, UiColor color) { + flat_box(x,y,w,h,ui_darker(color)); +} +inline void configure_boxes() { + Ui::set_boxtype(flat,flat_box,1,1,2,2); + Ui::set_boxtype(pressed,pressed_box,1,1,2,2); + Ui::set_boxtype(UI_UP_BOX,flat_box,1,1,2,2); + Ui::set_boxtype(UI_DOWN_BOX,pressed_box,1,1,2,2); + Ui::set_boxtype(UI_THIN_UP_BOX,flat_box,1,1,2,2); + Ui::set_boxtype(UI_THIN_DOWN_BOX,pressed_box,1,1,2,2); + Ui::set_boxtype(UI_ENGRAVED_BOX,flat_box,1,1,2,2); + Ui::set_boxtype(UI_BORDER_BOX,flat_box,1,1,2,2); +} +inline void initialize() { + static bool initialized = false; + if (initialized) return; + initialized = true; + Ui::background(34,38,44); Ui::background2(25,28,33); Ui::foreground(231,235,240); + Ui::set_color(UI_SELECTION_COLOR,53,116,111); + configure_boxes(); + Ui::scrollbar_size(12); +} +inline void style(UiWidget *widget) { + widget->labelfont(UI_HELVETICA); widget->labelsize(12); widget->labelcolor(text()); + widget->color(panel()); widget->selection_color(ui_rgb_color(53,116,111)); + if (widget->box() != UI_NO_BOX && widget->box() != UI_FLAT_BOX) widget->box(flat); + if (auto *button = dynamic_cast(widget)) { + button->box(flat); button->down_box(pressed); + button->color(ui_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(UI_FLAT_BOX); scroll->hscrollbar.slider(UI_FLAT_BOX); + } + } +} +} +#endif diff --git a/src/gui/JUCE/track_inserts.hpp b/src/gui/JUCE/track_inserts.hpp new file mode 100644 index 0000000..54075dd --- /dev/null +++ b/src/gui/JUCE/track_inserts.hpp @@ -0,0 +1,206 @@ +#ifndef XTC_TRACK_INSERTS_HPP +#define XTC_TRACK_INSERTS_HPP +#include "main_window.hpp" +#include "graph_widget.hpp" +#include "plugin_library.hpp" +#include "plugin_dialog.hpp" +#include "sequencer_widget.hpp" +#include "insert_meter.hpp" +#include "ui.hpp" +#include +#include +#include +class TrackInsertsPanel : public UiGroup { + MainWindow *owner_; + UiChoice *tracks_; + UiMenu_Button *actions_; + InsertMeter *meter_; + UiHold_Browser *slots_; + UiBox *message_; + UiValue_Input *wet_,*gain_; + GraphWidget *graph_; + UiScroll *scroll_; + xtc_graph *model_=nullptr; + uint64_t track_=UINT64_MAX,meter_track_=UINT64_MAX; + xtc_track_chain cached_{}; + xtc_track_chain clipboard_{}; + bool clipboard_ready_=false; + struct Comparison {xtc_track_chain chain{};uint64_t track=UINT64_MAX;bool ready=false;} comparisons_[2]; + std::vector ids_; + std::vector names_; + void result(xtc_result r) { if(r!=XTC_OK)owner_->report_error("Track inserts",r);refresh(true); } + public: + TrackInsertsPanel(int x,int y,int w,int h,MainWindow *owner):UiGroup(x,y,w,h,"Track inserts"),owner_(owner) { + tracks_=new UiChoice(x+64,y+12,240,28,"Track");tracks_->callback([](UiWidget *,void *c){auto *p=static_cast(c);int i=p->tracks_->value();if(i>=0 && static_cast(i)ids_.size()){p->track_=p->ids_[static_cast(i)];p->refresh(true);}},this); + auto *add=new UiMenu_Button(x+320,y+12,150,28,"Add effect"); + add->add("Internal Delay",0,[](UiWidget *,void *c){static_cast(c)->add(XTC_BUILTIN_PATH,2);},this); + add->add("Internal Reverb",0,[](UiWidget *,void *c){static_cast(c)->add(XTC_BUILTIN_PATH,3);},this); + add->add("Plugin library...",0,[](UiWidget *,void *c){auto *p=static_cast(c);std::string file;if(!xtc_choose_plugin_file(p->owner_->settings(),file))return;unsigned int index=0;if(xtc_choose_plugin(file.c_str(),p->owner_->sample_rate(),index))p->add(file.c_str(),index);},this); + for(unsigned int preset=0;preset<3;++preset) { + std::string label=std::string("Templates (replace)/")+xtc_track_chain_template_name(preset); + add->add(label.c_str(),0,[](UiWidget *w,void *c){auto *p=static_cast(c);auto *menu=static_cast(w);unsigned int index=0;for(;index<3;++index)if(!strcmp(menu->mvalue()->label(),xtc_track_chain_template_name(index)))break;p->result(p->apply_template(index));},this); + } + struct Action {const char *name;int command;}; + int left=x+486; + for(const auto &action:{Action{"Up",-1},Action{"Down",1},Action{"Bypass",2},Action{"Remove",3}}) { + auto *button=new UiButton(left,y+12,82,28,action.name);left+=90; + button->callback([](UiWidget *w,void *c){auto *p=static_cast(c);int command=!strcmp(w->label(),"Up") ? -1 : !strcmp(w->label(),"Down") ? 1 : !strcmp(w->label(),"Bypass") ? 2 : 3;p->result(p->edit_slot(command));},this); + } + auto *inspect=new UiButton(x+850,y+12,120,28,"Inspect / edit"); + inspect->callback([](UiWidget *,void *c){auto *p=static_cast(c);p->result(p->inspect_slot());},this); + auto *presets=new UiMenu_Button(x+978,y+12,68,28,"Preset"); + presets->add("Save chain...",0,[](UiWidget *,void *c){static_cast(c)->preset(true);},this); + presets->add("Load chain...",0,[](UiWidget *,void *c){static_cast(c)->preset(false);},this); + wet_=new UiValue_Input(x+110,y+52,100,26,"Insert wet");wet_->bounds(0,1);wet_->step(.01);wet_->when(UI_WHEN_RELEASE); + gain_=new UiValue_Input(x+360,y+52,100,26,"Chain gain");gain_->bounds(0,4);gain_->step(.01);gain_->when(UI_WHEN_RELEASE); + for(auto *input:{wet_,gain_})input->callback([](UiWidget *,void *c){auto *p=static_cast(c);p->result(p->set_mix(p->wet_->value(),p->gain_->value()));},this); + actions_=new UiMenu_Button(x+486,y+52,150,26,"Rack actions"); + actions_->add("Enable all effects",0,[](UiWidget *,void *c){auto *p=static_cast(c);p->result(p->bulk(XTC_CHAIN_ENABLE_ALL));},this); + actions_->add("Bypass all effects",0,[](UiWidget *,void *c){auto *p=static_cast(c);p->result(p->bulk(XTC_CHAIN_BYPASS_ALL));},this); + actions_->add("Clear chain",0,[](UiWidget *,void *c){auto *p=static_cast(c);p->result(p->bulk(XTC_CHAIN_CLEAR));},this); + actions_->add("Compare/Capture A",0,[](UiWidget *,void *c){auto *p=static_cast(c);p->result(p->comparison(0,true));},this); + actions_->add("Compare/Recall A",0,[](UiWidget *,void *c){auto *p=static_cast(c);p->result(p->comparison(0,false));},this,UI_MENU_INACTIVE); + actions_->add("Compare/Capture B",0,[](UiWidget *,void *c){auto *p=static_cast(c);p->result(p->comparison(1,true));},this); + actions_->add("Compare/Recall B",0,[](UiWidget *,void *c){auto *p=static_cast(c);p->result(p->comparison(1,false));},this,UI_MENU_INACTIVE); + actions_->add("Copy chain",0,[](UiWidget *,void *c){auto *p=static_cast(c);p->result(p->copy_chain());},this); + actions_->add("Paste chain (replace)",0,[](UiWidget *,void *c){auto *p=static_cast(c);p->result(p->paste_chain());},this,UI_MENU_INACTIVE); + actions_->add("Duplicate effect",0,[](UiWidget *,void *c){auto *p=static_cast(c);p->result(p->duplicate_slot());},this); + meter_=new InsertMeter(x+650,y+52,w-666,26); + message_=new UiBox(x+16,y+84,w-32,30,"Track input > inserts > output gain > track fader > bus/master");message_->align(UI_ALIGN_LEFT|UI_ALIGN_INSIDE); + slots_=new UiHold_Browser(x+16,y+126,230,h-142);slots_->format_char(0);slots_->callback([](UiWidget *,void *c){static_cast(c)->update_mix();},this); + scroll_=new UiScroll(x+260,y+126,w-276,h-142); + graph_=new GraphWidget(x+260,y+126,2000,4000,nullptr,[](void *,const xtc_graph_edit &,size_t *){return XTC_ERR_UNSUPPORTED;},nullptr,message_); + scroll_->end();resizable(scroll_);end(); + } + ~TrackInsertsPanel() override { graph_->set_graph(nullptr);xtc_graph_destroy(model_); } + uint64_t selected_track() const {return track_;} + size_t selected_slot() const {return slots_->value()>0 ? static_cast(slots_->value()-1) : SIZE_MAX;} + void select_track(uint64_t id) {track_=id;refresh(true);} + const xtc_graph *graph() const {return model_;} + void add(const char *file,unsigned int index) { result(owner_->add_track_effect(track_,file,index)); } + xtc_result bulk(xtc_chain_action action) { + xtc_track_chain chain{};auto r=xtc_project_get_chain(owner_->project_.get(),track_,&chain); + if(r==XTC_OK && !chain.count)return XTC_ERR_NOT_FOUND; + if(r==XTC_OK)r=xtc_track_chain_bulk(&chain,action,&chain); + return r==XTC_OK ? owner_->set_track_chain(track_,chain) : r; + } + xtc_result apply_template(unsigned int index) { + xtc_track_chain chain{};auto r=xtc_track_chain_template(index,xtc_project_tempo(owner_->project_.get()),&chain); + if(r==XTC_OK)r=owner_->set_track_chain(track_,chain); + return r; + } + InsertMeter *meter() const {return meter_;} + void meters(const xtc_meter_snapshot &snapshot,bool active,double seconds=.02) {meter_->feed(snapshot,track_,active,seconds);} + void clear_session() {meter_->feed(xtc_meter_snapshot{},UINT64_MAX,false,.02);clipboard_={};clipboard_ready_=false;for(auto &slot:comparisons_)slot=Comparison{};update_actions();} + bool clipboard_ready() const {return clipboard_ready_;} + bool comparison_ready(unsigned int slot) const {return slot<2 && comparisons_[slot].ready && comparisons_[slot].track==track_;} + xtc_result comparison(unsigned int slot,bool capture) { + if(slot>=2)return XTC_ERR_INVALID_ARGUMENT; + if(capture) { + xtc_track_chain chain{};auto r=xtc_project_get_chain(owner_->project_.get(),track_,&chain); + if(r==XTC_OK){comparisons_[slot].chain=chain;comparisons_[slot].track=track_;comparisons_[slot].ready=true;update_actions();} + return r; + } + if(!comparison_ready(slot))return XTC_ERR_NOT_FOUND; + return owner_->set_track_chain(track_,comparisons_[slot].chain); + } + void update_actions() { + for(unsigned int slot=0;slot<2;++slot){int item=actions_->find_index(slot ? "Compare/Recall B" : "Compare/Recall A");if(item>=0)actions_->mode(item,comparison_ready(slot) ? 0 : UI_MENU_INACTIVE);} + int index=actions_->find_index("Paste chain (replace)"); + if(index>=0)actions_->mode(index,clipboard_ready_ ? 0 : UI_MENU_INACTIVE); + } + xtc_result copy_chain() { + xtc_track_chain chain{};auto r=xtc_project_get_chain(owner_->project_.get(),track_,&chain); + if(r==XTC_OK){clipboard_=chain;clipboard_ready_=true;update_actions();}return r; + } + xtc_result paste_chain() { + if(!clipboard_ready_)return XTC_ERR_NOT_FOUND; + return owner_->set_track_chain(track_,clipboard_); + } + xtc_result duplicate_slot() { + xtc_track_chain chain{};auto r=xtc_project_get_chain(owner_->project_.get(),track_,&chain);size_t index=selected_slot(); + if(r==XTC_OK)r=xtc_track_chain_duplicate(&chain,index,&chain); + if(r==XTC_OK)r=owner_->set_track_chain(track_,chain); + if(r==XTC_OK){slots_->select(static_cast(index+2));update_mix();} + return r; + } + void update_mix() { + xtc_track_chain chain{};if(xtc_project_get_chain(owner_->project_.get(),track_,&chain)!=XTC_OK){wet_->deactivate();gain_->deactivate();return;} + gain_->activate();gain_->value(chain.output_gain);size_t index=selected_slot(); + if(indexactivate();wet_->value(chain.slots[index].wet);}else wet_->deactivate(); + } + xtc_result set_mix(double wet,double gain) { + if(!std::isfinite(wet) || wet<0 || wet>1 || !std::isfinite(gain) || gain<0 || gain>4)return XTC_ERR_INVALID_ARGUMENT; + xtc_track_chain chain{};auto r=xtc_project_get_chain(owner_->project_.get(),track_,&chain);if(r!=XTC_OK)return r; + size_t index=selected_slot();if(index(wet); + chain.output_gain=static_cast(gain);return owner_->set_track_chain(track_,chain); + } + xtc_result save_preset(const char *file) { + xtc_track_chain chain{};auto r=xtc_project_get_chain(owner_->project_.get(),track_,&chain); + return r==XTC_OK ? xtc_track_chain_preset_save(&chain,file) : r; + } + xtc_result load_preset(const char *file) { + xtc_track_chain chain{};auto r=xtc_track_chain_preset_load(file,&chain); + return r==XTC_OK ? owner_->set_track_chain(track_,chain) : r; + } + void preset(bool save) { + UiNative_File_Chooser chooser;chooser.title(save ? "Save effect chain" : "Load effect chain"); + chooser.type(save ? UiNative_File_Chooser::BROWSE_SAVE_FILE : UiNative_File_Chooser::BROWSE_FILE); + chooser.filter("Effect chain project\t*.xtc");if(save)chooser.options(UiNative_File_Chooser::SAVEAS_CONFIRM); + if(chooser.show()!=0 || !chooser.filename())return; + result(save ? save_preset(chooser.filename()) : load_preset(chooser.filename())); + } + xtc_result inspect_slot() { + xtc_track_chain chain{};auto r=xtc_project_get_chain(owner_->project_.get(),track_,&chain);size_t index=selected_slot(); + if(r!=XTC_OK)return r; + if(index>=chain.count)return XTC_ERR_INVALID_STATE; + xtc_plugin_details details{};xtc_plugin_spec edited{}; + r=xtc_plugin_describe(&chain.slots[index].plugin,owner_->sample_rate(),&details);if(r!=XTC_OK)return r; + if(!xtc_edit_plugin(chain.slots[index].plugin,details,edited))return XTC_OK; + chain.slots[index].plugin=edited;return owner_->set_track_chain(track_,chain); + } + /* Commands: -1 up, 1 down, 2 toggle bypass, 3 remove. */ + xtc_result edit_slot(int command) { + xtc_track_chain chain{};auto r=xtc_project_get_chain(owner_->project_.get(),track_,&chain);size_t index=selected_slot(); + if(r!=XTC_OK)return r; + if(index>=chain.count)return XTC_ERR_INVALID_STATE; + size_t selected=index; + if(command==-1 || command==1) { + if((command==-1 && !index) || (command==1 && index+1>=chain.count))return XTC_ERR_INVALID_ARGUMENT; + selected=command==-1 ? index-1 : index+1;std::swap(chain.slots[index],chain.slots[selected]); + }else if(command==2)chain.slots[index].enabled=!chain.slots[index].enabled; + else if(command==3) {for(size_t i=index+1;iset_track_chain(track_,chain); + if(r==XTC_OK && chain.count)slots_->select(static_cast(std::min(selected,chain.count-1)+1)); + update_mix();return r; + } + void refresh(bool force=false) { + const xtc_project *project=owner_->project_.get();size_t count=0,clips=0;xtc_project_get_counts(project,&count,&clips); + std::vector ids;std::vector names; + for(size_t i=0;iclear();for(const auto &name:names) {tracks_->add("track");tracks_->replace(tracks_->size()-2,name.c_str());}} + if(std::find(ids_.begin(),ids_.end(),track_)==ids_.end())track_=ids_.empty() ? UINT64_MAX : ids_[0]; + for(size_t i=0;ivalue(static_cast(i)); + update_actions(); + if(track_!=meter_track_){meter_track_=track_;meters(xtc_meter_snapshot{},false);} + xtc_track_chain chain{};chain.output_gain=1; + if(track_!=UINT64_MAX)xtc_project_get_chain(project,track_,&chain); + if(!rebuild && !std::memcmp(&cached_,&chain,sizeof(chain)))return; + cached_=chain;int selected=slots_->value();slots_->clear(); + for(size_t i=0;iadd(label.c_str());} + if(chain.count)slots_->select(selected>0 && static_cast(selected)<=chain.count ? selected : 1); + xtc_graph *next=nullptr;auto r=xtc_track_chain_graph(&chain,&next); + if(r==XTC_OK){graph_->set_graph(next);xtc_graph_destroy(model_);model_=next;graph_->auto_layout(); + size_t nodes=0,edges=0,max_level=0,max_row=0;std::array rows{}; + xtc_graph_get_counts(model_,&nodes,&edges); + for(size_t i=0;irouting(node.id,route))max_level=std::max(max_level,route.level);} + graph_->size(std::max(scroll_->w()-20,static_cast(max_level+1)*174+40),4000); + for(size_t i=0;irouting(node.id,route) && route.levelmove_node(node.id,20+static_cast(route.level)*174,24+static_cast(row)*104);}} + graph_->size(graph_->w(),std::max(scroll_->h()-20,static_cast(max_row+1)*104+48));scroll_->scroll_to(0,0); + message_->copy_label("Track input > inserts > output gain > track fader > bus/master. Scroll to inspect long chains.");} + update_mix();redraw(); + } +}; +#endif diff --git a/src/gui/JUCE/ui.cpp b/src/gui/JUCE/ui.cpp new file mode 100644 index 0000000..27594b9 --- /dev/null +++ b/src/gui/JUCE/ui.cpp @@ -0,0 +1,1234 @@ +#include "ui.hpp" +#include "xtc/xtc_version.h" +#include +namespace +{ +thread_local juce::Graphics *graphics = nullptr; +thread_local std::unique_ptr font; +thread_local float stroke = 1; +std::vector windows; +struct Timeout : juce::Timer +{ + void (*function)(void *); + void *context; + bool cancelled = false, executing = false; + Timeout(double seconds, void (*f)(void *), void *c) : function(f), context(c) + { + startTimer(std::max(1, static_cast(seconds * 1000))); + } + void timerCallback() override; +}; +std::vector> timers; +void Timeout::timerCallback() +{ + stopTimer(); + if (cancelled) + return; + cancelled = true; + executing = true; + function(context); + executing = false; +} +juce::Colour colour(UiColor c) { return juce::Colour(0xff000000U | c); } +std::string formatted(const char *text, va_list args) +{ + char buffer[8192]; + std::vsnprintf(buffer, sizeof(buffer), text, args); + return buffer; +} +int key_code(const juce::KeyPress &key) +{ + const int k = key.getKeyCode(); + if (k == juce::KeyPress::escapeKey) + return UI_Escape; + if (k == juce::KeyPress::returnKey) + return UI_Enter; + if (k == juce::KeyPress::deleteKey) + return UI_Delete; + if (k == juce::KeyPress::backspaceKey) + return UI_BackSpace; + if (k == juce::KeyPress::leftKey) + return UI_Left; + if (k == juce::KeyPress::rightKey) + return UI_Right; + if (k == juce::KeyPress::upKey) + return UI_Up; + if (k == juce::KeyPress::downKey) + return UI_Down; + if (k == juce::KeyPress::homeKey) + return UI_Home; + if (k >= juce::KeyPress::F1Key && k <= juce::KeyPress::F16Key) + return UI_F + k - juce::KeyPress::F1Key + 1; + return juce::CharacterFunctions::toLowerCase(static_cast(k)); +} +int modifiers(juce::ModifierKeys m) +{ + return (m.isCtrlDown() ? UI_CTRL : 0) | (m.isShiftDown() ? UI_SHIFT : 0) | + (m.isAltDown() ? UI_ALT : 0) | (m.isCommandDown() && !m.isCtrlDown() ? UI_META : 0); +} +} // namespace +int Ui::ex = 0, Ui::ey = 0, Ui::ek = 0, Ui::es = 0, Ui::eb = 0, Ui::clicks = 0; +UiWidget *Ui::pressed = nullptr; +UiGroup *UiGroup::current_ = nullptr; +bool Ui::event_inside(const UiWidget *w) +{ + return ex >= w->x() && ey >= w->y() && ex < w->x() + w->w() && ey < w->y() + w->h(); +} +UiWidget *Ui::focus() +{ + auto *c = juce::Component::getCurrentlyFocusedComponent(); + while (c) + { + if (auto *w = dynamic_cast(c)) + return w; + c = c->getParentComponent(); + } + return nullptr; +} +void Ui::delete_widget(UiWidget *w) +{ + juce::Component::SafePointer safe(w); + juce::MessageManager::callAsync( + [safe] + { + if (safe) + delete safe.getComponent(); + }); +} +void Ui::add_timeout(double s, void (*f)(void *), void *c) +{ + timers.erase(std::remove_if(timers.begin(), timers.end(), + [](auto &t) { return t->cancelled && !t->executing; }), + timers.end()); + timers.push_back(std::make_unique(s, f, c)); +} +void Ui::remove_timeout(void (*f)(void *), void *c) +{ + for (auto &t : timers) + if (t->function == f && t->context == c) + { + t->stopTimer(); + t->cancelled = true; + } + timers.erase(std::remove_if(timers.begin(), timers.end(), + [](auto &t) { return t->cancelled && !t->executing; }), + timers.end()); +} +double Ui::wait(double s) +{ + juce::MessageManager::getInstance()->runDispatchLoopUntil( + std::max(1, static_cast(s * 1000))); + timers.erase(std::remove_if(timers.begin(), timers.end(), + [](auto &t) { return t->cancelled && !t->executing; }), + timers.end()); + return s; +} +int Ui::run() +{ + for (;;) + { + bool any = false; + for (auto *w : windows) + any |= w->shown(); + if (!any) + break; + wait(); + } + timers.clear(); + return 0; +} +void Ui::flush() +{ + for (auto *w : windows) + if (auto *p = w->getPeer()) + p->performAnyPendingRepaintsNow(); +} +void Ui::screen_work_area(int &x, int &y, int &w, int &h, int) +{ + auto *d = juce::Desktop::getInstance().getDisplays().getPrimaryDisplay(); + auto r = d ? d->userBounds.toNearestInt() : juce::Rectangle(0, 0, 1440, 960); + x = r.getX(); + y = r.getY(); + w = r.getWidth(); + h = r.getHeight(); +} +UiColor ui_rgb_color(unsigned char r, unsigned char g, unsigned char b) +{ + return (static_cast(r) << 16) | (static_cast(g) << 8) | b; +} +UiColor ui_darker(UiColor c) { return colour(c).darker(.25f).getARGB() & 0xffffff; } +UiColor ui_color_average(UiColor a, UiColor b, float weight) +{ + return colour(b).interpolatedWith(colour(a), weight).getARGB() & 0xffffff; +} +void ui_color(UiColor c) +{ + if (graphics) + graphics->setColour(colour(c)); +} +void ui_font(int f, int size) +{ + font = std::make_unique( + juce::FontOptions(static_cast(size), f ? juce::Font::bold : juce::Font::plain)); + if (f == UI_COURIER_BOLD) + font->setTypefaceName(juce::Font::getDefaultMonospacedFontName()); + if (graphics) + graphics->setFont(*font); +} +void ui_rectf(int x, int y, int w, int h) +{ + if (graphics && w > 0 && h > 0) + graphics->fillRect(x, y, w, h); +} +void ui_rect(int x, int y, int w, int h) +{ + if (graphics && w > 0 && h > 0) + graphics->drawRect(x, y, w, h); +} +void ui_line(int x, int y, int a, int b) +{ + if (graphics) + graphics->drawLine(static_cast(x), static_cast(y), static_cast(a), + static_cast(b), stroke); +} +void ui_line_style(int, int width) { stroke = static_cast(std::max(1, width)); } +void ui_polygon(int a, int b, int c, int d, int e, int f) +{ + if (graphics) + { + juce::Path p; + p.startNewSubPath(static_cast(a), static_cast(b)); + p.lineTo(static_cast(c), static_cast(d)); + p.lineTo(static_cast(e), static_cast(f)); + p.closeSubPath(); + graphics->fillPath(p); + } +} +void ui_pie(int x, int y, int w, int h, double, double) +{ + if (graphics) + graphics->fillEllipse(static_cast(x), static_cast(y), static_cast(w), + static_cast(h)); +} +void ui_draw(const char *s, int x, int y) +{ + if (graphics && s) + graphics->drawSingleLineText(juce::String::fromUTF8(s), x, y); +} +void ui_draw(const char *s, int x, int y, int w, int h, int align) +{ + if (!graphics || !s) + return; + auto justification = align & UI_ALIGN_LEFT + ? juce::Justification::centredLeft + : (align & UI_ALIGN_RIGHT ? juce::Justification::centredRight + : juce::Justification::centred); + if (align & UI_ALIGN_TOP) + justification = juce::Justification::topLeft; + graphics->drawFittedText(juce::String::fromUTF8(s), x, y, w, h, justification, + align & UI_ALIGN_WRAP ? std::max(1, h / 12) : 1); +} +void ui_push_clip(int x, int y, int w, int h) +{ + if (graphics) + { + graphics->saveState(); + graphics->reduceClipRegion(x, y, w, h); + } +} +void ui_pop_clip() +{ + if (graphics) + graphics->restoreState(); +} +int ui_clip_box(int x, int y, int w, int h, int &a, int &b, int &c, int &d) +{ + auto r = juce::Rectangle(x, y, w, h); + if (graphics) + r = r.getIntersection(graphics->getClipBounds()); + a = r.getX(); + b = r.getY(); + c = r.getWidth(); + d = r.getHeight(); + return !r.isEmpty(); +} +namespace +{ +void message_dialog(const std::string &text) +{ + UiWindow window(720, 240, "xtc"); + window.set_modal(); + auto *box = new UiBox(16, 16, 688, 164, text.c_str()); + box->align(UI_ALIGN_LEFT | UI_ALIGN_INSIDE | UI_ALIGN_WRAP); + auto *close = new UiReturn_Button(600, 194, 100, 30, "OK"); + close->callback([](UiWidget *, void *w) { static_cast(w)->hide(); }, &window); + window.callback([](UiWidget *w, void *) { w->hide(); }); + window.end(); + window.show(); + while (window.shown()) + Ui::wait(); +} +} // namespace +void ui_alert(const char *s, ...) +{ + va_list a; + va_start(a, s); + auto t = formatted(s, a); + va_end(a); + message_dialog(t); +} +void ui_message(const char *s, ...) +{ + va_list a; + va_start(a, s); + auto t = formatted(s, a); + va_end(a); + message_dialog(t); +} +int ui_choice(const char *s, const char *a, const char *b, const char *c, ...) +{ + va_list args; + va_start(args, c); + auto message = formatted(s, args); + va_end(args); + juce::AlertWindow dialog("xtc", message, juce::MessageBoxIconType::QuestionIcon); + if (a) + dialog.addButton(a, 1); + if (b) + dialog.addButton(b, 2); + if (c) + dialog.addButton(c, 3); + int result = dialog.runModalLoop(); + return result ? result - 1 : 0; +} +const char *ui_input(const char *s, const char *initial, ...) +{ + va_list args; + va_start(args, initial); + auto message = formatted(s, args); + va_end(args); + juce::AlertWindow dialog("xtc", message, juce::MessageBoxIconType::QuestionIcon); + dialog.addTextEditor("value", initial ? initial : ""); + dialog.addButton("OK", 1, juce::KeyPress(juce::KeyPress::returnKey)); + dialog.addButton("Cancel", 0, juce::KeyPress(juce::KeyPress::escapeKey)); + static std::string answer; + if (dialog.runModalLoop() != 1) + return nullptr; + answer = dialog.getTextEditorContents("value").toStdString(); + return answer.c_str(); +} + +UiWidget::UiWidget(int x, int y, int w, int h, const char *s) + : px_(x), py_(y), pw_(w), ph_(h), label_(s ? s : "") +{ + setWantsKeyboardFocus(true); + setVisible(true); + setName(label_); + if (UiGroup::current()) + UiGroup::current()->add(this); + else + setBounds(x, y, w, h); +} +UiWidget::~UiWidget() +{ + if (Ui::pressed == this) + Ui::pressed = nullptr; + if (parent_) + parent_->remove(this); +} +void UiWidget::resize(int x, int y, int w, int h) +{ + if (x == px_ && y == py_ && w == pw_ && h == ph_ && + getX() == x - (parent_ ? parent_->x() : 0) && getY() == y - (parent_ ? parent_->y() : 0) && + getWidth() == w && getHeight() == h) + return; + px_ = x; + py_ = y; + pw_ = std::max(1, w); + ph_ = std::max(1, h); + setBounds(x - (parent_ ? parent_->x() : 0), y - (parent_ ? parent_->y() : 0), pw_, ph_); +} +UiWindow *UiWidget::window() const +{ + auto *c = const_cast(this)->getTopLevelComponent(); + return dynamic_cast(c); +} +void UiWidget::paint(juce::Graphics &g) +{ + auto *previous = graphics; + graphics = &g; + g.saveState(); + g.setOrigin(-x(), -y()); + if (!font) + ui_font(0, 12); + g.setFont(*font); + draw(); + g.restoreState(); + graphics = previous; +} +void UiWidget::draw_box() +{ + if (box() != UI_NO_BOX) + { + ui_color(color()); + ui_rectf(x(), y(), w(), h()); + if (box() != UI_FLAT_BOX) + { + ui_color(0x3a4049); + ui_rect(x(), y(), w(), h()); + } + } +} +void UiWidget::draw_label() +{ + ui_color(labelcolor()); + ui_font(labelfont(), labelsize()); + ui_draw(label(), x() + 4, y(), w() - 8, h(), align()); +} +void UiWidget::event(const juce::MouseEvent &e, int type) +{ + Ui::ex = x() + e.x; + Ui::ey = y() + e.y; + Ui::es = modifiers(e.mods); + Ui::eb = e.mods.isRightButtonDown() ? UI_RIGHT_MOUSE : UI_LEFT_MOUSE; + Ui::clicks = e.getNumberOfClicks() - 1; + if (type == UI_PUSH) + { + Ui::pressed = this; + take_focus(); + } + handle(type); + if (type == UI_RELEASE) + Ui::pressed = nullptr; +} +void UiWidget::mouseDown(const juce::MouseEvent &e) { event(e, UI_PUSH); } +void UiWidget::mouseDrag(const juce::MouseEvent &e) { event(e, UI_DRAG); } +void UiWidget::mouseUp(const juce::MouseEvent &e) { event(e, UI_RELEASE); } +void UiWidget::mouseDoubleClick(const juce::MouseEvent &e) { event(e, UI_PUSH); } +bool UiWidget::keyPressed(const juce::KeyPress &key) +{ + Ui::ek = key_code(key); + Ui::es = modifiers(key.getModifiers()); + if (handle(UI_KEYDOWN)) + return true; + if (auto *w = window()) + return w->handle(UI_SHORTCUT) != 0; + return false; +} +UiGroup::UiGroup(int x, int y, int w, int h, const char *s) : UiWidget(x, y, w, h, s) { begin(); } +UiGroup::~UiGroup() +{ + if (current_ == this) + current_ = parent(); + clear(); +} +void UiGroup::add(UiWidget *w) +{ + if (w->parent_ == this) + return; + if (w->parent_) + w->parent_->remove(w); + children_.push_back(w); + w->parent_ = this; + addChildComponent(w); + w->setVisible(true); + w->resize(w->x(), w->y(), w->w(), w->h()); +} +void UiGroup::remove(UiWidget *w) +{ + children_.erase(std::remove(children_.begin(), children_.end(), w), children_.end()); + removeChildComponent(w); + if (w->parent_ == this) + w->parent_ = nullptr; + if (resizable_ == w) + resizable_ = nullptr; +} +void UiGroup::clear() +{ + while (!children_.empty()) + delete children_.back(); +} +void UiGroup::resize(int nx, int ny, int nw, int nh) +{ + if (nx == x() && ny == y() && nw == w() && nh == h() && + getX() == nx - (parent() ? parent()->x() : 0) && + getY() == ny - (parent() ? parent()->y() : 0) && getWidth() == nw && getHeight() == nh) + return; + int dx = nx - x(), dy = ny - y(), dw = nw - w(), dh = nh - h(); + int rx = resizable_ ? resizable_->x() : x(), ry = resizable_ ? resizable_->y() : y(), + rw = resizable_ ? resizable_->w() : w(), rh = resizable_ ? resizable_->h() : h(); + UiWidget::resize(nx, ny, nw, nh); + auto edge = [](int p, int r, int size, int delta) + { + if (p <= r) + return p; + if (p >= r + size) + return p + delta; + return p + + static_cast(std::round(static_cast(p - r) * delta / std::max(1, size))); + }; + for (auto *c : children_) + { + int a = c->x(), b = c->y(), z = a + c->w(), t = b + c->h(); + if (resizable_) + { + a = edge(a, rx, rw, dw); + z = edge(z, rx, rw, dw); + b = edge(b, ry, rh, dh); + t = edge(t, ry, rh, dh); + } + c->resize(a + dx, b + dy, z - a, t - b); + } + redraw(); +} +UiWindow::UiWindow(int w, int h, const char *s) : UiWindow(50, 50, w, h, s) {} +UiWindow::UiWindow(int x, int y, int w, int h, const char *s) : UiGroup(0, 0, w, h, s) +{ + box(UI_FLAT_BOX); + if (parent()) + parent()->remove(this); + setBounds(x, y, w, h); + setVisible(false); + windows.push_back(this); +} +UiWindow::~UiWindow() +{ + windows.erase(std::remove(windows.begin(), windows.end(), this), windows.end()); + if (isCurrentlyModal()) + exitModalState(0); + removeFromDesktop(); +} +void UiWindow::show() +{ + if (!isOnDesktop()) + addToDesktop( + juce::ComponentPeer::windowHasTitleBar | juce::ComponentPeer::windowHasCloseButton | + juce::ComponentPeer::windowIsResizable | juce::ComponentPeer::windowHasMinimiseButton | + juce::ComponentPeer::windowHasMaximiseButton); + setVisible(true); + toFront(true); + if (modal_ && !isCurrentlyModal()) + enterModalState(true, nullptr, false); +} +void UiWindow::hide() +{ + if (isCurrentlyModal()) + exitModalState(0); + setVisible(false); +} +UiButton::UiButton(int x, int y, int w, int h, const char *s) : UiWidget(x, y, w, h, s) +{ + addChildComponent(button_); + button_.onClick = [this] { do_callback(); }; + sync_control(); + resized(); +} +void UiButton::sync_control() +{ + button_.setTooltip(getTooltip()); + button_.setButtonText(label()); + button_.setColour(juce::TextButton::buttonColourId, colour(color())); + button_.setColour(juce::TextButton::textColourOffId, colour(labelcolor())); + button_.setColour(juce::TextButton::buttonOnColourId, colour(selection_color())); +} +UiInput_::UiInput_(int x, int y, int w, int h, const char *s) : UiWidget(x, y, w, h, s) +{ + align(UI_ALIGN_LEFT); + addAndMakeVisible(editor); + editor.setMultiLine(false); + editor.onTextChange = [this] + { + if (when() & UI_WHEN_CHANGED) + do_callback(); + }; + editor.onReturnKey = [this] { do_callback(); }; + editor.onFocusLost = [this] + { + if (when() & UI_WHEN_RELEASE) + do_callback(); + }; + sync_control(); + resized(); +} +void UiInput_::value(const char *s) { editor.setText(juce::String::fromUTF8(s ? s : ""), false); } +const char *UiInput_::value() const +{ + text_ = editor.getText().toStdString(); + return text_.c_str(); +} +void UiInput_::sync_control() +{ + editor.setTooltip(getTooltip()); + editor.setColour(juce::TextEditor::backgroundColourId, colour(color())); + editor.setColour(juce::TextEditor::textColourId, colour(labelcolor())); +} +UiValue_Input::UiValue_Input(int x, int y, int w, int h, const char *s) + : UiWidget(x, y, w, h, s), input(x, y, w, h) +{ + if (input.parent()) + input.parent()->remove(&input); + addAndMakeVisible(input); + input.callback( + [](UiWidget *, void *p) + { + auto &v = *static_cast(p); + char *end = nullptr; + double d = std::strtod(v.input.value(), &end); + if (end && *end == 0 && std::isfinite(d)) + { + v.value(std::clamp(d, v.low_, v.high_)); + v.do_callback(); + } + else + v.value(v.value_); + }, + this); + input.when(UI_WHEN_ENTER_KEY | UI_WHEN_RELEASE); + resized(); +} +void UiValue_Input::value(double v) +{ + value_ = v; + char b[64]; + std::snprintf(b, sizeof(b), "%.8g", v); + input.value(b); +} +void UiValue_Input::sync_control() +{ + input.tooltip(getTooltip().toRawUTF8()); + input.color(color()); + input.labelcolor(labelcolor()); +} +UiValue_Slider::UiValue_Slider(int x, int y, int w, int h, const char *s) : UiWidget(x, y, w, h, s) +{ + addAndMakeVisible(slider_); + slider_.setSliderStyle(juce::Slider::LinearHorizontal); + slider_.setTextBoxStyle(juce::Slider::TextBoxRight, false, 60, 20); + slider_.setRange(0, 1); + slider_.setDoubleClickReturnValue(true, 0); + slider_.onValueChange = [this] + { + if ((when() & UI_WHEN_CHANGED) || !slider_.isMouseButtonDown(true)) + do_callback(); + }; + slider_.onDragStart = [this] { Ui::pressed = this; }; + slider_.onDragEnd = [this] + { + if (when() & UI_WHEN_RELEASE) + do_callback(); + Ui::pressed = nullptr; + }; + resized(); +} +void UiValue_Slider::bounds(double l, double h) +{ + slider_.setRange(std::min(l, h), std::max(l, h), slider_.getInterval()); +} +void UiValue_Slider::step(double s) +{ + slider_.setRange(slider_.getMinimum(), slider_.getMaximum(), s); +} +void UiValue_Slider::type(int t) +{ + UiWidget::type(t); + slider_.setSliderStyle(t == UI_VERT_SLIDER ? juce::Slider::LinearVertical + : t == UI_LINE_DIAL ? juce::Slider::RotaryHorizontalVerticalDrag + : juce::Slider::LinearHorizontal); + slider_.setTextBoxStyle( + t == UI_VERT_SLIDER ? juce::Slider::NoTextBox : juce::Slider::TextBoxRight, false, 60, 20); +} +void UiValue_Slider::sync_control() +{ + slider_.setTooltip(getTooltip()); + slider_.setColour(juce::Slider::backgroundColourId, colour(color())); + slider_.setColour(juce::Slider::thumbColourId, colour(selection_color())); +} +void UiProgress::draw() +{ + draw_box(); + ui_color(selection_color()); + ui_rectf(x(), y(), + static_cast( + w() * std::clamp((value_ - low_) / std::max(1e-12, high_ - low_), 0.0, 1.0)), + h()); + draw_label(); +} +const UiMenu_Item *UiMenu_Item::popup(int, int) const +{ + juce::PopupMenu menu; + int i = 0; + while (this[i].text) + { + menu.addItem(i + 1, juce::String::fromUTF8(this[i].text), this[i].active(), + (this[i].flags & UI_MENU_VALUE) != 0); + ++i; + } + int picked = menu.show(); + return picked > 0 ? this + picked - 1 : nullptr; +} +void UiMenu_::repair() +{ + for (size_t i = 0; i < labels_.size(); ++i) + items_[i].text = labels_[i].c_str(); + items_.back() = {}; +} +int UiMenu_::add(const char *s, int shortcut, UiCallback callback, void *context, int flags) +{ + std::string all = s ? s : ""; + size_t start = 0; + int first = static_cast(items_.size()) - 1; + do + { + size_t end = all.find('|', start); + std::string path = all.substr(start, end == std::string::npos ? end : end - start); + paths_.push_back(path); + size_t slash = path.find_last_of('/'); + labels_.push_back(path.substr(slash == std::string::npos ? 0 : slash + 1)); + items_.insert(items_.end() - 1, {nullptr, shortcut, callback, context, flags}); + if (end == std::string::npos) + break; + start = end + 1; + } while (start <= all.size()); + repair(); + return first; +} +void UiMenu_::replace(int index, const char *s) +{ + if (index < 0 || index >= static_cast(items_.size())) + return; + labels_[static_cast(index)] = s ? s : ""; + repair(); + redraw(); +} +void UiMenu_::clear() +{ + literal_ = false; + items_.assign(1, {}); + paths_.clear(); + labels_.clear(); + selected_ = -1; +} +void UiMenu_::copy(const UiMenu_Item *items) +{ + clear(); + literal_ = true; + for (size_t i = 0; items[i].text; ++i) + { + paths_.emplace_back(items[i].text); + labels_.emplace_back(items[i].text); + items_.insert(items_.end() - 1, items[i]); + } + repair(); +} +const UiMenu_Item *UiMenu_::menu() const { return items_.data(); } +const UiMenu_Item *UiMenu_::mvalue() const +{ + return selected_ >= 0 && selected_ < static_cast(labels_.size()) + ? &items_[static_cast(selected_)] + : nullptr; +} +const UiMenu_Item *UiMenu_::find_item(const char *s) const +{ + for (size_t i = 0; i < paths_.size(); ++i) + if (paths_[i] == s) + return &items_[i]; + return nullptr; +} +const UiMenu_Item *UiMenu_::picked(const UiMenu_Item *item) +{ + if (!item || !item->active()) + return nullptr; + selected_ = static_cast(item - items_.data()); + if (item->flags & UI_MENU_TOGGLE) + items_[static_cast(selected_)].flags ^= UI_MENU_VALUE; + auto callback = item->callback_; + auto *context = item->user_data_; + if (callback) + callback(this, context); + else + do_callback(); + redraw(); + return mvalue(); +} +const UiMenu_Item *UiMenu_::test_shortcut() +{ + for (auto &item : items_) + if (item.shortcut_ && item.shortcut_ == (Ui::ek | Ui::es) && item.active()) + return picked(&item); + return nullptr; +} +int UiMenu_::item_pathname(char *out, int size, const UiMenu_Item *item) const +{ + auto index = item - items_.data(); + if (index < 0 || index >= static_cast(paths_.size())) + return -1; + std::snprintf(out, static_cast(size), "%s", paths_[static_cast(index)].c_str()); + return 0; +} +void UiMenu_::show_popup(bool bar) +{ + juce::PopupMenu root; + if (literal_) + { + for (size_t i = 0; i < labels_.size(); ++i) + root.addItem(static_cast(i) + 1, juce::String::fromUTF8(labels_[i].c_str()), + items_[i].active()); + int result = root.showMenu(juce::PopupMenu::Options().withTargetComponent(this)); + if (result > 0) + picked(&items_[static_cast(result - 1)]); + return; + } + std::function build = + [&](juce::PopupMenu &menu, const std::string &prefix) + { + std::vector groups; + for (size_t i = 0; i < paths_.size(); ++i) + { + if (paths_[i].compare(0, prefix.size(), prefix)) + continue; + auto tail = paths_[i].substr(prefix.size()); + auto slash = tail.find('/'); + if (slash == std::string::npos) + menu.addItem(static_cast(i) + 1, juce::String::fromUTF8(labels_[i].c_str()), + items_[i].active(), (items_[i].flags & UI_MENU_VALUE) != 0); + else + { + auto name = tail.substr(0, slash); + if (std::find(groups.begin(), groups.end(), name) != groups.end()) + continue; + groups.push_back(name); + juce::PopupMenu sub; + build(sub, prefix + name + "/"); + menu.addSubMenu(name, sub); + } + } + }; + if (bar) + { + std::vector groups; + for (auto &path : paths_) + { + auto name = path.substr(0, path.find('/')); + if (std::find(groups.begin(), groups.end(), name) == groups.end()) + groups.push_back(name); + } + int selected = + std::clamp((Ui::ex - x()) / 90, 0, std::max(0, static_cast(groups.size()) - 1)); + if (!groups.empty()) + build(root, groups[static_cast(selected)] + "/"); + } + else + build(root, ""); + int result = root.showMenu(juce::PopupMenu::Options().withTargetComponent(this)); + if (result > 0) + picked(&items_[static_cast(result - 1)]); +} +int UiMenu_::handle(int e) +{ + if (e == UI_PUSH) + { + show_popup(); + return 1; + } + return UiWidget::handle(e); +} +void UiMenu_::draw() +{ + ui_color(color()); + ui_rectf(x(), y(), w(), h()); + ui_color(labelcolor()); + ui_font(0, labelsize()); + ui_draw(mvalue() ? mvalue()->label() : label(), x() + 6, y(), w() - 20, h(), UI_ALIGN_LEFT); + ui_draw("v", x() + w() - 16, y(), 14, h()); +} +void UiMenu_Bar::draw() +{ + ui_color(color()); + ui_rectf(x(), y(), w(), h()); + std::vector groups; + for (auto &p : paths_) + { + auto name = p.substr(0, p.find('/')); + if (std::find(groups.begin(), groups.end(), name) == groups.end()) + groups.push_back(name); + } + ui_color(labelcolor()); + ui_font(0, labelsize()); + for (size_t i = 0; i < groups.size(); ++i) + ui_draw(groups[i].c_str(), x() + static_cast(i) * 90 + 6, y(), 86, h(), UI_ALIGN_LEFT); +} +int UiMenu_Bar::handle(int e) +{ + if (e == UI_PUSH) + { + show_popup(true); + return 1; + } + return UiMenu_::handle(e); +} +UiInput_Choice::UiInput_Choice(int x, int y, int w, int h, const char *s) : UiGroup(x, y, w, h, s) +{ + input_ = new UiInput(x, y, w - 30, h); + menu_ = new UiMenu_Button(x + w - 30, y, 30, h, "v"); + menu_->callback( + [](UiWidget *w, void *p) + { + auto &c = *static_cast(p); + auto *m = static_cast(w); + if (m->text()) + c.input_->value(m->text()); + c.do_callback(); + }, + this); + end(); +} +UiHold_Browser::UiHold_Browser(int x, int y, int w, int h, const char *s) + : UiWidget(x, y, w, h, s), list_("", this) +{ + addAndMakeVisible(list_); + list_.setColour(juce::ListBox::backgroundColourId, colour(color())); + resized(); +} +void UiHold_Browser::paintListBoxItem(int row, juce::Graphics &g, int w, int h, bool selected) +{ + if (row < 0 || row >= static_cast(rows_.size())) + return; + g.fillAll(colour(selected ? selection_color() : color())); + g.setColour(colour(labelcolor())); + g.setFont(juce::FontOptions(static_cast(labelsize()))); + g.drawText(juce::String::fromUTF8(rows_[static_cast(row)].c_str()), 4, 0, w - 8, h, + juce::Justification::centredLeft); +} +UiScroll::UiScroll(int x, int y, int w, int h, const char *s) + : UiGroup(x, y, w, h, s), scrollbar(0, 0, 1, 1), hscrollbar(0, 0, 1, 1) +{ + remove(scrollbar); + remove(hscrollbar); + addAndMakeVisible(vertical_); + addAndMakeVisible(horizontal_); + vertical_.addListener(&listener_); + horizontal_.addListener(&listener_); +} +void UiScroll::Listener::scrollBarMoved(juce::ScrollBar *bar, double start) +{ + if (owner.scrolling_) + return; + owner.scroll_to(bar == &owner.horizontal_ ? static_cast(start) : owner.sx_, + bar == &owner.vertical_ ? static_cast(start) : owner.sy_); +} +void UiScroll::update_bars() +{ + int right = w() - 14, bottom = h() - 14; + for (int i = 0; i < children(); ++i) + { + right = std::max(right, child(i)->x() - x() + child(i)->w() + sx_); + bottom = std::max(bottom, child(i)->y() - y() + child(i)->h() + sy_); + } + scrolling_ = true; + vertical_.setBounds(w() - 12, 0, 12, h() - 12); + horizontal_.setBounds(0, h() - 12, w() - 12, 12); + vertical_.setRangeLimits(0, std::max(h() - 12, bottom)); + vertical_.setCurrentRange(sy_, std::max(1, h() - 12), juce::dontSendNotification); + horizontal_.setRangeLimits(0, std::max(w() - 12, right)); + horizontal_.setCurrentRange(sx_, std::max(1, w() - 12), juce::dontSendNotification); + if (getNumChildComponents() < 2 || + getChildComponent(getNumChildComponents() - 1) != &horizontal_ || + getChildComponent(getNumChildComponents() - 2) != &vertical_) + { + vertical_.toFront(false); + horizontal_.toFront(false); + } + scrolling_ = false; +} +void UiScroll::scroll_to(int x, int y) +{ + x = std::max(0, x); + y = std::max(0, y); + int dx = sx_ - x, dy = sy_ - y; + sx_ = x; + sy_ = y; + for (int i = 0; i < children(); ++i) + { + auto *c = child(i); + c->position(c->x() + dx, c->y() + dy); + } + update_bars(); + redraw(); +} +void UiScroll::mouseWheelMove(const juce::MouseEvent &, const juce::MouseWheelDetails &wheel) +{ + scroll_to(std::max(0, sx_ - static_cast(wheel.deltaX * 200)), + std::max(0, sy_ - static_cast(wheel.deltaY * 200))); +} +void UiTabs::update() +{ + auto *s = value(); + for (int i = 0; i < children(); ++i) + child(i)->setVisible(child(i) == s); + redraw(); +} +void UiTabs::draw() +{ + UiGroup::draw(); + auto *s = value(); + int left = x(); + const int tab_width = std::max(1, std::min(140, w() / std::max(1, children()))); + for (int i = 0; i < children(); ++i) + { + auto *c = child(i); + c->setVisible(c == s); + ui_color(c == s ? selection_color() : color()); + ui_rectf(left, y(), tab_width, 30); + ui_color(labelcolor()); + ui_font(0, 12); + ui_draw(c->label(), left + 6, y(), std::max(1, tab_width - 12), 30, UI_ALIGN_LEFT); + left += tab_width; + } +} +int UiTabs::handle(int e) +{ + if (e == UI_PUSH && Ui::ey < y() + 30) + { + const int tab_width = std::max(1, std::min(140, w() / std::max(1, children()))); + int n = (Ui::ex - x()) / tab_width; + if (n >= 0 && n < children()) + { + selected_ = child(n); + update(); + do_callback(); + return 1; + } + } + return UiGroup::handle(e); +} +int UiNative_File_Chooser::show() +{ + // Accept the retained labelled filters, including brace alternatives and + // callers which supply escaped tabs/newlines. JUCE expects semicolon globs. + auto filters = juce::String(filter_).replace("\\t", "\t").replace("\\n", "\n"); + juce::StringArray patterns; + for (auto line : juce::StringArray::fromLines(filters)) + { + auto pattern = line.containsChar('\t') ? line.fromFirstOccurrenceOf("\t", false, false) + : line; + if (pattern.containsChar('{') && pattern.containsChar('}')) + { + auto prefix = pattern.upToFirstOccurrenceOf("{", false, false); + auto alternatives = pattern.fromFirstOccurrenceOf("{", false, false) + .upToFirstOccurrenceOf("}", false, false); + auto suffix = pattern.fromFirstOccurrenceOf("}", false, false); + for (auto extension : juce::StringArray::fromTokens(alternatives, ",", "")) + patterns.add(prefix + extension + suffix); + } + else if (pattern.isNotEmpty()) + patterns.add(pattern); + } + auto wildcard = patterns.isEmpty() ? juce::String("*") : patterns.joinIntoString(";"); + juce::File start = directory_.empty() + ? juce::File::getSpecialLocation(juce::File::userHomeDirectory) + : juce::File(directory_); + if (!filename_.empty()) + start = start.getChildFile(filename_); + juce::FileChooser chooser(title_, start, wildcard, true); + bool ok = type_ == BROWSE_SAVE_FILE ? chooser.browseForFileToSave(true) + : chooser.browseForFileToOpen(); + if (!ok) + return 1; + filename_ = chooser.getResult().getFullPathName().toStdString(); + return 0; +} + +void UiWidget::mouseWheelMove(const juce::MouseEvent &e, const juce::MouseWheelDetails &wheel) +{ + if (auto *p = getParentComponent()) + p->mouseWheelMove(e.getEventRelativeTo(p), wheel); +} +void UiGroup::draw() +{ + draw_box(); + for (int i = 0; i < children(); ++i) + { + auto *c = child(i); + if (!c->visible() || (c->align() & UI_ALIGN_INSIDE) || !*c->label()) + continue; + ui_color(c->labelcolor()); + ui_font(c->labelfont(), c->labelsize()); + ui_draw(c->label(), x() + 4, c->y(), std::max(1, c->x() - x() - 12), c->h(), + UI_ALIGN_RIGHT); + } +} +void UiWindow::resize(int nx, int ny, int nw, int nh) +{ + UiGroup::resize(0, 0, std::max(min_w_, nw), std::max(min_h_, nh)); + setTopLeftPosition(nx, ny); +} +void UiWindow::resized() +{ + if (getWidth() != w() || getHeight() != h()) + resize(getX(), getY(), getWidth(), getHeight()); +} + +int &Ui::e_x = Ui::ex, &Ui::e_y = Ui::ey, &Ui::e_keysym = Ui::ek, &Ui::e_state = Ui::es, + &Ui::e_clicks = Ui::clicks; +UiWindow *Ui::first_window() +{ + for (auto i = windows.rbegin(); i != windows.rend(); ++i) + if ((*i)->shown()) + return *i; + return nullptr; +} +UiWindow *Ui::modal() +{ + return dynamic_cast(juce::Component::getCurrentlyModalComponent()); +} +void Ui::focus(UiWidget *w) +{ + if (w) + w->take_focus(); + else + juce::Component::unfocusAllComponents(); +} +int Ui::handle(int type, UiWindow *window) +{ + if (type == UI_KEYDOWN || type == UI_SHORTCUT) + return window->handle(type); + UiWidget *target = pressed; + if (type == UI_PUSH) + { + auto *c = window->getComponentAt(ex, ey); + while (c && !dynamic_cast(c)) + c = c->getParentComponent(); + target = dynamic_cast(c); + pressed = target; + } + if (!target) + return 0; + int result = target->handle(type); + if (type == UI_RELEASE) + pressed = nullptr; + return result; +} +void UiWidget::paintOverChildren(juce::Graphics &g) +{ + auto *previous = graphics; + graphics = &g; + g.saveState(); + g.setOrigin(-x(), -y()); + draw_overlay(); + g.restoreState(); + graphics = previous; +} + +void UiGroup::insert(UiWidget &w, UiWidget *before) +{ + add(w); + auto pos = std::find(children_.begin(), children_.end(), before); + if (pos != children_.end()) + { + children_.erase(std::remove(children_.begin(), children_.end(), &w), children_.end()); + pos = std::find(children_.begin(), children_.end(), before); + children_.insert(pos, &w); + w.toBehind(before); + } +} + +int UiGroup::handle(int event) +{ + if (event == UI_PUSH) + { + for (auto i = children_.rbegin(); i != children_.rend(); ++i) + { + auto *c = *i; + if (c->visible() && c->active() && Ui::event_inside(c) && c->handle(event)) + { + Ui::pressed = c; + return 1; + } + } + } + return event == UI_FOCUS || event == UI_UNFOCUS; +} + +namespace +{ +class WorkstationApplication : public juce::JUCEApplication +{ + public: + const juce::String getApplicationName() override { return "xtc JUCE"; } + const juce::String getApplicationVersion() override { return XTC_VERSION_STRING; } + void initialise(const juce::String &) override {} + void shutdown() override {} + void systemRequestedQuit() override + { + if (auto *w = Ui::first_window()) + w->do_callback(); + } +}; +juce::JUCEApplicationBase *create_workstation_application() { return new WorkstationApplication; } +} // namespace +struct UiInitialiser::State +{ + std::unique_ptr gui; + std::unique_ptr app; + std::unique_ptr tips; + State() + { + juce::JUCEApplicationBase::createInstance = create_workstation_application; + gui = std::make_unique(); + app.reset(create_workstation_application()); + tips = std::make_unique(nullptr, 700); + } + ~State() + { + tips.reset(); + app.reset(); + Ui::shutdown(); + gui.reset(); + juce::JUCEApplicationBase::createInstance = nullptr; + } +}; +UiInitialiser::UiInitialiser() : state_(std::make_unique()) {} +UiInitialiser::~UiInitialiser() = default; +void Ui::shutdown() +{ + timers.clear(); + if (juce::MessageManager::getInstanceWithoutCreating()) + juce::MessageManager::getInstance()->runDispatchLoopUntil(20); + font.reset(); +} + +int UiValue_Slider::handle(int event) +{ + if (event == UI_PUSH) + { + dragging_ = true; + drag_value_ = value(); + drag_x_ = Ui::event_x(); + drag_y_ = Ui::event_y(); + if (Ui::event_clicks()) + { + value(0); + drag_value_ = 0; + } + return 1; + } + if (event == UI_DRAG && dragging_) + { + double pixels = type() == UI_VERT_SLIDER ? drag_y_ - Ui::event_y() + : type() == UI_LINE_DIAL ? Ui::event_x() - drag_x_ + drag_y_ - Ui::event_y() + : Ui::event_x() - drag_x_; + double extent = type() == UI_VERT_SLIDER ? h() : type() == UI_LINE_DIAL ? 200 : w(); + value(drag_value_ + + pixels * (slider_.getMaximum() - slider_.getMinimum()) / std::max(1.0, extent)); + if (when() & UI_WHEN_CHANGED) + do_callback(); + return 1; + } + if (event == UI_RELEASE && dragging_) + { + dragging_ = false; + do_callback(); + return 1; + } + return UiWidget::handle(event); +} diff --git a/src/gui/JUCE/ui.hpp b/src/gui/JUCE/ui.hpp new file mode 100644 index 0000000..4f9393e --- /dev/null +++ b/src/gui/JUCE/ui.hpp @@ -0,0 +1,647 @@ +#ifndef XTC_JUCE_UI_HPP +#define XTC_JUCE_UI_HPP +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// JUCE component adapter for the workstation's retained coordinates and commands. +// This layer has no FLTK headers, objects or runtime dependency. +using uchar = unsigned char; +using UiColor = unsigned int; +using UiBoxtype = int; +constexpr int UI_NO_BOX = 0, UI_FLAT_BOX = 1, UI_UP_BOX = 2, UI_DOWN_BOX = 3, UI_THIN_UP_BOX = 4, + UI_THIN_DOWN_BOX = 5, UI_ENGRAVED_BOX = 6, UI_BORDER_BOX = 7, UI_FREE_BOXTYPE = 16; +constexpr int UI_ALIGN_LEFT = 1, UI_ALIGN_RIGHT = 2, UI_ALIGN_TOP = 4, UI_ALIGN_INSIDE = 8, + UI_ALIGN_CLIP = 16, UI_ALIGN_WRAP = 32, UI_ALIGN_TOP_LEFT = 5; +constexpr int UI_HELVETICA = 0, UI_HELVETICA_BOLD = 1, UI_COURIER_BOLD = 2, UI_BOLD = 1, + UI_NO_LABEL = 0, UI_SOLID = 0, UI_LINE_DIAL = 1, UI_HOR_FILL_SLIDER = 2, + UI_VERT_SLIDER = 3; +constexpr UiColor UI_WHITE = 0xffffff, UI_RED = 0xff0000, UI_SELECTION_COLOR = 0x35746f; +constexpr int UI_CTRL = 1 << 20, UI_SHIFT = 1 << 21, UI_ALT = 1 << 22, UI_META = 1 << 23; +constexpr int UI_PUSH = 1, UI_DRAG = 2, UI_RELEASE = 3, UI_FOCUS = 4, UI_UNFOCUS = 5, + UI_KEYDOWN = 6, UI_SHORTCUT = 7; +constexpr int UI_LEFT_MOUSE = 1, UI_RIGHT_MOUSE = 3, UI_Button = 0x10000, UI_BUTTON1 = 1 << 24; +constexpr int UI_Escape = 27, UI_Enter = 13, UI_KP_Enter = 13, UI_BackSpace = 8, UI_Delete = 127, + UI_Left = 0x100, UI_Right = 0x101, UI_Up = 0x102, UI_Down = 0x103, UI_Home = 0x104, + UI_F = 0x200; +constexpr int UI_WHEN_CHANGED = 1, UI_WHEN_ENTER_KEY = 2, UI_WHEN_RELEASE = 4, UI_MENU_TOGGLE = 1, + UI_MENU_VALUE = 2, UI_MENU_INACTIVE = 4, UI_CURSOR_DEFAULT = 0, UI_CURSOR_WAIT = 1; +class UiWidget; +class UiGroup; +class UiWindow; +using UiCallback = void (*)(UiWidget *, void *); +class UiInitialiser +{ + struct State; + std::unique_ptr state_; + + public: + UiInitialiser(); + ~UiInitialiser(); +}; +struct Ui +{ + static int ex, ey, ek, es, eb, clicks; + static int &e_x, &e_y, &e_keysym, &e_state, &e_clicks; + static UiWindow *first_window(); + static UiWindow *modal(); + static void focus(UiWidget *); + static int handle(int, UiWindow *); + static UiWidget *pressed; + static int event_x() { return ex; } + static int event_y() { return ey; } + static int event_key() { return ek; } + static int event_state() { return es; } + static int event_state(int mask) { return es & mask; } + static int event_button() { return ek >= 0x10000 && ek < 0x10010 ? ek - 0x10000 : eb; } + static int event_clicks() { return clicks; } + static bool event_inside(const UiWidget *); + static UiWidget *focus(); + static UiWidget *pushed() { return pressed; } + static void delete_widget(UiWidget *); + static void add_timeout(double, void (*)(void *), void * = nullptr); + static void repeat_timeout(double s, void (*f)(void *), void *c) { add_timeout(s, f, c); } + static void remove_timeout(void (*)(void *), void *); + static void shutdown(); + static int run(); + static double wait(double seconds = .01); + static void check() { wait(.001); } + static void flush(); + static int screen_num(int, int, int, int) { return 0; } + static void screen_work_area(int &, int &, int &, int &, int); + static void background(int, int, int) {} + static void background2(int, int, int) {} + static void foreground(int, int, int) {} + static void set_color(int, int, int, int) {} + static void set_boxtype(int, void (*)(int, int, int, int, UiColor), int, int, int, int) {} + static void scrollbar_size(int) {} +}; +UiColor ui_rgb_color(unsigned char, unsigned char, unsigned char); +UiColor ui_darker(UiColor); +UiColor ui_color_average(UiColor, UiColor, float); +void ui_color(UiColor); +void ui_font(int, int); +void ui_rectf(int, int, int, int); +void ui_rect(int, int, int, int); +void ui_line(int, int, int, int); +void ui_polygon(int, int, int, int, int, int); +void ui_pie(int, int, int, int, double, double); +void ui_draw(const char *, int, int); +void ui_draw(const char *, int, int, int, int, int = 0); +void ui_push_clip(int, int, int, int); +void ui_pop_clip(); +int ui_clip_box(int, int, int, int, int &, int &, int &, int &); +void ui_line_style(int, int = 1); +void ui_alert(const char *, ...); +void ui_message(const char *, ...); +int ui_choice(const char *, const char *, const char *, const char *, ...); +const char *ui_input(const char *, const char * = "", ...); + +class UiWidget : public juce::Component, public juce::SettableTooltipClient +{ + friend class UiGroup; + int px_, py_, pw_, ph_; + std::string label_, tooltip_; + UiCallback callback_ = nullptr; + void *context_ = nullptr; + UiGroup *parent_ = nullptr; + UiColor colour_ = 0x22262c, label_colour_ = 0xe7ebf0, selection_ = 0x35746f; + int box_ = UI_NO_BOX, alignment_ = UI_ALIGN_INSIDE, label_size_ = 12, font_ = 0, + when_ = UI_WHEN_RELEASE, type_ = 0; + + protected: + friend struct Ui; + void paint(juce::Graphics &) override; + void paintOverChildren(juce::Graphics &) override; + void mouseDown(const juce::MouseEvent &) override; + void mouseDrag(const juce::MouseEvent &) override; + void mouseUp(const juce::MouseEvent &) override; + void mouseDoubleClick(const juce::MouseEvent &) override; + void mouseWheelMove(const juce::MouseEvent &, const juce::MouseWheelDetails &) override; + bool keyPressed(const juce::KeyPress &) override; + void focusGained(FocusChangeType) override { handle(UI_FOCUS); } + void focusLost(FocusChangeType) override { handle(UI_UNFOCUS); } + void event(const juce::MouseEvent &, int); + virtual void sync_control() {} + + public: + UiWidget(int, int, int, int, const char * = nullptr); + ~UiWidget() override; + virtual void draw() + { + draw_box(); + draw_label(); + } + virtual void draw_overlay() {} + virtual int handle(int) { return 0; } + int x() const { return px_; } + int y() const { return py_; } + int w() const { return pw_; } + int h() const { return ph_; } + virtual void resize(int, int, int, int); + void position(int x, int y) { resize(x, y, w(), h()); } + void size(int w, int h) { resize(x(), y(), w, h); } + UiGroup *parent() const { return parent_; } + UiWindow *window() const; + const char *label() const { return label_.c_str(); } + void label(const char *s) { copy_label(s); } + void copy_label(const char *s) + { + label_ = s ? s : ""; + setName(label_); + sync_control(); + redraw(); + } + void tooltip(const char *s) + { + tooltip_ = s ? s : ""; + setTooltip(tooltip_); + sync_control(); + } + void copy_tooltip(const char *s) { tooltip(s); } + void callback(UiCallback f, void *c = nullptr) + { + callback_ = f; + context_ = c; + } + void do_callback() + { + if (callback_) + callback_(this, context_); + } + void *user_data() const { return context_; } + void user_data(void *c) { context_ = c; } + virtual void show() { setVisible(true); } + virtual void hide() { setVisible(false); } + bool visible_r() const { return isShowing(); } + bool visible() const { return isVisible(); } + bool shown() const { return isVisible(); } + void activate() { setEnabled(true); } + void deactivate() { setEnabled(false); } + int active() const { return isEnabled(); } + int active_r() const { return isEnabled(); } + void redraw() { repaint(); } + virtual int take_focus() + { + if (!isShowing() || !isEnabled()) + return 0; + grabKeyboardFocus(); + return 1; + } + void color(UiColor c) + { + colour_ = c; + sync_control(); + redraw(); + } + UiColor color() const { return colour_; } + void labelcolor(UiColor c) + { + label_colour_ = c; + sync_control(); + redraw(); + } + UiColor labelcolor() const { return label_colour_; } + void selection_color(UiColor c) + { + selection_ = c; + sync_control(); + } + UiColor selection_color() const { return selection_; } + void box(int b) { box_ = b; } + int box() const { return box_; } + void slider(int) {} + void down_box(int) {} + void labeltype(int) {} + void align(int a) { alignment_ = a; } + int align() const { return alignment_; } + void labelsize(int s) + { + label_size_ = s; + sync_control(); + } + int labelsize() const { return label_size_; } + void labelfont(int f) { font_ = f; } + int labelfont() const { return font_; } + void textsize(int s) { labelsize(s); } + void textcolor(UiColor c) { labelcolor(c); } + void when(int w) { when_ = w; } + int when() const { return when_; } + virtual void type(int t) { type_ = t; } + int type() const { return type_; } + void draw_box(); + void draw_label(); +}; +class UiGroup : public UiWidget +{ + std::vector children_; + UiWidget *resizable_ = nullptr; + static UiGroup *current_; + + public: + UiGroup(int, int, int, int, const char * = nullptr); + ~UiGroup() override; + static UiGroup *current() { return current_; } + static void current(UiGroup *g) { current_ = g; } + void begin() { current_ = this; } + void end() { current_ = parent(); } + void add(UiWidget *); + void add(UiWidget &w) { add(&w); } + void insert(UiWidget &, UiWidget *); + void remove(UiWidget *); + void remove(UiWidget &w) { remove(&w); } + void clear(); + int children() const { return static_cast(children_.size()); } + UiWidget *child(int i) const { return children_.at(static_cast(i)); } + void resizable(UiWidget *w) { resizable_ = w; } + void resizable(UiWidget &w) { resizable_ = &w; } + UiWidget *resizable() const { return resizable_; } + int handle(int) override; + void init_sizes() {} + void draw_children() {} + void resize(int, int, int, int) override; + void draw() override; +}; +class UiWindow : public UiGroup +{ + bool modal_ = false; + int min_w_ = 100, min_h_ = 100; + + protected: + void userTriedToCloseWindow() override { do_callback(); } + + public: + UiWindow(int, int, const char * = nullptr); + UiWindow(int, int, int, int, const char * = nullptr); + ~UiWindow() override; + void show() override; + void show(int, char **) { show(); } + void hide() override; + void set_modal() { modal_ = true; } + void set_non_modal() { modal_ = false; } + void size_range(int w, int h, int = 0, int = 0) + { + min_w_ = w; + min_h_ = h; + } + int x() const { return getX(); } + int y() const { return getY(); } + void resize(int, int, int, int) override; + void resized() override; + void cursor(int c) + { + setMouseCursor(c == UI_CURSOR_WAIT ? juce::MouseCursor::WaitCursor + : juce::MouseCursor::NormalCursor); + } +}; +using UiDouble_Window = UiWindow; +class UiBox : public UiWidget +{ + public: + using UiWidget::UiWidget; +}; +class UiButton : public UiWidget +{ + juce::TextButton button_; + bool toggle_ = false; + + protected: + void sync_control() override; + + public: + UiButton(int, int, int, int, const char * = nullptr); + int value() const { return button_.getToggleState() ? 1 : 0; } + void value(int v) { button_.setToggleState(v != 0, juce::dontSendNotification); } + void set_toggle() + { + toggle_ = true; + button_.setClickingTogglesState(true); + } + void resized() override { button_.setBounds(getLocalBounds()); } + void draw() override { button_.setVisible(true); } + int handle(int e) override + { + if (e == UI_PUSH) + return 1; + if (e == UI_RELEASE) + { + if (toggle_) + value(!value()); + do_callback(); + return 1; + } + return UiWidget::handle(e); + } +}; +class UiCheck_Button : public UiButton +{ + public: + UiCheck_Button(int x, int y, int w, int h, const char *s = nullptr) : UiButton(x, y, w, h, s) + { + set_toggle(); + } +}; +using UiReturn_Button = UiButton; +class UiInput_ : public UiWidget +{ + mutable std::string text_; + + protected: + void sync_control() override; + + public: + juce::TextEditor editor; + UiInput_(int, int, int, int, const char * = nullptr); + void value(const char *); + const char *value() const; + void maximum_size(int n) { editor.setInputRestrictions(n); } + int take_focus() override + { + if (!isShowing() || !isEnabled()) + return 0; + editor.grabKeyboardFocus(); + return 1; + } + void resized() override { editor.setBounds(getLocalBounds()); } + void draw() override {} +}; +using UiInput = UiInput_; +class UiValue_Input : public UiWidget +{ + double value_ = 0, low_ = -1e12, high_ = 1e12, step_ = 0; + + protected: + void sync_control() override; + + public: + UiInput input; + UiValue_Input(int, int, int, int, const char * = nullptr); + void value(double); + double value() const { return value_; } + void bounds(double l, double h) + { + low_ = std::min(l, h); + high_ = std::max(l, h); + } + void range(double l, double h) { bounds(l, h); } + void step(double s) { step_ = s; } + void precision(int) {} + void soft(int) {} + void resized() override { input.resize(0, 0, w(), h()); } + void draw() override {} +}; +class UiValue_Slider : public UiWidget +{ + protected: + juce::Slider slider_; + double drag_value_ = 0; + int drag_x_ = 0, drag_y_ = 0; + bool dragging_ = false; + void sync_control() override; + + public: + UiValue_Slider(int, int, int, int, const char * = nullptr); + double value() const { return slider_.getValue(); } + void value(double v) { slider_.setValue(v, juce::dontSendNotification); } + void bounds(double, double); + void range(double l, double h) { bounds(l, h); } + int handle(int) override; + void step(double); + using UiWidget::type; + void type(int) override; + void slider(int) {} + void slider_size(double) {} + void resized() override { slider_.setBounds(getLocalBounds()); } + void draw() override {} +}; +class UiDial : public UiValue_Slider +{ + public: + using UiValue_Slider::UiValue_Slider; +}; +class UiProgress : public UiWidget +{ + double value_ = 0, low_ = 0, high_ = 1; + + public: + using UiWidget::UiWidget; + void minimum(double v) { low_ = v; } + void maximum(double v) { high_ = v; } + void value(double v) + { + value_ = v; + redraw(); + } + float value() const { return static_cast(value_); } + void draw() override; +}; +struct UiMenu_Item +{ + const char *text = nullptr; + int shortcut_ = 0; + UiCallback callback_ = nullptr; + void *user_data_ = nullptr; + int flags = 0; + int shortcut() const { return shortcut_; } + const char *label() const { return text; } + void set() { flags |= UI_MENU_VALUE; } + void clear() { flags &= ~UI_MENU_VALUE; } + bool active() const { return !(flags & UI_MENU_INACTIVE); } + bool submenu() const { return false; } + const UiMenu_Item *popup(int, int) const; +}; +class UiMenu_ : public UiWidget +{ + protected: + std::vector items_{UiMenu_Item{}}; + std::vector paths_, labels_; + int selected_ = -1; + bool literal_ = false; + void repair(); + void show_popup(bool bar = false); + + public: + using UiWidget::UiWidget; + int add(const char *, int = 0, UiCallback = nullptr, void * = nullptr, int = 0); + void replace(int, const char *); + void clear(); + void copy(const UiMenu_Item *); + int find_index(const char *s) const + { + auto *p = find_item(s); + return p ? static_cast(p - items_.data()) : -1; + } + void mode(int i, int flags) + { + if (i >= 0 && i < static_cast(labels_.size())) + items_[static_cast(i)].flags = flags; + } + int size() const { return static_cast(items_.size()); } + const UiMenu_Item *menu() const; + const UiMenu_Item *mvalue() const; + const UiMenu_Item *find_item(const char *) const; + const UiMenu_Item *picked(const UiMenu_Item *); + const UiMenu_Item *test_shortcut(); + int item_pathname(char *, int, const UiMenu_Item *) const; + void value(int n) + { + selected_ = n; + sync_control(); + redraw(); + } + int value() const { return selected_; } + const char *text() const { return mvalue() ? mvalue()->label() : nullptr; } + const char *text(int n) const + { + return n >= 0 && n < static_cast(items_.size()) + ? items_[static_cast(n)].label() + : nullptr; + } + int handle(int) override; + void draw() override; +}; +class UiMenu_Bar : public UiMenu_ +{ + public: + using UiMenu_::UiMenu_; + void draw() override; + int handle(int) override; +}; +class UiMenu_Button : public UiMenu_ +{ + public: + using UiMenu_::UiMenu_; +}; +class UiChoice : public UiMenu_ +{ + public: + using UiMenu_::UiMenu_; +}; +class UiInput_Choice : public UiGroup +{ + UiInput *input_; + UiMenu_Button *menu_; + + public: + UiInput_Choice(int, int, int, int, const char * = nullptr); + UiInput *input() { return input_; } + UiMenu_Button *menubutton() { return menu_; } + const char *value() const { return input_->value(); } + void value(const char *v) { input_->value(v); } +}; +class UiHold_Browser : public UiWidget, private juce::ListBoxModel +{ + juce::ListBox list_; + std::vector rows_; + int getNumRows() override { return static_cast(rows_.size()); } + void paintListBoxItem(int, juce::Graphics &, int, int, bool) override; + void selectedRowsChanged(int) override { do_callback(); } + void listBoxItemDoubleClicked(int, const juce::MouseEvent &) override + { + Ui::clicks = 1; + do_callback(); + Ui::clicks = 0; + } + + public: + UiHold_Browser(int, int, int, int, const char * = nullptr); + ~UiHold_Browser() override { list_.setModel(nullptr); } + void add(const char *s) + { + rows_.emplace_back(s ? s : ""); + list_.updateContent(); + } + void clear() + { + rows_.clear(); + list_.updateContent(); + } + int value() const { return list_.getSelectedRow() + 1; } + void select(int n) { list_.selectRow(n - 1); } + int size() const { return static_cast(rows_.size()); } + void format_char(int) {} + void textsize(int) {} + void resized() override { list_.setBounds(getLocalBounds()); } + void draw() override {} +}; +class UiScroll : public UiGroup +{ + juce::ScrollBar vertical_{true}, horizontal_{false}; + int sx_ = 0, sy_ = 0; + bool scrolling_ = false; + struct Listener : juce::ScrollBar::Listener + { + UiScroll &owner; + explicit Listener(UiScroll &o) : owner(o) {} + void scrollBarMoved(juce::ScrollBar *, double) override; + } listener_{*this}; + void update_bars(); + + public: + enum + { + HORIZONTAL_ALWAYS = 1 + }; + UiWidget scrollbar, hscrollbar; + UiScroll(int, int, int, int, const char * = nullptr); + void scroll_to(int, int); + int xposition() const { return sx_; } + int yposition() const { return sy_; } + void draw() override + { + UiGroup::draw(); + update_bars(); + } + void mouseWheelMove(const juce::MouseEvent &, const juce::MouseWheelDetails &) override; +}; +class UiTabs : public UiGroup +{ + UiWidget *selected_ = nullptr; + void update(); + + public: + using UiGroup::UiGroup; + void value(UiWidget *w) + { + selected_ = w; + update(); + } + UiWidget *value() + { + if (!selected_ && children()) + selected_ = child(0); + return selected_; + } + void draw() override; + int handle(int) override; +}; +class UiNative_File_Chooser +{ + std::string title_, filter_, filename_, directory_; + int type_ = 0; + + public: + enum + { + BROWSE_FILE = 0, + BROWSE_SAVE_FILE = 1, + SAVEAS_CONFIRM = 2 + }; + void title(const char *s) { title_ = s ? s : ""; } + void type(int n) { type_ = n; } + void filter(const char *s) { filter_ = s ? s : ""; } + void directory(const char *s) { directory_ = s ? s : ""; } + void preset_file(const char *s) { filename_ = s ? s : ""; } + void options(int) {} + int show(); + const char *filename() const { return filename_.empty() ? nullptr : filename_.c_str(); } + const char *errmsg() const { return "File chooser failed"; } +}; +#endif diff --git a/src/gui/JUCE/velocity_lane.hpp b/src/gui/JUCE/velocity_lane.hpp new file mode 100644 index 0000000..aec5c9d --- /dev/null +++ b/src/gui/JUCE/velocity_lane.hpp @@ -0,0 +1,73 @@ +#ifndef XTC_JUCE_GUI_VELOCITY_LANE_HPP +#define XTC_JUCE_GUI_VELOCITY_LANE_HPP +#include "piano_roll.hpp" +#include "theme.hpp" +#include +#include + +/* Fixed-height controller lane, separate from the vertically scrolled keys. + * X coordinates follow the piano widget, including its horizontal scroll. */ +class VelocityLane : public UiWidget { + PianoRollWidget &piano_; + uint64_t editing_ = UINT64_MAX; + unsigned int preview_ = 100; + const xtc_project *editing_project_ = nullptr; + uint64_t editing_clip_ = UINT64_MAX; + unsigned int value(int py) const { + return static_cast(std::clamp((y()+h()-8-py)*127/std::max(1,h()-34),1,127)); + } + public: + VelocityLane(int x, int y, int w, int h, PianoRollWidget &piano) + : UiWidget(x,y,w,h,"Note velocity"), piano_(piano) {} + void draw() override { + ui_push_clip(x(),y(),w(),h()); + ui_color(xtc_ui::background()); ui_rectf(x(),y(),w(),h()); + ui_color(xtc_ui::border()); ui_line(x(),y(),x()+w(),y()); + ui_font(UI_HELVETICA,11); ui_color(xtc_ui::muted()); + ui_draw("VELOCITY 1..127 / Select a note, then drag its bar or use the velocity slider",x()+8,y()+17); + size_t count = 0; xtc_project_note_count(piano_.project(),&count); + for (int pass = 0; pass < 2; ++pass) for (size_t i = 0; i < count; ++i) { + xtc_note_info note{}; xtc_project_get_note(piano_.project(),i,¬e); + if (!piano_.note_visible(note) || (note.id == piano_.selected_note()) != (pass == 1)) continue; + int px = piano_.note_x(note.start_tick); + unsigned int velocity = editing_ == note.id ? preview_ : note.velocity; + int top = y()+h()-8-static_cast(velocity)*(h()-34)/127; + ui_color(note.id == piano_.selected_note() ? xtc_ui::accent() : ui_rgb_color(95,137,172)); + ui_rectf(px-2,top,4,y()+h()-8-top); ui_rectf(px-4,top-2,8,4); + } + ui_pop_clip(); + } + int handle(int event) override { + if (event == UI_PUSH && Ui::event_button() == UI_LEFT_MOUSE && Ui::event_y() >= y()+24) { + take_focus(); editing_ = UINT64_MAX; + size_t count = 0; int distance = 9; + xtc_project_note_count(piano_.project(),&count); + for (size_t i = 0; i < count; ++i) { + xtc_note_info note{}; xtc_project_get_note(piano_.project(),i,¬e); + if (!piano_.note_visible(note)) continue; + int d = std::abs(Ui::event_x()-piano_.note_x(note.start_tick)); + if (d < distance || (d == distance && note.id == piano_.selected_note())) { + distance = d; editing_ = note.id; + } + } + if (editing_ != UINT64_MAX) { + editing_project_ = piano_.project(); editing_clip_ = piano_.clip(); + piano_.select_note(editing_); preview_ = value(Ui::event_y()); + } + redraw(); piano_.redraw(); return 1; + } + if (event == UI_DRAG && editing_ != UINT64_MAX) { + preview_ = value(Ui::event_y()); redraw(); return 1; + } + if (event == UI_RELEASE && editing_ != UINT64_MAX) { + uint64_t id = editing_; editing_ = UINT64_MAX; + if (editing_project_ == piano_.project() && editing_clip_ == piano_.clip()) + (void)piano_.set_velocity(id,value(Ui::event_y())); + redraw(); return 1; + } + if (event == UI_KEYDOWN && Ui::event_key() == UI_Escape) { editing_ = UINT64_MAX; redraw(); return 1; } + if (event == UI_FOCUS || event == UI_UNFOCUS) return 1; + return UiWidget::handle(event); + } +}; +#endif diff --git a/tests/integration/README.md b/tests/integration/README.md index 80646e3..064c8ea 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -254,3 +254,29 @@ It requires an existing JACK/PipeWire server, changes no user preferences and connects no physical outputs. Enable `XTC_ENABLE_LIVE_TESTS` to include `playback` in CTest. The fake-backend `gui-midi-workflows` test also checks absent input/output/both, returning devices and genuine backend failures. + +The optional JUCE workstation builds the shared GUI regression source against +`src/gui/JUCE` as `test_juce_gui`; `juce-gui-*` CTests cover the same retained +commands, fault injection, dialogs, hierarchy and gestures without FLTK. Build +all test fixtures before running them. The real-backend `test_juce_playback` +delivers JUCE MouseEvents to native transport buttons, resumes with a JUCE +KeyPress, and checks finite nonzero stereo samples through a private JACK probe +with missing saved MIDI devices. It writes no user preferences and connects no +physical ports. Run it from the repository root under Xvfb after building: + +```sh +cmake --build build/juce-gui --parallel 4 +ctest --test-dir build/juce-gui -R juce-gui --output-on-failure -j1 +xvfb-run -a ./build/juce-gui/test_juce_playback +``` + +`XTC_ENABLE_LIVE_TESTS` registers the latter as `juce-playback`. An optional +`XTC_JUCE_SCREENSHOT` absolute output path captures the actual workstation +component during that live regression. + +Linux qualification used JUCE 8.0.15: all 12 JUCE GUI workflows passed (with +focused reruns after native focus/blur fixes), alongside all 57 existing +FLTK/core tests. Native Play/Stop/Space, expanded-track rendering and finite +stereo output passed with deliberately unavailable saved MIDI devices. Bare +Xvfb can emit JUCE X11 BadAtom diagnostics without a window manager; these do +not indicate a failed assertion in the workflow checks. diff --git a/tests/integration/test_juce_playback.cpp b/tests/integration/test_juce_playback.cpp new file mode 100644 index 0000000..7cc693f --- /dev/null +++ b/tests/integration/test_juce_playback.cpp @@ -0,0 +1,240 @@ +/* Native JUCE GUI playback regression. Connects only to a private software probe; + * neither user preferences nor physical audio/MIDI routes are changed. */ +#include "../../src/gui/JUCE/main_window.hpp" +#include "../../src/gui/JUCE/sequencer_track_widget.hpp" +#include "../../src/gui/JUCE/sequencer_widget.hpp" +#include "../../src/gui/JUCE/ui.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define REQUIRE(c) \ + do \ + { \ + if (!(c)) \ + throw std::runtime_error(#c); \ + } while (0) +namespace +{ +struct Probe +{ + jack_client_t *client = nullptr; + jack_port_t *ports[2]{}; + std::atomic heard[2]{}; + std::atomic invalid{false}; + ~Probe() + { + if (client) + jack_client_close(client); + } + static int process(jack_nframes_t frames, void *data) + { + auto &probe = *static_cast(data); + for (int channel = 0; channel < 2; ++channel) + { + auto *samples = + static_cast(jack_port_get_buffer(probe.ports[channel], frames)); + for (jack_nframes_t i = 0; i < frames; ++i) + { + if (!std::isfinite(samples[i])) + probe.invalid.store(true, std::memory_order_relaxed); + if (std::fabs(samples[i]) > .001f) + probe.heard[channel].store(true, std::memory_order_relaxed); + } + } + return 0; + } +}; +struct TemporaryDirectory +{ + std::string path; + TemporaryDirectory() + { + char name[] = "/tmp/xtc-playback-XXXXXX"; + REQUIRE(mkdtemp(name)); + path = name; + } + ~TemporaryDirectory() { std::filesystem::remove_all(path); } +}; +UiWidget *find(UiGroup &group, const char *text, bool exact = true) +{ + for (int i = 0; i < group.children(); ++i) + { + auto *child = group.child(i); + if (child->label() && (exact ? !std::strcmp(child->label(), text) + : std::strstr(child->label(), text) != nullptr)) + return child; + if (auto *nested = dynamic_cast(child)) + if (auto *result = find(*nested, text, exact)) + return result; + } + return nullptr; +} +SequencerWidget *timeline(UiGroup &group) +{ + for (int i = 0; i < group.children(); ++i) + { + if (auto *s = dynamic_cast(group.child(i))) + return s; + if (auto *nested = dynamic_cast(group.child(i))) + if (auto *s = timeline(*nested)) + return s; + } + return nullptr; +} +void pump(int milliseconds) +{ + auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(milliseconds); + while (std::chrono::steady_clock::now() < end) + Ui::wait(.01); +} +void mouse_click(juce::Component *target, juce::Point point) +{ + REQUIRE(target); + auto now = juce::Time::getCurrentTime(); + juce::MouseEvent down(juce::Desktop::getInstance().getMainMouseSource(), point, + juce::ModifierKeys(juce::ModifierKeys::leftButtonModifier), 1, 0, 0, 0, 0, + target, target, now, point, now, 1, false); + target->mouseDown(down); + juce::MouseEvent up(juce::Desktop::getInstance().getMainMouseSource(), point, + juce::ModifierKeys(), 1, 0, 0, 0, 0, target, target, now, point, now, 1, + false); + target->mouseUp(up); +} +void click(MainWindow &, UiWidget *button) +{ + REQUIRE(button && button->active()); + // Deliver JUCE MouseEvents to the native TextButton child. + juce::Component *target = nullptr; + for (int i = 0; i < button->getNumChildComponents(); ++i) + if (dynamic_cast(button->getChildComponent(i))) + target = button->getChildComponent(i); + REQUIRE(target); + mouse_click(target, target->getLocalBounds().toFloat().getCentre()); +} + +} // namespace +int main() +{ + UiInitialiser initialise; + try + { + TemporaryDirectory directory; + xtc_settings settings{}; + xtc_settings_defaults(&settings); + std::strcpy(settings.midi_input_port, "xtc-missing-production-keyboard:out"); + std::strcpy(settings.midi_output_port, "xtc-missing-production-synth:in"); + std::snprintf(settings.client_name, sizeof(settings.client_name), "xtc-smoke-%ld", + static_cast(getpid())); + settings.main_x = 0; + settings.main_y = 0; + settings.width = 1280; + settings.height = 800; + settings.transport_detached = settings.sequencer_detached = settings.graph_detached = + settings.mixer_detached = 0; + settings.loop_enabled = 0; + settings.stop_after_tail = 0; + std::string config = directory.path + "/preferences.conf"; + REQUIRE(xtc_settings_save(&settings, config.c_str()) == XTC_OK); + Probe probe; + jack_status_t status{}; + probe.client = jack_client_open("xtc-playback-probe", JackNoStartServer, &status); + REQUIRE(probe.client); + probe.ports[0] = + jack_port_register(probe.client, "left", JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0); + probe.ports[1] = + jack_port_register(probe.client, "right", JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0); + REQUIRE(probe.ports[0] && probe.ports[1]); + REQUIRE(jack_set_process_callback(probe.client, Probe::process, &probe) == 0 && + jack_activate(probe.client) == 0); + MainWindow window(false, config.c_str()); + window.show(); + pump(100); + REQUIRE(window.open_project("examples/projects/playback-test.xtc") == XTC_OK); + auto *s = timeline(window); + REQUIRE(s); + click(window, find(window, "Play")); + pump(300); + if (!find(window, "running", false)) + { + if (auto *error = find(window, "Start failed", false)) + std::fprintf(stderr, "Observed: %s\n", error->label()); + } + REQUIRE(find(window, "running", false)); + REQUIRE(find(window, "MIDI device unavailable", false)); + for (int channel = 0; channel < 2; ++channel) + { + std::string source = + std::string(settings.client_name) + (channel ? ":output_right" : ":output"); + int code = -1; + for (int attempt = 0; attempt < 50; ++attempt) + { + code = jack_connect(probe.client, source.c_str(), + jack_port_name(probe.ports[channel])); + if (code == 0 || code == EEXIST) + break; + pump(10); + } + REQUIRE(code == 0 || code == EEXIST); + } + auto start = s->position(); + pump(700); + REQUIRE(s->position() > start); + REQUIRE(probe.heard[0].load() && probe.heard[1].load() && !probe.invalid.load()); + click(window, find(window, "Stop")); + pump(300); + auto paused = s->position(); + pump(200); + REQUIRE(s->position() == paused); + // Space resumes after a sequencer child has taken focus. + s->take_focus(); + REQUIRE(static_cast(s)->keyPressed(juce::KeyPress(' '))); + pump(300); + REQUIRE(s->position() > paused); + // A child at local (0,0) must paint consistently when composed into + // the full workstation; this catches retained-coordinate origin errors. + xtc_track_info track{}; + REQUIRE(xtc_project_get_track(s->project(), 0, &track) == XTC_OK); + auto *header = s->track_widget(track.id)->header(); + auto *menu = window.child(0); + REQUIRE(header->isShowing() && menu->isShowing()); + mouse_click(header, {14, 12}); + pump(60); + REQUIRE(s->track_expanded(track.id)); + auto menu_image = menu->createComponentSnapshot(menu->getLocalBounds()); + auto header_image = header->createComponentSnapshot(header->getLocalBounds()); + auto image = window.createComponentSnapshot(window.getLocalBounds()); + REQUIRE(image.getPixelAt(2, 2) == menu_image.getPixelAt(2, 2)); + REQUIRE(image.getPixelAt(header->x() + 5, header->y() + 5) == + header_image.getPixelAt(5, 5)); + if (const char *capture = std::getenv("XTC_JUCE_SCREENSHOT")) + { + juce::FileOutputStream stream{juce::File(capture)}; + REQUIRE(stream.openedOk()); + juce::PNGImageFormat png; + REQUIRE(png.writeImageToStream(image, stream)); + } + window.do_callback(); + REQUIRE(!window.shown()); + xtc_settings saved{}; + REQUIRE(xtc_settings_load(&saved, config.c_str()) == XTC_OK); + REQUIRE(!std::strcmp(saved.midi_input_port, settings.midi_input_port) && + !std::strcmp(saved.midi_output_port, settings.midi_output_port)); + std::puts("Live Play/Stop/Space and stereo playback passed with unavailable saved MIDI " + "devices; preferences retained."); + return 0; + } + catch (const std::exception &error) + { + std::fprintf(stderr, "Playback regression failed: %s\n", error.what()); + return 1; + } +} diff --git a/tools/install.py b/tools/install.py index f6aabde..37a2924 100644 --- a/tools/install.py +++ b/tools/install.py @@ -11,12 +11,13 @@ parser = argparse.ArgumentParser(description=__doc__) arguments(parser) parser.add_argument('--build-dir', type=Path, required=True) parser.add_argument('--gui', action='store_true') +parser.add_argument('--juce-gui', action='store_true') args = parser.parse_args() source = Path(__file__).resolve().parent.parent entries = [] for destination, original in inventory(source, layout(args)): name = destination.name - if name == 'BINARY_MANIFEST.json' or (not args.gui and name in ('xtc', 'xtc.desktop')): + if (name == 'xtc-juce' and not args.juce_gui) or name == 'BINARY_MANIFEST.json' or (not args.gui and name in ('xtc', 'xtc.desktop')): continue if original is None: original = args.build_dir / name @@ -32,7 +33,7 @@ for destination, original in entries: try: with os.fdopen(fd, 'wb') as output, original.open('rb') as input_file: shutil.copyfileobj(input_file, output) - os.fchmod(output.fileno(), 0o755 if destination.name in ('xtc', 'xtc-check') else 0o644) + os.fchmod(output.fileno(), 0o755 if destination.name in ('xtc', 'xtc-check', 'xtc-juce') else 0o644) os.replace(pending, destination) finally: Path(pending).unlink(missing_ok=True) diff --git a/tools/install_layout.py b/tools/install_layout.py index ff1ce27..7d11c4d 100644 --- a/tools/install_layout.py +++ b/tools/install_layout.py @@ -30,6 +30,7 @@ def directory(value): def inventory(source, paths): entries = [('libdir', 'libxtc.a', None), ('pkgconfigdir', 'xtc.pc', None), ('bindir', 'xtc-check', None), ('bindir', 'xtc', None), + ('bindir', 'xtc-juce', None), ('desktopdir', 'xtc.desktop', source / 'packaging/xtc.desktop'), ('licensedir', 'LICENSE', source / 'LICENSE'), ('licensedir', 'CLAP-LICENSE', source / 'third_party/clap/LICENSE'),