Phase 27: strengthen undo transactions and improve editor workflow

This commit is contained in:
Johannes Findeisen 2026-10-27 16:49:30 +01:00
commit c4daef7c42
21 changed files with 460 additions and 27 deletions

View file

@ -140,7 +140,7 @@ ifeq ($(GUI_AVAILABLE),yes)
gui: $(BUILD_DIR)/xtc
$(BUILD_DIR)/test_gui: $(BUILD_DIR)/tests/integration/test_gui.o $(BUILD_DIR)/src/gui/main_window.o $(BUILD_DIR)/src/gui/graph_widget.o $(BUILD_DIR)/src/gui/sequencer_widget.o $(BUILD_DIR)/src/gui/piano_roll.o $(BUILD_DIR)/src/gui/mixer_widget.o $(BUILD_DIR)/src/gui/plugin_dialog.o $(BUILD_DIR)/libxtc.a
$(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) -lm -ldl
$(CXX) $(LDFLAGS) -o $@ $^ $(FLTK_LIBS) -lm -ldl -Wl,--wrap=xtc_history_reserve
test-gui: $(BUILD_DIR)/test_gui
xvfb-run -a -s "-screen 0 1280x800x24" ./$(BUILD_DIR)/test_gui

View file

@ -149,3 +149,7 @@ reset, transactional replacement and insertion into graph wires. See
Phase 26 adds common parameter targets, Linear/Hold mixer automation and saved
per-channel live MIDI CC bindings. Project v10 reads v1–v9. See
[CONTROL.md](docs/CONTROL.md) for timing, precedence and qualification limits.
Phase 27 reserves undo storage before edits/publication, groups completed takes,
preserves surviving editor selections and improves context menus/focus feedback.
See [EDITING.md](docs/EDITING.md) for the workflow audit and failure boundaries.

115
docs/EDITING.md Normal file
View file

