2026-10-26 22:51:25 +01:00
|
|
|
#include "main_window.hpp"
|
2026-10-26 23:55:35 +01:00
|
|
|
#include "graph_widget.hpp"
|
2026-10-27 00:10:45 +01:00
|
|
|
#include "sequencer_widget.hpp"
|
|
|
|
|
#include <FL/Fl_Native_File_Chooser.H>
|
2026-10-26 23:40:28 +01:00
|
|
|
#include "xtc/xtc_version.h"
|
2026-10-26 22:51:25 +01:00
|
|
|
|
2026-10-26 23:40:28 +01:00
|
|
|
#include <FL/Fl.H>
|
2026-10-26 22:51:25 +01:00
|
|
|
#include <FL/Fl_Box.H>
|
2026-10-26 23:40:28 +01:00
|
|
|
#include <FL/Fl_Button.H>
|
|
|
|
|
#include <FL/Fl_Group.H>
|
2026-10-26 23:55:35 +01:00
|
|
|
#include <FL/Fl_Scroll.H>
|
2026-10-26 22:51:25 +01:00
|
|
|
#include <FL/Fl_Menu_Bar.H>
|
|
|
|
|
#include <FL/Fl_Tabs.H>
|
2026-10-26 23:40:28 +01:00
|
|
|
#include <FL/fl_ask.H>
|
|
|
|
|
#include <cstdio>
|
|
|
|
|
#include <cstring>
|
2026-10-26 23:55:35 +01:00
|
|
|
#include <stdexcept>
|
2026-10-26 22:51:25 +01:00
|
|
|
|
2026-10-26 23:40:28 +01:00
|
|
|
namespace {
|
|
|
|
|
void set_label(Fl_Box *box, const char *text)
|
|
|
|
|
{
|
|
|
|
|
if (box->label() == nullptr || std::strcmp(box->label(), text) != 0)
|
|
|
|
|
box->copy_label(text);
|
|
|
|
|
}
|
2026-10-26 22:51:25 +01:00
|
|
|
}
|
|
|
|
|
|
2026-10-27 00:10:45 +01:00
|
|
|
MainWindow::MainWindow(bool prompt_unsaved)
|
|
|
|
|
: Fl_Double_Window(1000, 680, XTC_PROJECT_NAME " — modular audio workstation"),
|
|
|
|
|
prompt_unsaved_(prompt_unsaved)
|
2026-10-26 22:51:25 +01:00
|
|
|
{
|
2026-10-27 00:10:45 +01:00
|
|
|
xtc_project *initial = nullptr;
|
|
|
|
|
xtc_result result = xtc_project_create(&initial);
|
|
|
|
|
if (result != XTC_OK) throw std::runtime_error(xtc_result_string(result));
|
|
|
|
|
project_.reset(initial);
|
2026-10-26 22:51:25 +01:00
|
|
|
begin();
|
2026-10-26 23:40:28 +01:00
|
|
|
auto *menu = new Fl_Menu_Bar(0, 0, 1000, 28);
|
2026-10-27 00:10:45 +01:00
|
|
|
menu->add("File/New", FL_CTRL + 'n', file_callback, this);
|
|
|
|
|
menu->add("File/Open", FL_CTRL + 'o', file_callback, this);
|
|
|
|
|
menu->add("File/Save", FL_CTRL + 's', file_callback, this);
|
|
|
|
|
menu->add("File/Save As", FL_CTRL + FL_SHIFT + 's', file_callback, this);
|
2026-10-26 23:40:28 +01:00
|
|
|
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);
|
2026-10-26 22:51:25 +01:00
|
|
|
|
2026-10-26 23:40:28 +01:00
|
|
|
play_ = new Fl_Button(12, 40, 80, 30, "Play");
|
|
|
|
|
play_->callback(play_callback, this);
|
2026-10-27 00:10:45 +01:00
|
|
|
play_->tooltip("Start the audio engine (F5 or Space). Regions are silent until audio/MIDI phases.");
|
2026-10-26 23:40:28 +01:00
|
|
|
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,
|
2026-10-27 00:10:45 +01:00
|
|
|
"Edit signal flow in Modular Graph. No hardware ports are connected automatically; timeline regions are silent; recording is a future phase.");
|
2026-10-26 23:40:28 +01:00
|
|
|
hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
|
|
|
|
|
hint->labelsize(12);
|
2026-10-26 22:51:25 +01:00
|
|
|
|
2026-10-26 23:40:28 +01:00
|
|
|
tabs_ = new Fl_Tabs(8, 114, 984, 470);
|
|
|
|
|
tabs_->begin();
|
|
|
|
|
auto *sequencer = new Fl_Group(8, 140, 984, 444, "Sequencer");
|
2026-10-26 22:51:25 +01:00
|
|
|
sequencer->begin();
|
2026-10-27 00:10:45 +01:00
|
|
|
auto *add_track = new Fl_Button(20, 150, 100, 26, "Add track");
|
|
|
|
|
auto *add_region = new Fl_Button(128, 150, 110, 26, "Add region");
|
|
|
|
|
auto *delete_region = new Fl_Button(246, 150, 130, 26, "Delete region/track");
|
|
|
|
|
auto *zoom_in = new Fl_Button(384, 150, 80, 26, "Zoom +");
|
|
|
|
|
auto *zoom_out = new Fl_Button(472, 150, 80, 26, "Zoom -");
|
|
|
|
|
auto *timeline_hint = new Fl_Box(20, 548, 956, 28,
|
|
|
|
|
"Silent regions. Drag body to move, edges to trim; ruler seeks while stopped.");
|
|
|
|
|
timeline_hint->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE); timeline_hint->labelsize(12);
|
|
|
|
|
auto *timeline_scroll = new Fl_Scroll(20, 188, 956, 350);
|
|
|
|
|
sequencer_ = new SequencerWidget(20, 188, project_.get(), seek, this);
|
|
|
|
|
timeline_scroll->end();
|
|
|
|
|
sequencer_->callback([](Fl_Widget *, void *c) { static_cast<MainWindow *>(c)->mark_dirty(); }, this);
|
|
|
|
|
add_track->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->add_track(); }, sequencer_);
|
|
|
|
|
add_region->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->add_region(); }, sequencer_);
|
|
|
|
|
delete_region->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->delete_selection(); }, sequencer_);
|
|
|
|
|
zoom_in->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->zoom(1); }, sequencer_);
|
|
|
|
|
zoom_out->callback([](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->zoom(-1); }, sequencer_);
|
|
|
|
|
sequencer->resizable(timeline_scroll);
|
2026-10-26 22:51:25 +01:00
|
|
|
sequencer->end();
|
2026-10-26 23:40:28 +01:00
|
|
|
auto *mixer = new Fl_Group(8, 140, 984, 444, "Mixer");
|
2026-10-26 22:51:25 +01:00
|
|
|
mixer->begin();
|
2026-10-26 23:40:28 +01:00
|
|
|
new Fl_Box(24, 164, 940, 40, "Mixer placeholder — channels and meters are not implemented yet.");
|
2026-10-26 22:51:25 +01:00
|
|
|
mixer->end();
|
2026-10-26 23:40:28 +01:00
|
|
|
auto *graph = new Fl_Group(8, 140, 984, 444, "Modular Graph");
|
2026-10-26 22:51:25 +01:00
|
|
|
graph->begin();
|
2026-10-26 23:55:35 +01:00
|
|
|
graph_state_ = new Fl_Box(20, 182, 956, 24, "Graph: offline");
|
|
|
|
|
graph_state_->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE);
|
|
|
|
|
graph_state_->labelsize(12);
|
|
|
|
|
auto *feedback = new Fl_Box(20, 548, 956, 28,
|
|
|
|
|
"Add nodes. Drag output to input to connect; click a node or wire then Delete. Scroll to pan.");
|
|
|
|
|
feedback->align(FL_ALIGN_LEFT | FL_ALIGN_INSIDE | FL_ALIGN_WRAP);
|
|
|
|
|
feedback->labelsize(12);
|
|
|
|
|
auto *scroll = new Fl_Scroll(20, 212, 956, 328);
|
2026-10-27 00:10:45 +01:00
|
|
|
graph_widget_ = new GraphWidget(20, 212, 2000, 4000, xtc_project_graph(project_.get()), edit_graph, this, feedback);
|
2026-10-26 23:55:35 +01:00
|
|
|
scroll->end();
|
|
|
|
|
auto *oscillator = new Fl_Button(20, 150, 130, 26, "Add oscillator");
|
|
|
|
|
oscillator->callback([](Fl_Widget *, void *context) {
|
|
|
|
|
(void)static_cast<GraphWidget *>(context)->add_node(XTC_NODE_OSCILLATOR);
|
|
|
|
|
}, graph_widget_);
|
|
|
|
|
auto *gain = new Fl_Button(158, 150, 110, 26, "Add gain");
|
|
|
|
|
gain->callback([](Fl_Widget *, void *context) {
|
|
|
|
|
(void)static_cast<GraphWidget *>(context)->add_node(XTC_NODE_GAIN);
|
|
|
|
|
}, graph_widget_);
|
|
|
|
|
auto *meter = new Fl_Button(276, 150, 110, 26, "Add meter");
|
|
|
|
|
meter->callback([](Fl_Widget *, void *context) {
|
|
|
|
|
(void)static_cast<GraphWidget *>(context)->add_node(XTC_NODE_METER);
|
|
|
|
|
}, graph_widget_);
|
|
|
|
|
auto *output = new Fl_Button(394, 150, 110, 26, "Add output");
|
|
|
|
|
output->callback([](Fl_Widget *, void *context) {
|
|
|
|
|
(void)static_cast<GraphWidget *>(context)->add_node(XTC_NODE_OUTPUT);
|
|
|
|
|
}, graph_widget_);
|
|
|
|
|
auto *remove = new Fl_Button(512, 150, 130, 26, "Delete selection");
|
|
|
|
|
remove->callback([](Fl_Widget *, void *context) {
|
|
|
|
|
(void)static_cast<GraphWidget *>(context)->delete_selected();
|
|
|
|
|
}, graph_widget_);
|
|
|
|
|
graph->resizable(scroll);
|
2026-10-26 22:51:25 +01:00
|
|
|
graph->end();
|
2026-10-26 23:40:28 +01:00
|
|
|
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);
|
2026-10-26 22:51:25 +01:00
|
|
|
|
2026-10-26 23:40:28 +01:00
|
|
|
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);
|
2026-10-26 22:51:25 +01:00
|
|
|
end();
|
2026-10-26 23:40:28 +01:00
|
|
|
resizable(tabs_);
|
|
|
|
|
size_range(800, 540);
|
|
|
|
|
callback(close_callback, this);
|
|
|
|
|
open_backend();
|
|
|
|
|
Fl::add_timeout(0.1, timer_callback, this);
|
2026-10-26 22:51:25 +01:00
|
|
|
}
|
|
|
|
|
|
2026-10-26 23:40:28 +01:00
|
|
|
MainWindow::~MainWindow()
|
2026-10-26 22:51:25 +01:00
|
|
|
{
|
2026-10-26 23:40:28 +01:00
|
|
|
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];
|
2026-10-27 00:10:45 +01:00
|
|
|
const char *hint = std::strstr(operation, "Project") != nullptr ? "Check the project path, file format and write permissions." :
|
|
|
|
|
result == XTC_ERR_UNSUPPORTED ? "Rebuild with JACK development support." :
|
2026-10-26 23:40:28 +01:00
|
|
|
"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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-26 23:55:35 +01:00
|
|
|
xtc_result MainWindow::edit_graph(void *context, const xtc_graph_edit &edit, size_t *out_added_id)
|
|
|
|
|
{
|
|
|
|
|
auto *window = static_cast<MainWindow *>(context);
|
|
|
|
|
xtc_graph *candidate = nullptr;
|
|
|
|
|
size_t added_id = SIZE_MAX;
|
2026-10-27 00:10:45 +01:00
|
|
|
xtc_result result = xtc_graph_apply_edit(xtc_project_graph(window->project_.get()), &edit, &candidate, &added_id);
|
2026-10-26 23:55:35 +01:00
|
|
|
if (result != XTC_OK) return result;
|
2026-10-27 00:10:45 +01:00
|
|
|
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 && 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();
|
2026-10-26 23:55:35 +01:00
|
|
|
if (out_added_id != nullptr && edit.kind == XTC_EDIT_ADD_NODE) *out_added_id = added_id;
|
|
|
|
|
window->refresh_status();
|
|
|
|
|
return XTC_OK;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-26 23:40:28 +01:00
|
|
|
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);
|
2026-10-26 23:55:35 +01:00
|
|
|
else {
|
2026-10-27 00:10:45 +01:00
|
|
|
result = xtc_backend_submit(backend_, xtc_project_graph(project_.get()));
|
2026-10-26 23:55:35 +01:00
|
|
|
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_, "");
|
|
|
|
|
}
|
2026-10-26 23:40:28 +01:00
|
|
|
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));
|
2026-10-26 23:55:35 +01:00
|
|
|
set_label(graph_state_, "Graph: offline — edits stored locally; Reconnect to play");
|
2026-10-26 23:40:28 +01:00
|
|
|
} 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");
|
2026-10-26 23:55:35 +01:00
|
|
|
set_label(graph_state_, "Graph: engine status unavailable");
|
2026-10-26 23:40:28 +01:00
|
|
|
set_label(status_, "Unable to read JACK status — use Reconnect");
|
|
|
|
|
indicator_->labelcolor(fl_rgb_color(255, 155, 120));
|
|
|
|
|
} else {
|
2026-10-27 00:10:45 +01:00
|
|
|
uint64_t tick = 0;
|
|
|
|
|
if (xtc_project_sample_to_tick(project_.get(), status.sample_position, status.sample_rate, &tick) == XTC_OK)
|
|
|
|
|
sequencer_->position(tick);
|
2026-10-26 23:40:28 +01:00
|
|
|
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" :
|
2026-10-26 23:55:35 +01:00
|
|
|
running_ ? "Audio: running — modular graph" : "Audio: ready / stopped";
|
2026-10-26 23:40:28 +01:00
|
|
|
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);
|
2026-10-26 23:55:35 +01:00
|
|
|
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");
|
2026-10-26 23:40:28 +01:00
|
|
|
}
|
|
|
|
|
xtc_backend_collect(backend_);
|
|
|
|
|
}
|
|
|
|
|
if (can_play) play_->activate(); else play_->deactivate();
|
|
|
|
|
if (ready_ && running_) stop_->activate(); else stop_->deactivate();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool MainWindow::close_window()
|
|
|
|
|
{
|
2026-10-27 00:10:45 +01:00
|
|
|
if (!discard_changes()) return false;
|
2026-10-26 23:40:28 +01:00
|
|
|
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<MainWindow *>(context)->start_engine(); }
|
|
|
|
|
void MainWindow::stop_callback(Fl_Widget *, void *context)
|
|
|
|
|
{ static_cast<MainWindow *>(context)->stop_engine(); }
|
|
|
|
|
void MainWindow::reconnect_callback(Fl_Widget *, void *context)
|
|
|
|
|
{ static_cast<MainWindow *>(context)->reconnect_engine(); }
|
|
|
|
|
void MainWindow::close_callback(Fl_Widget *, void *context)
|
|
|
|
|
{ (void)static_cast<MainWindow *>(context)->close_window(); }
|
|
|
|
|
void MainWindow::view_callback(Fl_Widget *widget, void *view)
|
|
|
|
|
{
|
|
|
|
|
auto *window = static_cast<MainWindow *>(widget->window());
|
|
|
|
|
window->tabs_->value(static_cast<Fl_Widget *>(view));
|
|
|
|
|
window->tabs_->redraw();
|
|
|
|
|
}
|
|
|
|
|
void MainWindow::about_callback(Fl_Widget *, void *)
|
|
|
|
|
{
|
2026-10-27 00:10:45 +01:00
|
|
|
fl_message("%s %s\nModular audio workstation\nPhase 5: projects and silent arrangement regions; audio/MIDI content follows.",
|
2026-10-26 23:40:28 +01:00
|
|
|
XTC_PROJECT_NAME, XTC_VERSION_STRING);
|
|
|
|
|
}
|
|
|
|
|
void MainWindow::timer_callback(void *context)
|
|
|
|
|
{
|
|
|
|
|
auto *window = static_cast<MainWindow *>(context);
|
|
|
|
|
window->refresh_status();
|
|
|
|
|
if (!window->closing_) Fl::repeat_timeout(0.1, timer_callback, context);
|
2026-10-26 22:51:25 +01:00
|
|
|
}
|
2026-10-27 00:10:45 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
void MainWindow::mark_dirty()
|
|
|
|
|
{ dirty_ = true; copy_label("xtc * — unsaved changes"); }
|
|
|
|
|
bool MainWindow::discard_changes()
|
|
|
|
|
{
|
|
|
|
|
return !dirty_ || !prompt_unsaved_ ||
|
|
|
|
|
fl_choice("Discard unsaved project changes?", "Cancel", "Discard", nullptr) == 1;
|
|
|
|
|
}
|
|
|
|
|
xtc_result MainWindow::seek(void *context, uint64_t tick)
|
|
|
|
|
{
|
|
|
|
|
auto *window = static_cast<MainWindow *>(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;
|
|
|
|
|
}
|
|
|
|
|
xtc_result MainWindow::save_project(const char *path)
|
|
|
|
|
{
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
xtc_result MainWindow::install_project(xtc_project *candidate)
|
|
|
|
|
{
|
|
|
|
|
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 = xtc_backend_submit(backend_, xtc_project_graph(candidate));
|
|
|
|
|
}
|
|
|
|
|
if (r != XTC_OK) return r;
|
|
|
|
|
project_.reset(candidate);
|
|
|
|
|
graph_widget_->reset_layout();
|
|
|
|
|
graph_widget_->set_graph(xtc_project_graph(candidate));
|
|
|
|
|
sequencer_->set_project(candidate);
|
|
|
|
|
dirty_ = false;
|
|
|
|
|
refresh_status();
|
|
|
|
|
return XTC_OK;
|
|
|
|
|
}
|
|
|
|
|
xtc_result MainWindow::open_project(const char *path)
|
|
|
|
|
{
|
|
|
|
|
if (path == nullptr) return XTC_ERR_INVALID_ARGUMENT;
|
|
|
|
|
std::string loaded_path(path);
|
|
|
|
|
xtc_project *candidate = nullptr;
|
|
|
|
|
xtc_result r = xtc_project_create(&candidate);
|
|
|
|
|
if (r == XTC_OK) r = xtc_project_load(candidate, path);
|
|
|
|
|
if (r == XTC_OK) r = install_project(candidate);
|
|
|
|
|
if (r != XTC_OK) xtc_project_destroy(candidate);
|
|
|
|
|
else { path_ = loaded_path; copy_label(path_.c_str()); }
|
|
|
|
|
return r;
|
|
|
|
|
}
|
|
|
|
|
void MainWindow::file_callback(Fl_Widget *widget, void *context)
|
|
|
|
|
{
|
|
|
|
|
auto *window = static_cast<MainWindow *>(context);
|
|
|
|
|
auto *menu = static_cast<Fl_Menu_Bar *>(widget);
|
|
|
|
|
const char *action = menu->mvalue()->label();
|
|
|
|
|
xtc_result r = XTC_OK;
|
|
|
|
|
if (std::strcmp(action, "New") == 0) {
|
|
|
|
|
if (!window->discard_changes()) return;
|
|
|
|
|
xtc_project *candidate = nullptr;
|
|
|
|
|
r = xtc_project_create(&candidate);
|
|
|
|
|
if (r == XTC_OK) r = window->install_project(candidate);
|
|
|
|
|
if (r != XTC_OK) xtc_project_destroy(candidate);
|
|
|
|
|
else { window->path_.clear(); window->copy_label("xtc — Untitled"); }
|
|
|
|
|
} 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 {
|
|
|
|
|
Fl_Native_File_Chooser chooser;
|
|
|
|
|
chooser.title(open ? "Open project" : "Save project");
|
|
|
|
|
chooser.type(open ? Fl_Native_File_Chooser::BROWSE_FILE : Fl_Native_File_Chooser::BROWSE_SAVE_FILE);
|
|
|
|
|
chooser.filter("xtc projects *.xtc");
|
|
|
|
|
if (!open) chooser.options(Fl_Native_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("Project operation failed", r);
|
|
|
|
|
}
|