From c4daef7c42c6902f8ff9f28aa02cf01cb6dcd51e Mon Sep 17 00:00:00 2001 From: hanez Date: Tue, 27 Oct 2026 16:49:30 +0100 Subject: [PATCH] Phase 27: strengthen undo transactions and improve editor workflow --- Makefile | 2 +- README.md | 4 + docs/EDITING.md | 115 +++++++++++++++++++++++++++ docs/KNOWN_LIMITATIONS.md | 8 +- docs/MIXER.md | 8 +- docs/ROADMAP.md | 35 +++++++- docs/USER_MANUAL.md | 7 ++ docs/WORKFLOW.md | 12 +++ include/xtc/xtc_mixer.h | 8 ++ src/gui/graph_widget.cpp | 21 +++++ src/gui/graph_widget.hpp | 1 + src/gui/main_window.cpp | 72 ++++++++++++++--- src/gui/main_window.hpp | 3 + src/gui/mixer_widget.cpp | 2 + src/gui/piano_roll.cpp | 35 ++++++-- src/gui/piano_roll.hpp | 2 + src/gui/sequencer_widget.cpp | 18 +++++ src/gui/sequencer_widget.hpp | 1 + src/project/history.c | 39 ++++++++- tests/integration/test_gui.cpp | 49 +++++++++++- tests/integration/test_reliability.c | 45 +++++++++++ 21 files changed, 460 insertions(+), 27 deletions(-) create mode 100644 docs/EDITING.md diff --git a/Makefile b/Makefile index 7745b08..91446d5 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 5cb5e0c..4b13b07 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/EDITING.md b/docs/EDITING.md new file mode 100644 index 0000000..f085c88 --- /dev/null +++ b/docs/EDITING.md @@ -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. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index fcfc10b..6bcea9d 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -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. diff --git a/docs/MIXER.md b/docs/MIXER.md index 54516a5..dad75b1 100644 --- a/docs/MIXER.md +++ b/docs/MIXER.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6344e78..f123907 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index a622830..d4b0a97 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -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. diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 8253725..d86e622 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -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. diff --git a/include/xtc/xtc_mixer.h b/include/xtc/xtc_mixer.h index 0663eb6..8b5ac61 100644 --- a/include/xtc/xtc_mixer.h +++ b/include/xtc/xtc_mixer.h @@ -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. diff --git a/src/gui/graph_widget.cpp b/src/gui/graph_widget.cpp index ca7ee1b..4182b65 100644 --- a/src/gui/graph_widget.cpp +++ b/src/gui/graph_widget.cpp @@ -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)"; diff --git a/src/gui/graph_widget.hpp b/src/gui/graph_widget.hpp index b8d2af4..ab4b337 100644 --- a/src/gui/graph_widget.hpp +++ b/src/gui/graph_widget.hpp @@ -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. diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 438d90a..f543eee 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -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::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(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(widget); owner->piano_->command(static_cast(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; diff --git a/src/gui/main_window.hpp b/src/gui/main_window.hpp index f53273d..85ad0c7 100644 --- a/src/gui/main_window.hpp +++ b/src/gui/main_window.hpp @@ -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 history_{nullptr, xtc_history_destroy}; /** * @brief FLTK menu callback for the Undo and Redo menu items. diff --git a/src/gui/mixer_widget.cpp b/src/gui/mixer_widget.cpp index fdd843b..2c8b878 100644 --- a/src/gui/mixer_widget.cpp +++ b/src/gui/mixer_widget.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -459,6 +460,7 @@ void MixerWidget::select(Fl_Widget *, void *c) */ int MixerWidget::handle(int e) { + if (e == FL_KEYDOWN && dynamic_cast(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{}; diff --git a/src/gui/piano_roll.cpp b/src/gui/piano_roll.cpp index 2be7e41..e477d78 100644 --- a/src/gui/piano_roll.cpp +++ b/src/gui/piano_roll.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -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,¬e) == XTC_OK && note.id == id && visible(note)) { + selected_ = id; all_ = false; mode_ = 0; redraw(); return true; + } + } + return false; +} + void PianoRollWidget::set_project(xtc_project *project, uint64_t clip) { if (project != project_ || clip != clip_) { @@ -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{}; diff --git a/src/gui/piano_roll.hpp b/src/gui/piano_roll.hpp index b08536e..fb73c4d 100644 --- a/src/gui/piano_roll.hpp +++ b/src/gui/piano_roll.hpp @@ -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); diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index 9b70ee7..c54fc31 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -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; diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index 42b280f..9cfe161 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -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); diff --git a/src/project/history.c b/src/project/history.c index 09c2635..da6348b 100644 --- a/src/project/history.c +++ b/src/project/history.c @@ -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, ©); + r = capture(h,p,©); if (r != XTC_OK) return r; remember_ids(h, p); diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index ed3ffee..89fc4b8 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -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); diff --git a/tests/integration/test_reliability.c b/tests/integration/test_reliability.c index 2541560..1bf5a52 100644 --- a/tests/integration/test_reliability.c +++ b/tests/integration/test_reliability.c @@ -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 #include #include @@ -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;