@ -0,0 +1,115 @@
# Editing commands and undo (Phase 27 audit)
The control thread owns project edits and history. Audio plans are immutable
snapshots; widgets never pass editable project pointers into the callback.
Graph commands and batch note commands use the existing public C command APIs.
The GUI groups a completed gesture or accepted dialog as one history step.
## Operation audit
| Operation | History / grouping | Engine and external effects |
| --- | --- | --- |
| Track create/delete/name/order; clip create/delete/move/trim/name | One accepted command/drag | Stops playback before mutation; track removal cascades clips/notes/lanes, never files |
| MIDI clip creation and default source routing | One command, including automatic route | Core candidate graph is validated before model adoption |
| Audio duplicate/split/gain/fades | One command including copied metadata/notes | Source WAV is shared; split does not rewrite samples |
| Note add/delete/drag/resize, filtered quantize/transpose/properties/repeat | One gesture/batch, including fresh note IDs | Stops playback; validates all matched notes/capacity before applying |
| Graph add/remove/connect/disconnect/insert gain | One graph command | Candidate and history storage prepared before engine submission; active graph remains immutable |
| Mixer gain/pan/mute/solo, bus create/route, automation points | One released slider/command/point | Stops playback; bus deletion reroutes members without deleting their media |
| Plugin add/replace/control/reset/automation/insert | One accepted command/dialog including wiring | Candidate compiles before installation; old module instances retire on control thread |
| MIDI controller bindings | One channel-map command | Rebuilds immutable MIDI plan; no callback mutation |
| Import/relink audio | One accepted edit, including newly needed track/routes | Import candidate prepared before adoption; source media never copied/deleted by undo |
| Recording start/stop | Start has no document history step; completed take adds one | Undo removes take reference, never finalized WAV; failed add retains saved take for manual import |
| Save / Save As | No history step; retains existing history | Writes document; undo does not restore earlier on-disk file versions |
| New / Open | Fresh baseline after unsaved-change handling | Parse/prepare failure retains old document/history; engine may already be stopped/reset after preparation failure |
| Transport, panic, external connections/reconnect | Not document history | Undo does not reverse playback, live voices, device connections or capture side effects |
| Selection, tabs, snap/grid, zoom/scroll, node layout, meters/reset peaks | Session UI state, no history | No music or media changes |
No arbitrary cross-command grouping or automation recording mode is introduced.
An accepted no-op model edit can still consume a step. History holds 32 edits
plus a baseline; new accepted edits discard redo, and eviction drops the oldest
baseline. Monotonic ID high-water marks survive undo and branches. History is
session-only; restart/New/Open discards it.
## History ownership and failure boundary
`xtc_history_reserve` allocates one empty fixed-capacity project/graph snapshot
before an edit mutates the document or a candidate is submitted to the engine.
Failure aborts the command without changing history or dropping redo. The call
is idempotent: storage left by a rejected command is reused. It neither records
an edit nor moves the cursor. GUI arrangement/note/mixer guards reserve before
stopping playback; candidate graph/plugin/map/take paths reserve before
publication. File/media work and compilation can still fail before adoption.
The next `xtc_history_record` copies model arrays and the bounded 64-node/4096-
edge graph into reserved storage without allocating. Successful record takes
ownership of that storage, remembers IDs, then releases old redo/evicted states.
The snapshot contains metadata/graph configuration only, never sample buffers,
DSP instances, module handles or GUI pointers. An unused reservation is freed
with history. This adds at most one pending metadata snapshot to the 33 retained
states. C clients that call record without reserving retain the original
clone-and-report-allocation-failure behavior; they must handle that result.
All history work is serialized on the control thread, never the audio callback.
Undo/redo checks out an owned candidate without moving the cursor, stops/resets
transport and prepares its assets/plans, then accepts the adjacent history step
only after installation succeeds. Failed checkout/preparation retains the
cursor/document, so retry is possible. Preparation failure may leave transport
stopped at zero; it does not resume playback automatically. Snapshot restoration
marks the document dirty conservatively, even if it matches a saved file.
## Selection, focus and menus
Ctrl+Z undoes; Ctrl+Shift+Z and Ctrl+Y redo. The main Edit menu disables unavailable
steps and disables history while recording. Piano roll also has Edit/Undo/Redo
shortcuts. Undo preserves currently selected track/clip, mixer strip, graph node
and piano-roll clip/note when their stable IDs still exist (notes must remain
visible under the current filter). Clip selection follows its restored track. A removed selection
clears, with track fallback for a removed clip. A visible piano roll stays open
when its clip survives; missing clips hide it. Historical selections/layouts are
not saved in snapshots; undo does not resurrect a selection already cleared by
a deletion. Open/New retain their reset behavior.
Editor-local shortcuts act on focused editors. Text inputs retain Space and
text undo/editing keys; numeric mixer inputs do not invoke fader shortcuts.
Timeline Ctrl+D duplicates, F2 renames, Alt+Up/Down reorders tracks, S splits
audio at the cursor, A opens audio properties and Delete removes the selection.
Graph Left/Right chooses a node, L arranges by flow, I inserts unity gain in the
selected wire, Delete removes the selected node/wire and Escape cancels gestures.
Piano-roll commands are listed in its Notes menu. Escape cancels gestures;
ordinary deletion is reversible and does not add a confirmation dialog.
Graph right-click now selects the node/wire under the pointer before showing
Inspect/Arrange/Insert/Delete; empty space clears a stale selection. Piano-roll
right-click selects the clicked note and offers Properties/Quantize/Repeat/Delete.
It no longer immediately deletes the note. Timeline context menus retain their
selection and duplicate/rename/delete/audio commands. Dismissing a context menu
does not edit the document. Context menus and widgets remain C++/FLTK UI code.
Open/import/plugin load/replacement paint a busy cursor and operation status
before synchronous work, flushing drawing without dispatching new input or
reentering commands. This is feedback, not background loading or cancellable
progress. Failure messages distinguish capacity, memory, target/value, format/
optional-support and audio errors. Unsaved New/Open/Quit and Save As overwrite
keep their existing confirmations; undoable deletes and external-media reference
edits do not request extra approval. No operation here deletes source media.
## Evidence and manual procedure
`make all test gui test-gui-plugins test-reliability analyze test-workflow
test-gui-jack` covers core commands, grouped gestures/batches, backend rollback,
selection retention, take undo/redo and real software JACK state restoration.
The GUI fixture injects history-reservation failure before graph/map/import
publication and verifies unchanged document, submission count and redo. The
allocation-interposed reliability suite fails each reservation allocation, then
fails all allocations while capturing a reserved edit; redo branching and
snapshot restoration still succeed. ASan/UBSan cover the same ownership paths.
For native manual inspection: right-click different graph nodes/wires and empty
space, dismiss the menu, and confirm only selection changes. Right-click a note,
dismiss, then explicitly Delete and undo; confirm the note returns. Type spaces
and use text undo in a name/input dialog, and edit numeric mixer fields without
triggering fader keys. Record a short take, stop, undo once and confirm its WAV
still exists; redo restores the reference. Watch operation feedback on a large
import/Open. These manual popup/focus/large-file and physical-device listening
checks remain pending; automation does not claim broad accessibility or human
usability qualification. Existing native-plugin failure limits still apply.

View file

@ -19,7 +19,8 @@
Automated LADSPA controls use run(1) and can be expensive.
- GUI loading/compilation is synchronous on the control thread. Timeline grid/
zoom/selection and graph positions are session UI state. Undo retains 32 edits
plus baseline; allocation failures are reported, not silently hidden. Media is
plus baseline and at most one reserved metadata snapshot; GUI edits reserve
history before mutation/publication. Media is
never removed by undo/delete. Broad screen-reader/accessibility testing is pending.
- Format v10 reads v1–v9 and uses relative local media references. External absolute
media/module paths need manual repair after relocation. Saving does not bundle
@ -59,3 +60,8 @@ PERFORMANCE.md, PLUGIN_HOSTING.md and RECOVERY.md for tested contracts/evidence.
for live sine volume/pan/sustain. Live mappings do not drive arranged lanes,
faders or plugins. No automation capture, touch/latch/write modes or loop
transport. Backward jumps are deterministic for lanes, not opaque plugin tails.
- Editing feedback remains synchronous; busy cursors are not background loading
or cancellable progress. Surviving selections are retained across undo but
historical selection/layout is not stored. Native context-menu/focus manual
qualification and broad accessibility testing remain pending. See EDITING.md.

