From 2ecc03b482f93ab539e6bed5e16572274e33096f Mon Sep 17 00:00:00 2001 From: hanez Date: Mon, 26 Oct 2026 23:40:28 +0100 Subject: [PATCH] Connect FLTK shell to JACK engine controls and status --- Makefile | 28 +++- README.md | 8 +- docs/ARCHITECTURE.md | 9 ++ docs/AUDIO_ENGINE.md | 5 +- docs/BUILD.md | 26 ++- docs/GUI.md | 46 ++++++ docs/ROADMAP.md | 8 +- src/gui/main.cpp | 21 ++- src/gui/main_window.cpp | 242 ++++++++++++++++++++++++---- src/gui/main_window.hpp | 38 ++++- tests/integration/README.md | 37 +++++ tests/integration/lsan.supp | 4 + tests/integration/test_gui.cpp | 204 +++++++++++++++++++++++ tests/integration/test_gui_jack.cpp | 60 +++++++ 14 files changed, 691 insertions(+), 45 deletions(-) create mode 100644 tests/integration/lsan.supp create mode 100644 tests/integration/test_gui.cpp create mode 100644 tests/integration/test_gui_jack.cpp diff --git a/Makefile b/Makefile index d378b4f..1d1343b 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ FLTK_LIBS := $(shell $(FLTK_CONFIG) --ldflags) GUI_AVAILABLE := yes endif -.PHONY: all clean test test-jack debug gui install +.PHONY: all clean test test-jack test-gui test-gui-jack debug gui install all: $(BUILD_DIR)/libxtc.a $(BUILD_DIR)/xtc-check $(BUILD_DIR)/xtc-check: $(BUILD_DIR)/src/main.o $(BUILD_DIR)/libxtc.a @@ -94,9 +94,31 @@ endif ifeq ($(GUI_AVAILABLE),yes) gui: $(BUILD_DIR)/xtc -$(BUILD_DIR)/xtc: $(BUILD_DIR)/src/gui/main.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/libxtc.a +$(BUILD_DIR)/test_gui: $(BUILD_DIR)/tests/integration/test_gui.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/libxtc.a $(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) -lm + +test-gui: $(BUILD_DIR)/test_gui + xvfb-run -a -s "-screen 0 1280x800x24" ./$(BUILD_DIR)/test_gui + +ifeq ($(JACK_AVAILABLE),yes) +$(BUILD_DIR)/test_gui_jack: $(BUILD_DIR)/tests/integration/test_gui_jack.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/libxtc.a + $(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) $(JACK_LIBS) -lm + +test-gui-jack: $(BUILD_DIR)/test_gui_jack + xvfb-run -a -s "-screen 0 1280x800x24" ./$(BUILD_DIR)/test_gui_jack else +test-gui-jack: + @echo "JACK development files are required for the live GUI test." + @exit 1 +endif + +$(BUILD_DIR)/xtc: $(BUILD_DIR)/src/gui/main.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/libxtc.a + $(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) $(JACK_LIBS) -lm +else +test-gui-jack test-gui: + @echo "FLTK development files are required for GUI tests." + @exit 1 + gui: @echo "FLTK was not found by pkg-config or fltk-config. GUI requires FLTK development files." @exit 1 @@ -111,4 +133,4 @@ clean: rm -rf "$(BUILD_DIR)" -include $(CORE_OBJECTS:.o=.d) $(TEST_OBJECT:.o=.d) $(BUILD_DIR)/src/main.d $(BUILD_DIR)/tests/unit/test_offline.d $(BUILD_DIR)/tests/unit/test_realtime.d $(BUILD_DIR)/tests/unit/test_backend.d \ - $(BUILD_DIR)/src/gui/main.d $(BUILD_DIR)/src/gui/main_window.d $(BUILD_DIR)/tests/integration/test_jack.d + $(BUILD_DIR)/src/gui/main.d $(BUILD_DIR)/src/gui/main_window.d $(BUILD_DIR)/tests/integration/test_jack.d $(BUILD_DIR)/tests/integration/test_gui.d $(BUILD_DIR)/tests/integration/test_gui_jack.d diff --git a/README.md b/README.md index 453a505..ce7cffb 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ This repository is a starter scaffold, not a production DAW. It currently provid - C tests for deterministic rendering, routing, validation and lifecycle; - an optional JACK-compatible mono backend with start/stop, routing and status; - safe control/audio plan handoff and concurrent replacement tests; -- an optional FLTK GUI shell; +- an optional FLTK shell with engine controls, view switching and backend status; - architecture, format, GUI, roadmap, and decision documents. The JACK backend has passed software-loopback testing; physical-device playback @@ -51,7 +51,11 @@ Run the GUI: ./build/xtc ``` -The current GUI is only a shell and is not connected to the backend yet. +The GUI opens JACK and offers Play/Stop/Reconnect, backend status and view +switching. Play starts the engine in silence; playback content and editors are +not implemented. F5 starts, F8 stops, Space toggles, F6 reconnects, and Ctrl+1/2/3 +switches views. Ctrl+Q closes the client and window. No physical ports are +connected automatically. See [GUI documentation](docs/GUI.md). With JACK development files and an existing JACK-compatible server, run the isolated software-loopback test with `make test-jack`. It connects only test clients. See [engine documentation](docs/AUDIO_ENGINE.md) for configuration, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 61aaebe..55b258a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -43,3 +43,12 @@ the optional JACK implementation drives that same realtime engine. Only acknowledged retired plans are reclaimed during processing. Backend close synchronizes callbacks before reclaiming remaining plans. See decision 0004 for the release/acquire protocol, bounded resource retention and recovery. + +## GUI backend ownership + +The Phase 3 window owns one C backend handle and serializes all control calls +on the GUI thread. A GUI timer reads snapshots and collects retired resources; +no audio callback calls FLTK. Successful window close first closes the JACK +client (synchronizing callbacks), then removes the GUI timer and hides the +window. Failed close leaves both handle and window available for retry. View +switching is GUI state only; editors have no domain mutations yet. See GUI.md. diff --git a/docs/AUDIO_ENGINE.md b/docs/AUDIO_ENGINE.md index 0db5df8..83750d4 100644 --- a/docs/AUDIO_ENGINE.md +++ b/docs/AUDIO_ENGINE.md @@ -131,5 +131,6 @@ internal realtime guarantees depend on the installed implementation/server. Validation commands and results are recorded in BUILD.md and the integration test README. Current limitations: mono input/output, no parameter ramping or crossfaded graph changes, no realtime meter bridge, no automatic reconnect, -no physical-device or long-duration endurance qualification. GUI controls and -status display are Phase 3; the GUI remains a placeholder shell. +no physical-device or long-duration endurance qualification. Phase 3 connects +the FLTK shell to backend controls and status; see GUI.md. Its editors remain +placeholders and the GUI submits no playback graph yet. diff --git a/docs/BUILD.md b/docs/BUILD.md index 608ac47..e1b94af 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -17,8 +17,8 @@ make gui `all` builds the static C library and startup check. `test` runs the existing core/offline tests, concurrent plan-handoff stress, JACK fault injection (when JACK headers are available), and the startup check. `debug` builds and tests separately in -`build/debug`. `gui` builds the existing placeholder shell; it does not produce -audio. Generated files remain under `build` and header changes trigger rebuilds. +`build/debug`. `gui` builds the FLTK shell with backend controls and status. +The shell starts a silent engine and never connects physical ports. Generated files remain under `build` and header changes trigger rebuilds. Compiler, flags, `BUILD_DIR`, `PKG_CONFIG`, `FLTK_CONFIG`, and installation `PREFIX` can be overridden through Make variables. C uses C99; GUI uses C++17. @@ -76,8 +76,24 @@ fault-injection tests cover those notification paths. No packages installed or system/device configuration changed. Sanitizers are not enabled in the live callback qualification run. +## Phase 3 verification + +```sh +make clean all test gui debug test-gui test-gui-jack +``` + +`test-gui` needs Xvfb and `xvfb-run`, uses a fake C backend and requires no JACK +server. `test-gui-jack` needs a running server; it creates no physical routes. +Both passed, including repeated active-client close and reconnect. The GUI also +builds without JACK metadata and reports unsupported backend support at startup. +A live keyboard/visual smoke run passed under Xvfb. Clang ASan/UBSan GUI tests +passed with a suppression limited to the installed fontconfig library's +exit-time font allocations; see tests/integration/README.md for the distinction +and command. No compiler warnings remain with GCC or Clang. Core/backend tests +pass with unsuppressed ASan/UBSan. No packages or system configuration changed. + ## Next milestone -Phase 3 connects the FLTK shell to the backend C API for engine controls, -status/error reporting and clean shutdown. Physical playback and endurance, -project persistence and sequencing remain unverified/unimplemented as applicable. +Phase 4 implements the modular graph editor and submits validated graph edits +through the C API. Physical playback/endurance, project persistence and +sequencing remain unverified/unimplemented as applicable. diff --git a/docs/GUI.md b/docs/GUI.md index 8ba1350..92727f9 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -16,3 +16,49 @@ from project mutation. Never call FLTK from the audio thread. Visual direction: compact, high-contrast, restrained dark interface. Avoid excessive animation and modal configuration. Recreate broad usability ideas, not energyXT's exact artwork or proprietary resources. + +## Phase 3 shell + +The shell opens JACK on startup through `xtc_backend.h`. It never accesses +backend structures, processes samples, creates domain objects, or calls JACK +itself. All backend control calls run on the GUI thread; FLTK is never called +from an audio callback. No server is started and no audio ports are connected +by the GUI. With no graph submitted, the running backend produces silence. + +- **Play / F5:** start the engine. **Stop / F8:** deactivate it. **Space:** toggle. + These are engine controls, not timeline playback or transport positioning. +- **Reconnect / F6:** close the old client and open a fresh stopped client. + Use this after server loss or sample-rate changes. A failed close preserves + the handle and reports the failure instead of opening another client. +- **View menu / Ctrl+1, Ctrl+2, Ctrl+3:** switch between sequencer, mixer and + modular graph. Tabs provide the same view switching. Editors remain clearly + labeled placeholders; no project editing is implied. +- **File / Quit / Ctrl+Q** or the window close button: close the backend and + synchronize audio callbacks before hiding/destroying widgets. A failed close + leaves the window open for a retry. Unsupported New/Open/Save actions are + visibly disabled. About identifies the development milestone and version. + +The indicator shows disconnected, ready, running, server lost, format changed +or process-error states. The status line shows rate, actual block size, mono +layout, processed block count, xruns and process result. Command errors remain +visible until a successful control action; periodic refresh does not erase them. +A GUI-owned 100 ms timer reads atomic snapshots and collects retired plans. +Label text changes only when needed. The timer is removed before successful +close and again by the window destructor. No GUI locks are held across control +calls. Operations such as opening JACK can briefly block the GUI; there is no +worker/control-thread framework in this initial shell. + +MainWindow owns the backend handle and FLTK owns the window's children. The +destructor also attempts cleanup on exceptional event-loop exit; if JACK close +itself fails then, it reports to stderr and retains the callback context until +process exit. Normal close/reconnect paths permit retry and release ownership +only after successful close. The entry point catches C++ exceptions before +process exit. Backend operations return C error codes throughout. + +Tests: `make test-gui` runs real widgets/callbacks/timers against a fake C backend +under Xvfb, including control errors, startup without JACK, partially initialized +handles, server/format notifications, view switching, retrying close, and timer +removal. `make test-gui-jack` additionally requires a running JACK-compatible +server, tests repeated start/stop, reconnect and active-client close, and creates +no routes. Physical playback is outside this shell. See BUILD.md and the +integration test README for sanitizer limitations and keyboard smoke results. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 31148f1..96b17a8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -26,12 +26,18 @@ format/xrun/loss status, start/stop/reopen, and bounded pending/retired plan handoff using lock-free atomics. Hardware-independent failure/concurrency tests and a 30-second software-loopback run pass. See decision 0004 and AUDIO_ENGINE.md. Physical hardware playback/endurance and live server-loss recovery remain -unverified; no GUI engine integration until Phase 3. +unverified; GUI engine controls are implemented in Phase 3. ## Phase 3 — FLTK shell Main window, transport controls, view switching, backend status, clean startup and shutdown through the C API. +Implemented: dark FLTK shell, Play/Stop/Reconnect, view/menu shortcuts, backend +state/format/xrun/process status, persistent command errors, timer-based refresh +and retirement collection, and callback-safe close/retry. Mock-backend GUI tests +and live GUI/JACK start/stop/reconnect/close tests pass under Xvfb. Editors and +project-file actions remain placeholders; engine runs in silence. See GUI.md. + ## Phase 4 — Modular editor Draw nodes/ports/edges; create, move and connect nodes; reject invalid edges; safely submit edits to the C core. diff --git a/src/gui/main.cpp b/src/gui/main.cpp index aa9bda5..395538d 100644 --- a/src/gui/main.cpp +++ b/src/gui/main.cpp @@ -1,10 +1,23 @@ #include - +#include +#include +#include #include "main_window.hpp" int main(int argc, char **argv) { - MainWindow window; - window.show(argc, argv); - return Fl::run(); + try { + Fl::scheme("flat"); + Fl::background(34, 36, 40); + Fl::background2(24, 26, 30); + Fl::foreground(230, 232, 236); + MainWindow window; + window.show(argc, argv); + return Fl::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/main_window.cpp b/src/gui/main_window.cpp index 34ab5c9..0d673b3 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -1,52 +1,240 @@ #include "main_window.hpp" +#include "xtc/xtc_version.h" +#include #include +#include +#include #include #include -#include +#include +#include +#include -extern "C" { -#include "xtc/xtc.h" +namespace { +void set_label(Fl_Box *box, const char *text) +{ + if (box->label() == nullptr || std::strcmp(box->label(), text) != 0) + box->copy_label(text); +} } MainWindow::MainWindow() - : Fl_Double_Window(1000, 680, "xtc — modular audio workstation") + : Fl_Double_Window(1000, 680, XTC_PROJECT_NAME " — modular audio workstation") { begin(); + auto *menu = new Fl_Menu_Bar(0, 0, 1000, 28); + menu->add("File/New (not implemented)", 0, nullptr, nullptr, FL_MENU_INACTIVE); + menu->add("File/Open (not implemented)", 0, nullptr, nullptr, FL_MENU_INACTIVE); + menu->add("File/Save (not implemented)", 0, nullptr, nullptr, FL_MENU_INACTIVE); + menu->add("File/Quit", FL_CTRL + 'q', close_callback, this); + menu->add("Audio/Start engine", FL_F + 5, play_callback, this); + menu->add("Audio/Stop engine", FL_F + 8, stop_callback, this); + menu->add("Audio/Reconnect", FL_F + 6, reconnect_callback, this); - Fl_Menu_Bar *menu = new Fl_Menu_Bar(0, 0, 1000, 28); - menu->add("File/New", 0, nullptr); - menu->add("File/Open...", 0, nullptr); - menu->add("File/Save", 0, nullptr); - menu->add("Help/About", 0, nullptr); + play_ = new Fl_Button(12, 40, 80, 30, "Play"); + play_->callback(play_callback, this); + play_->tooltip("Start the audio engine (F5 or Space). No sequencer content yet."); + stop_ = new Fl_Button(100, 40, 80, 30, "Stop"); + stop_->callback(stop_callback, this); + stop_->tooltip("Stop the audio engine (F8 or Space)."); + auto *reconnect = new Fl_Button(188, 40, 110, 30, "Reconnect"); + reconnect->callback(reconnect_callback, this); + reconnect->tooltip("Close and reopen JACK, leaving the engine stopped (F6)."); + indicator_ = new Fl_Box(316, 40, 672, 30, "Audio: disconnected"); + indicator_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); + indicator_->labelfont(FL_BOLD); + auto *hint = new Fl_Box(12, 78, 976, 24, + "Engine controls only. Playback content and recording are not implemented; no ports are connected automatically."); + hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); + hint->labelsize(12); - Fl_Tabs *tabs = new Fl_Tabs(8, 36, 984, 600); - tabs->begin(); - - Fl_Group *sequencer = new Fl_Group(8, 60, 984, 576, "Sequencer"); + tabs_ = new Fl_Tabs(8, 114, 984, 470); + tabs_->begin(); + auto *sequencer = new Fl_Group(8, 140, 984, 444, "Sequencer"); sequencer->begin(); - new Fl_Box(24, 84, 940, 40, - "Sequencer placeholder — timeline and clips are not implemented yet."); + new Fl_Box(24, 164, 940, 40, "Sequencer placeholder — timeline and clips are not implemented yet."); sequencer->end(); - - Fl_Group *mixer = new Fl_Group(8, 60, 984, 576, "Mixer"); + auto *mixer = new Fl_Group(8, 140, 984, 444, "Mixer"); mixer->begin(); - new Fl_Box(24, 84, 940, 40, - "Mixer placeholder — channels and meters are not implemented yet."); + new Fl_Box(24, 164, 940, 40, "Mixer placeholder — channels and meters are not implemented yet."); mixer->end(); - - Fl_Group *graph = new Fl_Group(8, 60, 984, 576, "Modular Graph"); + auto *graph = new Fl_Group(8, 140, 984, 444, "Modular Graph"); graph->begin(); - new Fl_Box(24, 84, 940, 40, - "Modular graph placeholder — the C graph API is the current prototype."); + new Fl_Box(24, 164, 940, 40, "Modular graph placeholder — visual graph editing is not implemented yet."); graph->end(); + tabs_->end(); + tabs_->value(sequencer); + menu->add("View/Sequencer", FL_CTRL + '1', view_callback, sequencer); + menu->add("View/Mixer", FL_CTRL + '2', view_callback, mixer); + menu->add("View/Modular Graph", FL_CTRL + '3', view_callback, graph); + menu->add("Help/About", 0, about_callback); - tabs->end(); + status_ = new Fl_Box(12, 594, 976, 26, "JACK: not connected"); + status_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); + status_->labelsize(12); + error_ = new Fl_Box(12, 624, 976, 44, ""); + error_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE | FL_ALIGN_WRAP); + error_->labelcolor(fl_rgb_color(255, 155, 120)); + error_->labelsize(12); end(); - resizable(tabs); + resizable(tabs_); + size_range(800, 540); + callback(close_callback, this); + open_backend(); + Fl::add_timeout(0.1, timer_callback, this); } -void MainWindow::show(int argc, char **argv) +MainWindow::~MainWindow() { - Fl_Double_Window::show(argc, argv); + Fl::remove_timeout(timer_callback, this); + // 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 retained until process exit.\n", stderr); +} + +void MainWindow::report_error(const char *operation, xtc_result result) +{ + char text[384]; + const char *hint = result == XTC_ERR_UNSUPPORTED ? "Rebuild with JACK development support." : + "Check the JACK-compatible server and use Reconnect to retry."; + std::snprintf(text, sizeof(text), "%s: %s. %s", operation, xtc_result_string(result), hint); + set_label(error_, text); +} + +void MainWindow::open_backend() +{ + xtc_backend_config config = {XTC_BACKEND_JACK, XTC_PROJECT_NAME, 65536U, 1U}; + 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 set_label(error_, ""); + refresh_status(); +} + +void MainWindow::start_engine() +{ + if (!ready_) { + report_error("Audio is disconnected; use Reconnect", XTC_ERR_INVALID_STATE); + return; + } + if (running_) return; + xtc_result result = xtc_backend_start(backend_); + if (result != XTC_OK) report_error("Start engine failed", result); + else set_label(error_, ""); + refresh_status(); +} + +void MainWindow::stop_engine() +{ + if (!ready_) return; + xtc_result result = xtc_backend_stop(backend_); + if (result != XTC_OK) report_error("Stop engine failed", result); + else set_label(error_, ""); + refresh_status(); +} + +void MainWindow::reconnect_engine() +{ + 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(); +} + +void MainWindow::refresh_status() +{ + bool can_play = false; + if (!ready_) { + set_label(indicator_, "Audio: disconnected"); + set_label(status_, "JACK: not connected — use Reconnect after starting the server"); + indicator_->labelcolor(fl_rgb_color(255, 155, 120)); + } else { + xtc_backend_status status = {}; + xtc_result result = xtc_backend_get_status(backend_, &status); + if (result != XTC_OK) { + report_error("Read engine status failed", result); + set_label(indicator_, "Audio: status unavailable"); + set_label(status_, "Unable to read JACK status — use Reconnect"); + indicator_->labelcolor(fl_rgb_color(255, 155, 120)); + } else { + running_ = status.running != 0; + const bool fault = status.server_lost || status.sample_rate_changed || status.process_result != XTC_OK; + can_play = !running_ && !fault; + 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" : + running_ ? "Audio: running (silence — no playback content)" : "Audio: ready / stopped"; + set_label(indicator_, state); + indicator_->labelcolor(fault ? fl_rgb_color(255, 155, 120) : + running_ ? fl_rgb_color(125, 225, 160) : fl_rgb_color(215, 215, 215)); + char text[256]; + std::snprintf(text, sizeof(text), "JACK | %u Hz | %u frames | mono | %u blocks | %u xruns | %s", + status.sample_rate, status.buffer_frames, status.processed_blocks, status.xruns, + xtc_result_string(status.process_result)); + set_label(status_, text); + } + xtc_backend_collect(backend_); + } + if (can_play) play_->activate(); else play_->deactivate(); + if (ready_ && running_) stop_->activate(); else stop_->deactivate(); +} + +bool MainWindow::close_window() +{ + 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; + closing_ = true; + Fl::remove_timeout(timer_callback, this); + hide(); + return true; +} + +int MainWindow::handle(int event) +{ + if (event == FL_SHORTCUT && Fl::event_key() == ' ' && + (Fl::event_state() & (FL_CTRL | FL_ALT | FL_META)) == 0) { + if (running_) stop_engine(); else start_engine(); + return 1; + } + return Fl_Double_Window::handle(event); +} +void MainWindow::play_callback(Fl_Widget *, void *context) +{ static_cast(context)->start_engine(); } +void MainWindow::stop_callback(Fl_Widget *, void *context) +{ static_cast(context)->stop_engine(); } +void MainWindow::reconnect_callback(Fl_Widget *, void *context) +{ static_cast(context)->reconnect_engine(); } +void MainWindow::close_callback(Fl_Widget *, void *context) +{ (void)static_cast(context)->close_window(); } +void MainWindow::view_callback(Fl_Widget *widget, void *view) +{ + auto *window = static_cast(widget->window()); + window->tabs_->value(static_cast(view)); + window->tabs_->redraw(); +} +void MainWindow::about_callback(Fl_Widget *, void *) +{ + fl_message("%s %s\nModular audio workstation\nPhase 3: JACK engine controls; editors remain placeholders.", + XTC_PROJECT_NAME, XTC_VERSION_STRING); +} +void MainWindow::timer_callback(void *context) +{ + auto *window = static_cast(context); + window->refresh_status(); + if (!window->closing_) Fl::repeat_timeout(0.1, timer_callback, context); } diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index ad107e9..63f51d2 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -2,11 +2,47 @@ #define XTC_MAIN_WINDOW_HPP #include +#include "xtc/xtc_backend.h" + +class Fl_Box; +class Fl_Button; +class Fl_Tabs; class MainWindow : public Fl_Double_Window { public: MainWindow(); - void show(int argc, char **argv); + ~MainWindow() override; + MainWindow(const MainWindow &) = delete; + MainWindow &operator=(const MainWindow &) = delete; + using Fl_Double_Window::show; + int handle(int event) override; + +private: + xtc_backend *backend_ = nullptr; + bool ready_ = false; + bool running_ = false; + bool closing_ = false; + Fl_Button *play_ = nullptr; + Fl_Button *stop_ = nullptr; + Fl_Box *indicator_ = nullptr; + Fl_Box *status_ = nullptr; + Fl_Box *error_ = nullptr; + Fl_Tabs *tabs_ = nullptr; + + void open_backend(); + void start_engine(); + void stop_engine(); + void reconnect_engine(); + void refresh_status(); + bool close_window(); + void report_error(const char *operation, xtc_result result); + static void play_callback(Fl_Widget *, void *context); + static void stop_callback(Fl_Widget *, void *context); + static void reconnect_callback(Fl_Widget *, void *context); + static void close_callback(Fl_Widget *, void *context); + static void view_callback(Fl_Widget *widget, void *view); + static void about_callback(Fl_Widget *, void *); + static void timer_callback(void *context); }; #endif diff --git a/tests/integration/README.md b/tests/integration/README.md index d6751e4..8458bed 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -26,3 +26,40 @@ format change is performed on the shared server. Those paths, missing server, port/callback setup failure, activation/deactivation/close failure, null buffers, invalid configuration, oversize buffers, and reopen are tested with injected JACK callbacks/results in `tests/unit/test_backend.c`. + +## FLTK shell tests + +```sh +make gui test-gui +# Optional, requires a running JACK-compatible server: +make test-gui-jack +``` + +These targets require FLTK development files, `xvfb-run` and Xvfb. No additional +GUI test library is needed. `test_gui.cpp` uses real widgets, menus and timers +with a fake public C backend. It covers Play/Stop/Reconnect, view switching, +persistent startup/control errors, server loss/rate changes, partial handles, +close-failure retry and timer removal. All 18 opened handles are closed. +`test_gui_jack.cpp` uses the real backend, repeats start/stop nine times across +three windows, reconnects, and closes each window while running. No audio ports +are connected. Both tests passed on the inspected host. + +A separate xdotool smoke run against `build/xtc` under a 1280x800 Xvfb display +exercised F5/F8/F6, Space, Ctrl+1/2/3 and Ctrl+Q. The app exited successfully; +visual inspection confirmed running status and mixer view switching. + +Clang ASan/UBSan found no application errors in the GUI tests. The unsuppressed +run reports exit-time allocations originating in the installed fontconfig +library used by FLTK/Pango. `lsan.supp` narrowly suppresses that library; leak +checking stays enabled for application allocations. This is not a claim that +the third-party font stack is leak-free. Apply only to GUI sanitizer tests: + +```sh +LSAN_OPTIONS=suppressions=tests/integration/lsan.supp xvfb-run -a -s '-screen 0 1280x800x24' ./build/gui-sanitize/test_gui +``` + +Build that binary with Clang/Clang++, `-fsanitize=address,undefined` in both +CFLAGS/CXXFLAGS and LDFLAGS, using the baseline warning/include/standard flags +from BUILD.md. Keep FLTK's `-I` and `-D` flags from `fltk-config --cxxflags`, +but exclude the distribution's GCC-specific LTO/object-code flags for Clang. +No suppressions are used for core/backend/realtime sanitizer tests. diff --git a/tests/integration/lsan.supp b/tests/integration/lsan.supp new file mode 100644 index 0000000..68ffda1 --- /dev/null +++ b/tests/integration/lsan.supp @@ -0,0 +1,4 @@ +# Installed FLTK/Pango/fontconfig retain font caches at process exit. +# Keep leak detection active for application allocations and backend handles. +# The unsuppressed GUI run reports allocation stacks in libfontconfig.so.1. +leak:libfontconfig.so diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp new file mode 100644 index 0000000..ed94c4a --- /dev/null +++ b/tests/integration/test_gui.cpp @@ -0,0 +1,204 @@ +#include "../../src/gui/main_window.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Fake only the public C backend boundary. Real FLTK widgets, callbacks and + * timers run under Xvfb; no server/device is required or touched. */ +struct xtc_backend { int running = 0; }; +namespace { +xtc_backend handle; +xtc_result open_result = XTC_OK, start_result = XTC_OK, stop_result = XTC_OK, close_result = XTC_OK; +xtc_result status_result = XTC_OK; +xtc_backend_status engine_status = {}; +bool partial_open = false; +unsigned int opens = 0, starts = 0, stops = 0, closes = 0, collects = 0, alive = 0; +int failures = 0; +#define CHECK(c) do { if (!(c)) { std::fprintf(stderr,"FAIL %s:%d: %s\n",__FILE__,__LINE__,#c); ++failures; } } while (0) +void pump() +{ + const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(180); + while (std::chrono::steady_clock::now() < end) Fl::wait(0.02); +} +Fl_Widget *find(Fl_Group *group, const char *label) +{ + for (int i = 0; i < group->children(); ++i) { + Fl_Widget *child = group->child(i); + if (child->label() != nullptr && std::strcmp(child->label(), label) == 0) return child; + auto *nested = dynamic_cast(child); + if (nested != nullptr) if (auto *match = find(nested, label)) return match; + } + return nullptr; +} +bool contains(Fl_Group *group, const char *text) +{ + for (int i = 0; i < group->children(); ++i) { + Fl_Widget *child = group->child(i); + if (child->label() != nullptr && std::strstr(child->label(), text) != nullptr) return true; + if (auto *nested = dynamic_cast(child)) if (contains(nested, text)) return true; + } + return false; +} +void press(Fl_Widget *widget) +{ + CHECK(widget != nullptr); + if (widget != nullptr) widget->do_callback(); +} +void reset() +{ + CHECK(alive == 0U); + open_result = start_result = stop_result = close_result = status_result = XTC_OK; + engine_status = {}; + engine_status.sample_rate = 48000U; + engine_status.buffer_frames = 1024U; + partial_open = false; +} +} +extern "C" { +xtc_result xtc_backend_open(const xtc_backend_config *config, xtc_backend **out) +{ + CHECK(config->kind == XTC_BACKEND_JACK && config->channel_count == 1U); + ++opens; + *out = open_result == XTC_OK || partial_open ? &handle : nullptr; + if (*out != nullptr) { ++alive; handle.running = 0; } + return open_result; +} +xtc_result xtc_backend_close(xtc_backend *backend) +{ + if (backend == nullptr) return XTC_OK; + ++closes; + if (close_result == XTC_OK) { CHECK(alive == 1U); --alive; handle.running = 0; } + return close_result; +} +xtc_result xtc_backend_start(xtc_backend *backend) +{ CHECK(backend == &handle); ++starts; if (start_result == XTC_OK) handle.running = 1; return start_result; } +xtc_result xtc_backend_stop(xtc_backend *backend) +{ CHECK(backend == &handle); ++stops; if (stop_result == XTC_OK) handle.running = 0; return stop_result; } +xtc_result xtc_backend_get_status(const xtc_backend *backend, xtc_backend_status *status) +{ + CHECK(backend == &handle && alive == 1U); + *status = engine_status; + status->running = handle.running && !status->server_lost; + return status_result; +} +void xtc_backend_collect(xtc_backend *backend) +{ CHECK(backend == &handle && alive == 1U); ++collects; } +} +int main(int argc, char **argv) +{ + reset(); + { + MainWindow window; + window.show(argc, argv); + auto *play = find(&window, "Play"); + auto *stop = find(&window, "Stop"); + auto *reconnect = find(&window, "Reconnect"); + CHECK(play != nullptr && stop != nullptr && reconnect != nullptr); + if (play == nullptr || stop == nullptr || reconnect == nullptr) return 1; + CHECK(play->active() && !stop->active()); + CHECK(contains(&window, "ready / stopped")); + press(play); + CHECK(starts == 1U && !play->active() && stop->active()); + CHECK(contains(&window, "running (silence")); + press(stop); + CHECK(stops == 1U && play->active() && !stop->active()); + start_result = XTC_ERR_IO; + press(play); + CHECK(contains(&window, "Start engine failed: I/O error")); + pump(); // A refresh must not erase a command error. + CHECK(contains(&window, "Start engine failed: I/O error")); + start_result = XTC_OK; + press(play); + CHECK(!contains(&window, "Start engine failed")); + stop_result = XTC_ERR_IO; + press(stop); + CHECK(contains(&window, "Stop engine failed") && stop->active()); + stop_result = XTC_OK; + press(stop); + Fl_Menu_Bar *menu = nullptr; + Fl_Tabs *tabs = nullptr; + for (int i = 0; i < window.children(); ++i) { + if (auto *found = dynamic_cast(window.child(i))) menu = found; + if (auto *found = dynamic_cast(window.child(i))) tabs = found; + } + CHECK(menu != nullptr && tabs != nullptr); + if (menu == nullptr || tabs == nullptr) return 1; + for (const char *view : {"View/Mixer", "View/Modular Graph", "View/Sequencer"}) { + const Fl_Menu_Item *item = menu->find_item(view); + CHECK(item != nullptr); + if (item != nullptr) { + menu->picked(item); + CHECK(std::strcmp(tabs->value()->label(), std::strchr(view, '/') + 1) == 0); + } + } + engine_status.xruns = 3U; + engine_status.processed_blocks = 123U; + pump(); + CHECK(contains(&window, "123 blocks | 3 xruns")); + engine_status.sample_rate_changed = 1; + pump(); + CHECK(contains(&window, "sample rate changed") && !play->active()); + engine_status.sample_rate_changed = 0; + engine_status.server_lost = 1; + pump(); + CHECK(contains(&window, "server lost") && !play->active()); + engine_status.server_lost = 0; + press(reconnect); + CHECK(contains(&window, "ready / stopped") && play->active()); + status_result = XTC_ERR_IO; + pump(); + CHECK(contains(&window, "status unavailable") && !play->active()); + status_result = XTC_OK; + press(reconnect); + close_result = XTC_ERR_IO; + press(reconnect); + CHECK(contains(&window, "Reconnect could not close JACK") && alive == 1U); + window.do_callback(); + CHECK(window.shown() && alive == 1U && contains(&window, "window kept open for retry")); + close_result = XTC_OK; + menu->picked(menu->find_item("File/Quit")); + CHECK(!window.shown() && alive == 0U); + unsigned int before = collects; + pump(); + CHECK(collects == before); // Removed timer cannot call a closed backend. + } + for (xtc_result failure : {XTC_ERR_IO, XTC_ERR_UNSUPPORTED}) { + reset(); + open_result = failure; + MainWindow window; + window.show(argc, argv); + CHECK(contains(&window, "Open JACK failed") && !find(&window, "Play")->active()); + open_result = XTC_OK; + press(find(&window, "Reconnect")); + CHECK(find(&window, "Play")->active()); + press(find(&window, "Play")); + window.do_callback(); // Close while running, without a preceding stop. + CHECK(!window.shown() && alive == 0U); + } + reset(); + partial_open = true; + open_result = XTC_ERR_IO; + { + MainWindow window; + CHECK(alive == 1U && !find(&window, "Play")->active()); + window.do_callback(); // Partially initialized handles are closed only. + CHECK(alive == 0U); + } + reset(); + for (int i = 0; i < 10; ++i) { + MainWindow window; + press(find(&window, "Play")); + window.do_callback(); + } + CHECK(alive == 0U); + if (failures != 0) return 1; + std::printf("GUI controls/errors/views/timers/cleanup passed: %u opens, %u closes.\n", opens, closes); + return 0; +} diff --git a/tests/integration/test_gui_jack.cpp b/tests/integration/test_gui_jack.cpp new file mode 100644 index 0000000..eb9aee3 --- /dev/null +++ b/tests/integration/test_gui_jack.cpp @@ -0,0 +1,60 @@ +#include "../../src/gui/main_window.hpp" +#include +#include +#include +#include +#include + +namespace { +Fl_Widget *find(Fl_Group &group, const char *label) +{ + for (int i = 0; i < group.children(); ++i) { + Fl_Widget *child = group.child(i); + if (child->label() != nullptr && std::strstr(child->label(), label) != nullptr) return child; + if (auto *nested = dynamic_cast(child)) if (auto *match = find(*nested, label)) return match; + } + return nullptr; +} +void pump() +{ + const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(400); + while (std::chrono::steady_clock::now() < end) Fl::wait(0.02); +} +} +#define CHECK(c) do { if (!(c)) { std::fprintf(stderr,"FAIL %s:%d: %s\n",__FILE__,__LINE__,#c); return 1; } } while (0) +int main(int argc, char **argv) +{ + // Real JACK backend and real FLTK event loop. No routes, no playback data. + for (int iteration = 0; iteration < 3; ++iteration) { + MainWindow window; + window.show(argc, argv); + CHECK(find(window, "ready / stopped") != nullptr); + auto *play = find(window, "Play"); + auto *stop = find(window, "Stop"); + auto *reconnect = find(window, "Reconnect"); + CHECK(play != nullptr && stop != nullptr && reconnect != nullptr); + for (int restart = 0; restart < 3; ++restart) { + CHECK(play->active()); + play->do_callback(); + pump(); + CHECK(find(window, "running (silence") != nullptr); + auto *status = find(window, "JACK |"); + CHECK(status != nullptr); + unsigned int rate = 0U, frames = 0U, blocks = 0U; + CHECK(std::sscanf(status->label(), "JACK | %u Hz | %u frames | mono | %u blocks", &rate, &frames, &blocks) == 3); + CHECK(rate > 0U && frames > 0U && blocks > 0U); + stop->do_callback(); + CHECK(find(window, "ready / stopped") != nullptr); + } + reconnect->do_callback(); + CHECK(find(window, "ready / stopped") != nullptr); + play->do_callback(); + pump(); + CHECK(find(window, "running (silence") != nullptr); + window.do_callback(); // Close with an active client; synchronizes callbacks. + CHECK(!window.shown()); + pump(); // No timer may access the closed client. + } + std::puts("Live GUI/JACK start/stop/reconnect/active-close tests passed."); + return 0; +}