View file

@ -104,7 +104,8 @@ plus a baseline, cloning metadata and graph, never audio samples. Common graph,
track, clip, note, mixer, import/relink and automation edits are included. One
accepted gesture is one step; selection, scrolling and graph-node placement are
GUI state and are not undoable. New/Open resets history; Save retains it.
Undo/redo stops and resets transport and resets editor selections/layout. The
Undo/redo stops and resets transport; Phase 27 preserves surviving selections
by stable ID (layout remains session state). The
candidate is installed before moving the history cursor: validation, asset-plan
or backend failure retains the current document and history position. Missing
assets retain the existing silent/reporting behavior. A new edit after Undo
@ -112,8 +113,9 @@ removes the redo branch; the oldest state is evicted at capacity. Allocation
ID high-water marks survive Undo/branching so abandoned IDs are not reused. History is
session-only and Undo marks the document dirty even if it matches a saved state.
External WAV files are never deleted or restored by Undo; recording itself cannot
be undone while active. An allocation failure retaining a new history snapshot
reports an error; the accepted edit remains in the document.
be undone while active. Phase 27 reserves snapshot storage before GUI commands mutate/publish, so an
allocation failure rejects the command before adoption; reserved capture itself
does not allocate. C clients using unreserved history record must handle errors.
With mixer focus, M/S toggles mute/solo, Left/Right changes gain by 0.05,
Shift+Left/Right changes pan, and Up/Down selects a track. Native sliders also

View file

@ -720,10 +720,37 @@ Make common editing actions quick, discoverable, and safe to reverse.
- Keep presentation logic out of the core C engine.
### Acceptance criteria
- Documented editing operations have predictable undo/redo behavior.
- Undo and redo restore the project model and relevant engine state consistently.
- Failed operations do not leave partial changes behind.
- Common tasks can be completed using documented keyboard and mouse interactions.
- [x] Documented editing operations have predictable undo/redo behavior.
- [x] Undo and redo restore the project model and relevant engine state consistently.
- [x] Failed operations do not leave partial changes behind.
- [x] Common tasks can be completed using documented keyboard and mouse interactions.
Implemented: operation-by-operation audit in EDITING.md; reserved history storage
before GUI command mutation/candidate publication, followed by allocation-free
snapshot capture; recording start creates no empty history entry and each
completed take is one undoable reference edit; unavailable main-menu history
steps disabled; surviving editor selections and open piano-roll context retained
on undo; piano-roll history shortcuts/context menu and pointer-targeted graph
context menus; input focus guards and synchronous busy feedback. Existing C
graph/batch note commands and snapshot history remain the model boundary; FLTK
presentation stays in C++. No project format change (v10 retained).
Validation: warning-enabled core/GUI builds and full tests; interposed allocation
failure for each history reservation and capture with all allocations disabled;
GUI history-reservation failure retains document, engine submission count and
redo; completed-take undo grouping retains WAV; selection/context-target tests;
existing grouped gestures/batches, candidate/backend rollback and save/reopen;
workflow/software JACK GUI integration, reliability/callback guards, Clang static
analysis, core/GUI ASan+UBSan, optional-dependency-free build, installation and
package relocation. GUI sanitizer runs retain the existing fontconfig-only leak
suppression. EDITING.md supplies a reproducible native manual procedure for
popup dismissal, text focus, large synchronous operations and recorded takes.
Native manual popup/focus/large-file usability checks, broad accessibility and
physical-device listening/endurance remain pending. History remains bounded and
session-only; selection/layout history, arbitrary command grouping, background
loading and destructive source-media editing are not supplied. Preparation
failure can stop/reset transport while retaining the old document/history.
## Phase 28 — Project Format, Compatibility, and Portability

View file

@ -145,3 +145,10 @@ replaced. Audio → MIDI controller mapping lists all channel bindings; enter
must be distinct, 120/123 reserved). Maps are undoable and saved. Controllers
modify live sine voices; arranged track/bus/plugin automation takes precedence
over its static controls and is unaffected by those MIDI bindings. See CONTROL.md.
Phase 27: unavailable main-menu undo/redo actions are disabled, including while
recording. Piano roll has Edit/Undo/Redo shortcuts and a right-click note menu;
right-click no longer immediately deletes notes. Graph right-click targets the
node/wire under the pointer. Undo preserves surviving selections. Completed
takes add one undo step; undo removes references and keeps WAV files. See
EDITING.md for grouped commands, failure handling and keyboard/focus behavior.

View file

@ -131,3 +131,15 @@ its region never deletes the WAV; recording failures retain their recovery path.
There is no destructive media-delete operation. New/Open reset history after
unsaved-change handling. The capacity limit and allocation-error reporting in
MIXER.md still apply. Tests: `make test-editing test-gui-plugins test-workflow`.
## Phase 27 editing audit
See [EDITING.md](EDITING.md) for the operation-by-operation undo matrix, command
ownership, allocation-failure boundary, external-media behavior and manual
context-menu/focus procedure. History now reserves storage before edits and
publication, preserving redo when commands fail. Recording start has no empty
undo entry; a completed take is one step and undo never deletes its WAV.
Undo preserves surviving selections and an open piano roll; the piano window
also offers Edit/Undo/Redo. Graph context menus select under the pointer and
piano-roll right-click now opens a menu instead of immediately deleting a note.
Load/import/plugin work paints busy feedback while remaining synchronous.

View file

@ -113,6 +113,14 @@ void xtc_history_destroy(xtc_history * h);
* @param p Project model; borrowed for this call.
* @return XTC_OK on success, or an error code describing the failure.
*/
/* Reserve fixed-capacity snapshot storage before a user command mutates a
* project or publishes a candidate. Idempotent; failure retains history/redo.
* The next record uses this storage without allocating. It remains reserved
* across rejected commands and is freed on record or history destruction.
* All calls are serialized on the control thread, never in the callback. */
xtc_result xtc_history_reserve(xtc_history *history);
/* Available adjacent steps; metadata-only, no cloning/allocation. */
xtc_result xtc_history_counts(const xtc_history *history, size_t *undo_count, size_t *redo_count);
xtc_result xtc_history_record(xtc_history * h, const xtc_project * p);
/**
* @brief Clones the adjacent undo or redo state without moving the history cursor.

View file

@ -445,6 +445,26 @@ void GraphWidget::draw()
* @return 1 when the event was consumed, 0 for unsupported left-button input,
* or the result of Fl_Widget::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 = Fl::event_x() - x(), top = Fl::event_y() - y();
@ -454,6 +474,7 @@ int GraphWidget::handle(int event)
return 1;
case FL_PUSH: {
if (Fl::event_button() == FL_RIGHT_MOUSE) {
take_focus(); select_at(left,top);
Fl_Menu_Item items[5]{};
items[0].text = "Inspect selection";
items[1].text = "Arrange by signal flow (L)";

View file

@ -109,6 +109,7 @@ class GraphWidget : public Fl_Widget {
* @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.

View file

@ -13,6 +13,7 @@
#include <FL/Fl_Group.H>
#include <FL/Fl_Scroll.H>
#include <FL/Fl_Menu_Bar.H>
#include <FL/Fl_Input_.H>
#include <FL/Fl_Tabs.H>
#include <FL/fl_ask.H>
#include <cstdio>
@ -37,6 +38,15 @@ void set_label(Fl_Box *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 {
Fl_Window &window_;
public:
BusyOperation(Fl_Window &window, Fl_Box *status, const char *message) : window_(window) {
set_label(status,message); window_.cursor(FL_CURSOR_WAIT); Fl::flush();
}
~BusyOperation() { window_.cursor(FL_CURSOR_DEFAULT); }
};
} // namespace
/**
@ -64,6 +74,7 @@ MainWindow::MainWindow(bool prompt_unsaved)
history_.reset(history);
begin();
auto *menu = new Fl_Menu_Bar(0, 0, 1000, 28);
menu_ = menu;
menu->add("Edit/Undo", FL_CTRL + 'z', history_callback, this);
menu->add("Edit/Redo", FL_CTRL + FL_SHIFT + 'z', history_callback, this);
menu->add("Edit/Redo alternate", FL_CTRL + 'y', history_callback, this);
@ -325,8 +336,11 @@ 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_UNSUPPORTED ? "Rebuild with JACK development support."
: "Check the JACK-compatible server and use Reconnect to retry.";
: 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);
}
@ -360,6 +374,7 @@ xtc_result MainWindow::edit_graph(void *context, const xtc_graph_edit &edit, siz
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) {
@ -524,8 +539,18 @@ void MainWindow::reconnect_engine()
* 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 : FL_MENU_INACTIVE);
}
}
void MainWindow::refresh_status()
{
update_history_controls();
if (mixer_ && !ready_) { mixer_->peaks(0, 0); mixer_->track_peaks(0, 0); }
bool can_play = false, can_record = false;
if (!ready_) {
@ -652,6 +677,9 @@ bool MainWindow::close_window()
*/
int MainWindow::handle(int event)
{
if (event == FL_SHORTCUT && dynamic_cast<Fl_Input_ *>(Fl::focus()) &&
(Fl::event_key() == ' ' || (Fl::event_state(FL_CTRL) &&
(Fl::event_key() == 'z' || Fl::event_key() == 'y' || Fl::event_key() == 'd')))) return 0;
if (event == FL_SHORTCUT && Fl::event_key() == ' ' && (Fl::event_state() & (FL_CTRL | FL_ALT | FL_META)) == 0) {
if (running_)
stop_engine();
@ -766,6 +794,7 @@ void MainWindow::mark_dirty()
if (piano_)
piano_->set_project(project_.get(), piano_->clip());
dirty_ = true;
update_history_controls();
copy_label("xtc * — unsaved changes");
}
@ -925,6 +954,7 @@ xtc_result MainWindow::install_project(xtc_project *candidate, bool reset_histor
*/
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();
@ -1047,11 +1077,9 @@ void MainWindow::file_callback(Fl_Widget *widget, void *context)
xtc_result MainWindow::before_arrangement_edit(void *context)
{
auto *window = static_cast<MainWindow *>(context);
if (window->recorder_)
return XTC_ERR_INVALID_STATE;
if (!window->ready_)
return XTC_OK;
xtc_result r = xtc_backend_stop(window->backend_);
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;
}
@ -1069,6 +1097,7 @@ xtc_result MainWindow::before_arrangement_edit(void *context)
*/
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;
@ -1131,8 +1160,7 @@ xtc_result MainWindow::record_audio(const char *path)
(void)xtc_backend_set_recorder(backend_, nullptr);
xtc_recorder_destroy(recorder_);
recorder_ = nullptr;
} else
mark_dirty();
}
refresh_status();
return r;
}
@ -1174,6 +1202,7 @@ void MainWindow::finish_recording()
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) {
@ -1264,6 +1293,9 @@ xtc_result MainWindow::open_piano_roll()
auto *bar = static_cast<Fl_Menu_Bar *>(widget);
owner->piano_->command(static_cast<int>(bar->mvalue() - bar->menu()) - 1);
}, this);
menu->add("Edit/Undo",FL_CTRL+'z',history_callback,this);
menu->add("Edit/Redo",FL_CTRL+FL_SHIFT+'z',history_callback,this);
menu->add("Edit/Redo alternate",FL_CTRL+'y',history_callback,this);
auto *scroll = new Fl_Scroll(12, 48, 876, 560);
piano_ = new PianoRollWidget(12, 48, project_.get(), clip.id, before_arrangement_edit, this);
piano_->callback(
@ -1334,6 +1366,12 @@ 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)
@ -1342,8 +1380,20 @@ xtc_result MainWindow::undo(bool redo)
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;
}
@ -1373,6 +1423,7 @@ xtc_result MainWindow::set_control_map(unsigned int channel, const unsigned int
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;
@ -1402,6 +1453,7 @@ 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();
@ -1409,6 +1461,7 @@ xtc_result MainWindow::install_plugin_graph(xtc_graph *graph)
}
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 = 48000;
xtc_backend_status status{};
@ -1429,6 +1482,7 @@ xtc_result MainWindow::add_plugin(const char *path, unsigned int index, size_t *
}
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 = 48000; xtc_backend_status status{};
if (ready_ && xtc_backend_get_status(backend_,&status) == XTC_OK) rate = status.sample_rate;

View file

@ -17,6 +17,7 @@ class SequencerWidget;
class Fl_Box;
class Fl_Button;
class Fl_Tabs;
class Fl_Menu_Bar;
class MainWindow : public Fl_Double_Window {
public:
@ -168,6 +169,8 @@ class MainWindow : public Fl_Double_Window {
private:
MixerWidget *mixer_ = nullptr;
Fl_Menu_Bar *menu_ = nullptr;
void update_history_controls();
std::unique_ptr<xtc_history, decltype(&xtc_history_destroy)> history_{nullptr, xtc_history_destroy};
/**
* @brief FLTK menu callback for the Undo and Redo menu items.

View file

@ -7,6 +7,7 @@
#include <FL/Fl_Choice.H>
#include <FL/Fl_Progress.H>
#include <FL/Fl_Value_Input.H>
#include <FL/Fl_Input_.H>
#include <FL/Fl_Value_Slider.H>
#include <FL/fl_ask.H>
#include <FL/fl_draw.H>
@ -459,6 +460,7 @@ void MixerWidget::select(Fl_Widget *, void *c)
*/
int MixerWidget::handle(int e)
{
if (e == FL_KEYDOWN && dynamic_cast<Fl_Input_ *>(Fl::focus())) return Fl_Group::handle(e);
if (e == FL_KEYDOWN && !(Fl::event_state() & (FL_CTRL | FL_ALT | FL_META))) {
int key = Fl::event_key();
xtc_track_info t{};

View file

@ -2,6 +2,7 @@
#include <FL/Fl.H>
#include <FL/fl_draw.H>
#include <FL/fl_ask.H>
#include <FL/Fl_Menu_Item.H>
#include <algorithm>
#include <cstdio>
#include <sstream>
@ -46,6 +47,19 @@ PianoRollWidget::PianoRollWidget(int left, int top, xtc_project *project, uint64
* @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,&note) == 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_) {
@ -248,8 +262,17 @@ int PianoRollWidget::handle(int event)
}
}
if (selected_ != UINT64_MAX && Fl::event_button() == FL_RIGHT_MOUSE) {
delete_selected();
mode_ = 0;
Fl_Menu_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(Fl::event_x(),Fl::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 (Fl::event_button() == FL_LEFT_MOUSE && selected_ == UINT64_MAX && Fl::event_x() >= x() + key_width &&
Fl::event_y() >= y() + header) {
int pitch = 127 - (Fl::event_y() - y() - header) / row_height;
@ -293,11 +316,11 @@ int PianoRollWidget::handle(int event)
if (event == FL_KEYDOWN) {
int key = Fl::event_key();
if (key == 'a' && (Fl::event_state() & FL_CTRL)) { select_filtered(); return 1; }
if (key == 'q') { command(3); return 1; }
if (key == 'v') { command(2); return 1; }
if (key == 'g') { command(0); return 1; }
if (key == 'f') { command(1); return 1; }
if (key == 't') { command(4); return 1; }
if (!Fl::event_state(FL_CTRL|FL_ALT|FL_META) && key == 'q') { command(3); return 1; }
if (!Fl::event_state(FL_CTRL|FL_ALT|FL_META) && key == 'v') { command(2); return 1; }
if (!Fl::event_state(FL_CTRL|FL_ALT|FL_META) && key == 'g') { command(0); return 1; }
if (!Fl::event_state(FL_CTRL|FL_ALT|FL_META) && key == 'f') { command(1); return 1; }
if (!Fl::event_state(FL_CTRL|FL_ALT|FL_META) && key == 't') { command(4); return 1; }
if (key == 'd' && (Fl::event_state() & FL_CTRL)) { command(5); return 1; }
if (key == FL_Up || key == FL_Down) {
xtc_note_edit edit{};

View file

@ -69,6 +69,8 @@ class PianoRollWidget : public Fl_Widget {
*
* @return XTC_OK on success, the guard's error, or the project error.
*/
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);

View file

@ -31,6 +31,24 @@ SequencerWidget::SequencerWidget(int left, int top, xtc_project *project, Seek s
* @param project Borrowed project; must outlive the widget's use of it.
* @param reset Whether to clear selection, cursor, and drag state.
*/
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;
}
track_ = track; clip_ = clip; mode_ = 0; redraw();
return true;
}
void SequencerWidget::set_project(xtc_project *project, bool reset)
{
project_ = project;

View file

@ -13,6 +13,7 @@ class SequencerWidget : public Fl_Widget {
*
* @return XTC_OK to allow the edit, or an error to cancel it.
*/
bool select(uint64_t track, uint64_t clip = UINT64_MAX);
xtc_result duplicate_selection();
xtc_result split_selection();
xtc_result audio_properties(float gain, uint64_t fade_in, uint64_t fade_out);

View file

@ -6,6 +6,7 @@
struct xtc_history {
xtc_project *states[XTC_HISTORY_EDITS + 1];
size_t count, cursor;
xtc_project *reserved;
uint64_t next_track, next_clip, next_note;
size_t next_node;
};
@ -40,6 +41,7 @@ void xtc_history_destroy(xtc_history *h)
return;
for (i = 0; i < h->count; ++i)
xtc_project_destroy(h->states[i]);
xtc_project_destroy(h->reserved);
free(h);
}
@ -73,6 +75,41 @@ xtc_result xtc_history_create(const xtc_project *p, xtc_history **out)
return XTC_OK;
}
xtc_result xtc_history_reserve(xtc_history *h)
{
if (!h) return XTC_ERR_INVALID_ARGUMENT;
if (h->reserved) return XTC_OK;
return xtc_project_create(&h->reserved);
}
xtc_result xtc_history_counts(const xtc_history *h, size_t *undo_count, size_t *redo_count)
{
if (!h || !undo_count || !redo_count || undo_count == redo_count) return XTC_ERR_INVALID_ARGUMENT;
*undo_count = h->cursor;
*redo_count = h->count-h->cursor-1;
return XTC_OK;
}
/* Copy into the preallocated project and 64-node/4096-edge graph. No allocation
* can fail after an accepted model command or successful engine publication. */
static xtc_result capture(xtc_history *h, const xtc_project *p, xtc_project **out)
{
xtc_project *copy = h->reserved;
xtc_graph *graph;
xtc_node *nodes;
xtc_edge *edges;
if (!copy) return xtc_project_clone(p,out);
if (p->graph->node_count > 64 || p->graph->edge_count > 4096) return XTC_ERR_CAPACITY;
graph = copy->graph; nodes = graph->nodes; edges = graph->edges;
*copy = *p;
copy->graph = graph;
*graph = *p->graph;
graph->max_nodes = 64; graph->nodes = nodes; graph->edges = edges;
memcpy(nodes,p->graph->nodes,p->graph->node_count*sizeof(*nodes));
memcpy(edges,p->graph->edges,p->graph->edge_count*sizeof(*edges));
h->reserved = NULL;
*out = copy;
return XTC_OK;
}
/**
* @brief Appends a project snapshot, discarding redo states and evicting the oldest state if full.
*
@ -87,7 +124,7 @@ xtc_result xtc_history_record(xtc_history *h, const xtc_project *p)
xtc_result r;
if (!h || !p)
return XTC_ERR_INVALID_ARGUMENT;
r = xtc_project_clone(p, &copy);
r = capture(h,p,&copy);
if (r != XTC_OK)
return r;
remember_ids(h, p);

View file

@ -27,7 +27,7 @@ namespace {
xtc_backend handle;
xtc_result seek_result = XTC_OK;
xtc_result open_result = XTC_OK, start_result = XTC_OK, stop_result = XTC_OK, close_result = XTC_OK;
xtc_result status_result = XTC_OK, submit_result = XTC_OK;
xtc_result status_result = XTC_OK, submit_result = XTC_OK, reserve_result = XTC_OK;
unsigned int submissions = 0U;
xtc_backend_status engine_status = {};
bool partial_open = false;
@ -94,7 +94,7 @@ void reset()
{
CHECK(alive == 0U);
open_result = start_result = stop_result = close_result = status_result = XTC_OK;
submit_result = seek_result = XTC_OK;
submit_result = seek_result = reserve_result = XTC_OK;
engine_status = {};
engine_status.sample_rate = 48000U;
engine_status.buffer_frames = 1024U;
@ -102,6 +102,11 @@ void reset()
}
}
extern "C" {
xtc_result __real_xtc_history_reserve(xtc_history *history);
xtc_result __wrap_xtc_history_reserve(xtc_history *history) {
return reserve_result == XTC_OK ? __real_xtc_history_reserve(history) : reserve_result;
}
xtc_result xtc_backend_open(const xtc_backend_config *config, xtc_backend **out)
{
CHECK(config->kind == XTC_BACKEND_JACK && config->channel_count == 2U);
@ -334,6 +339,8 @@ int main(int argc, char **argv)
CHECK(sequence->position() == 1920); // Graph edits preserve the transport.
engine_status.sample_position = 0;
std::string take = std::string(directory) + "/take.wav";
size_t before_take_nodes = 0, before_take_edges = 0;
CHECK(xtc_graph_get_counts(canvas->graph(),&before_take_nodes,&before_take_edges) == XTC_OK);
CHECK(window.record_audio(take.c_str()) == XTC_OK && handle.recorder != nullptr);
CHECK(canvas->add_node(XTC_NODE_GAIN) == XTC_ERR_INVALID_STATE);
CHECK(window.save_project(filename.c_str()) == XTC_ERR_INVALID_STATE);
@ -349,6 +356,14 @@ int main(int argc, char **argv)
press(stop);
CHECK(handle.recorder == nullptr);
CHECK(xtc_project_get_counts(sequence->project(), &track_count, &clip_count) == XTC_OK && clip_count == 2);
CHECK(window.undo(false) == XTC_OK);
CHECK(xtc_project_get_counts(sequence->project(),&track_count,&clip_count) == XTC_OK && clip_count == 1);
CHECK(access(take.c_str(),F_OK) == 0); // Undo never deletes the external take.
CHECK(window.undo(false) == XTC_OK);
size_t after_take_nodes = 0, after_take_edges = 0;
CHECK(xtc_graph_get_counts(canvas->graph(),&after_take_nodes,&after_take_edges) == XTC_OK && after_take_nodes+1 == before_take_nodes);
CHECK(window.undo(true) == XTC_OK && window.undo(true) == XTC_OK);
CHECK(xtc_project_get_counts(sequence->project(),&track_count,&clip_count) == XTC_OK && clip_count == 2);
CHECK(sequence->waveform_count() == 1);
// Short clips use their four-pixel draw width for hit testing too.
xtc_clip_info audio_clip{};
@ -656,6 +671,36 @@ int main(int argc, char **argv)
CHECK(xtc_project_get_automation(sequence->project(),0,&point) == XTC_OK && point.step == 1);
unlink("/tmp/xtc-gui-controls-test.xtc"); window.do_callback();
}
{
reset(); MainWindow window(false);
auto *sequence = timeline(&window); auto *canvas = graph_canvas(&window);
CHECK(sequence->add_track() == XTC_OK && sequence->add_midi_clip() == XTC_OK);
const uint64_t track = sequence->selected_track(), clip = sequence->selected_clip();
CHECK(window.open_piano_roll() == XTC_OK);
CHECK(window.piano_roll()->add_note(0,240,60) == XTC_OK);
const uint64_t note = window.piano_roll()->selected_note();
CHECK(window.piano_roll()->add_note(240,240,64) == XTC_OK);
CHECK(window.piano_roll()->select_note(note));
CHECK(window.undo(false) == XTC_OK);
CHECK(sequence->selected_track() == track && sequence->selected_clip() == clip);
CHECK(window.piano_roll()->clip() == clip && window.piano_roll()->selected_note() == note);
const xtc_project *before = sequence->project(); unsigned int bindings[3] = {1,74,11};
unsigned int old_submissions = submissions;
reserve_result = XTC_ERR_OUT_OF_MEMORY;
CHECK(canvas->add_node(XTC_NODE_GAIN) == XTC_ERR_OUT_OF_MEMORY && sequence->project() == before);
CHECK(window.set_control_map(0,bindings) == XTC_ERR_OUT_OF_MEMORY && sequence->project() == before);
CHECK(window.import_audio("/absent/not-loaded.wav") == XTC_ERR_OUT_OF_MEMORY && sequence->project() == before);
CHECK(submissions == old_submissions);
reserve_result = XTC_OK;
CHECK(window.undo(true) == XTC_OK); // Failed commands preserved redo.
size_t count = 0;
CHECK(xtc_project_note_count(sequence->project(),&count) == XTC_OK && count == 2);
CHECK(canvas->add_node(XTC_NODE_GAIN) == XTC_OK);
const size_t node = canvas->selected_node(); int left, top;
CHECK(canvas->node_position(node,left,top) && canvas->select_at(left+50,top+30) && canvas->selected_node() == node);
CHECK(!canvas->select_at(-20,-20) && canvas->selected_node() == SIZE_MAX);
window.do_callback();
}
if (const char *module = std::getenv("XTC_TEST_PLUGIN")) {
reset();
MainWindow window(false);

View file

@ -2,6 +2,7 @@
* reject host allocation/reclamation inside processing. No production hooks. */
#include "xtc/xtc_realtime.h"
#include "xtc/xtc_native.h"
#include "xtc/xtc_control.h"
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
@ -83,9 +84,53 @@ static void graph_limits(void)
xtc_plan_destroy(plan);
}
static void history_reservations(void)
{
xtc_project *project = NULL, *snapshot = NULL;
xtc_history *history = NULL;
uint64_t track, bus;
unsigned int map[3] = {1,74,11}, actual[3];
size_t undo_count, redo_count, tracks, clips;
int failure, succeeded = 0;
assert(xtc_project_create(&project) == XTC_OK);
assert(xtc_project_add_track(project,"History",&track) == XTC_OK);
assert(xtc_history_create(project,&history) == XTC_OK);
assert(xtc_project_set_mix(project,track,.5f,0,0,0) == XTC_OK);
assert(xtc_history_record(history,project) == XTC_OK);
assert(xtc_history_checkout(history,0,&snapshot) == XTC_OK && xtc_history_accept(history,0) == XTC_OK);
xtc_project_destroy(project); project = snapshot; snapshot = NULL;
for (failure = 0; failure < 12; ++failure) {
xtc_result r;
fail_after = failure;
r = xtc_history_reserve(history);
fail_after = -1;
assert(xtc_history_counts(history,&undo_count,&redo_count) == XTC_OK && undo_count == 0 && redo_count == 1);
if (r == XTC_OK) { succeeded = 1; break; }
assert(r == XTC_ERR_OUT_OF_MEMORY);
}
assert(succeeded);
fail_after = 0; // Fail every allocation: reserved record must still succeed.
assert(xtc_history_reserve(history) == XTC_OK);
assert(xtc_project_add_bus(project,"Bus",&bus) == XTC_OK);
assert(xtc_project_route_track(project,track,bus) == XTC_OK);
assert(xtc_project_set_control_map(project,0,map) == XTC_OK);
assert(xtc_history_record(history,project) == XTC_OK);
fail_after = -1;
assert(xtc_history_counts(history,&undo_count,&redo_count) == XTC_OK && undo_count == 1 && redo_count == 0);
assert(xtc_history_checkout(history,0,&snapshot) == XTC_OK);
assert(xtc_project_get_counts(snapshot,&tracks,&clips) == XTC_OK && tracks == 1 && clips == 0);
assert(xtc_project_get_control_map(snapshot,0,actual) == XTC_OK && actual[0] == 7);
xtc_project_destroy(snapshot); snapshot = NULL;
assert(xtc_history_accept(history,0) == XTC_OK && xtc_history_checkout(history,1,&snapshot) == XTC_OK);
assert(xtc_project_get_counts(snapshot,&tracks,&clips) == XTC_OK && tracks == 2);
assert(xtc_project_get_control_map(snapshot,0,actual) == XTC_OK && actual[0] == 1 && actual[1] == 74);
xtc_project_destroy(snapshot); xtc_project_destroy(project); xtc_history_destroy(history);
}
int main(void)
{
graph_limits();
history_reservations();
xtc_native_descriptor descriptor = {XTC_NATIVE_ABI_VERSION, sizeof(descriptor),
"reliability.gain", XTC_NATIVE_EFFECT, prepare, reset, process, destroy};
xtc_graph *graph = NULL;