From 440e98f2b571bc7b606b9f2e7ff7993acbdbfbed Mon Sep 17 00:00:00 2001 From: hanez Date: Tue, 27 Oct 2026 06:28:25 +0100 Subject: [PATCH] Phase 17: add undoable duplication, naming, ordering and timeline commands --- Makefile | 8 +++++ docs/ROADMAP.md | 8 +++++ docs/WORKFLOW.md | 31 ++++++++++++++++++ include/xtc/xtc_project.h | 6 ++++ src/gui/main_window.cpp | 9 ++++++ src/gui/sequencer_widget.cpp | 57 +++++++++++++++++++++++++++++++--- src/gui/sequencer_widget.hpp | 7 ++++- src/project/project.c | 46 +++++++++++++++++++++++++++ tests/integration/test_gui.cpp | 22 +++++++++++++ tests/unit/test_editing.c | 42 +++++++++++++++++++++++++ 10 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_editing.c diff --git a/Makefile b/Makefile index 1b5e0bc..176225f 100644 --- a/Makefile +++ b/Makefile @@ -328,3 +328,11 @@ test-control: $(BUILD_DIR)/test_control ./$(BUILD_DIR)/test_control test: test-control -include $(BUILD_DIR)/tests/unit/test_control.d + +$(BUILD_DIR)/test_editing: $(BUILD_DIR)/tests/unit/test_editing.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ -lm -ldl +.PHONY: test-editing +test-editing: $(BUILD_DIR)/test_editing + ./$(BUILD_DIR)/test_editing +test: test-editing +-include $(BUILD_DIR)/tests/unit/test_editing.d diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ce07c3f..6e054e4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -355,6 +355,14 @@ instrument timbres and physical listening remain outside the supported scope. - GUI operations do not block the real-time audio thread. - Interaction behavior is documented and covered by relevant tests. +Implemented: transactional full-source clip duplication, clip/track names, +track ordering, configurable timeline snap, Ctrl+D/F2/Alt+arrow commands and a +context menu; kind/name clip labels and focus regressions. Snapshot/GUI tests +verify duplicate/rename/order undo/redo, source-note retention, capacity rollback +and existing graph/backend history behavior. Recording undo removes references, +never media. See WORKFLOW.md. Background loading, persisted UI layout and broad +screen-reader/accessibility qualification remain explicit limits. + ## Phase 18 — Project Format, Compatibility, and Portability **Goal:** Keep project files valid, recoverable, and maintainable over time. diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index e2720f2..f7f122f 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -100,3 +100,34 @@ These checks cover a minimal composition workflow, not physical listening or endurance qualification. No new dependency, project-format change, plugin host, MIDI capture, tempo editor, loop playback, device-routing GUI, or persisted editor layout was introduced. See GUI.md, MIDI.md, MIXER.md and LINUX.md for details. + +## Phase 17 editing workflow + +Select a clip and press Ctrl+D (or Edit/Duplicate clip) to duplicate it immediately +after its end. Complete source notes, trim offsets and media references are +retained with fresh stable IDs. No audio file is copied or overwritten. F2 or +Edit/Rename clip or track names the selection. Clip labels show both kind and +user name. Alt+Up/Down with timeline focus reorders its selected track without +changing IDs, clip membership or routing. Right-click a timeline clip/track for +Duplicate, Rename and Delete. Invalid/capacity edits retain the existing model. + +View/Snap selects 1, 120, 240 (default), 480 or 960 ticks. It affects ruler seeks, +clip moves and trims; 1 tick is the finest grid. Existing +/- zoom and scrollbars +retain their behavior. Piano roll and mixer lanes retain their documented 240-tick +grid. Grid/zoom/selection are session UI preferences, not undoable music edits. +Escape cancels a drag; Delete affects the focused editor; text fields keep their +own editing keys. Commands are named in menus/tooltips and timeline focus accepts +keyboard editing; screen-reader and broad accessibility qualification are pending. + +Create/delete/move/trim/duplicate, names/order, graph connects/deletes, notes, +mixer/automation and plugin edits use the 32-edit snapshot history. Ctrl+Z undoes; +Ctrl+Shift+Z or Ctrl+Y redoes. Backend preparation precedes accepting an undo/redo +snapshot; failure retains its cursor/document. Model edits stop playback before +mutation; compilation/file work occurs on the control thread and may pause the +GUI, never block the audio callback. This is not a background-loading editor. + +A successful recorded take adds a reference as one undoable edit. Undo/removing +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`. diff --git a/include/xtc/xtc_project.h b/include/xtc/xtc_project.h index e57ad44..c3a88eb 100644 --- a/include/xtc/xtc_project.h +++ b/include/xtc/xtc_project.h @@ -14,6 +14,12 @@ extern "C" { #define XTC_TICKS_PER_QUARTER 960U #define XTC_PROJECT_MAX_TICK UINT64_C(38400000) typedef struct xtc_project xtc_project; +/* Control-thread edits, transactional validation; duplicate retains complete + * source notes/trim/media references, allocates fresh IDs, copies no audio. */ +xtc_result xtc_project_duplicate_clip(xtc_project *project, uint64_t clip, uint64_t track, uint64_t start, uint64_t *out); +xtc_result xtc_project_rename_track(xtc_project *project, uint64_t track, const char *name); +xtc_result xtc_project_rename_clip(xtc_project *project, uint64_t clip, const char *name); +xtc_result xtc_project_move_track(xtc_project *project, uint64_t track, size_t index); typedef struct { uint64_t id; diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index b7799a7..85d17cd 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -146,6 +146,15 @@ MainWindow::MainWindow(bool prompt_unsaved) sequencer_ = new SequencerWidget(20, 188, project_.get(), seek, this); timeline_scroll->end(); sequencer_->before_edit = before_arrangement_edit; + menu->add("Edit/Duplicate clip",FL_CTRL+'d',[](Fl_Widget *, void *c) { (void)static_cast(c)->duplicate_selection(); },sequencer_); + menu->add("Edit/Rename clip or track",FL_F+2,[](Fl_Widget *, void *c) { + const char *name = fl_input("Name (1..63 UTF-8 bytes)",""); if (name) (void)static_cast(c)->rename_selection(name); + },sequencer_); + for (const char *label : {"View/Snap/1 tick","View/Snap/120 ticks","View/Snap/240 ticks","View/Snap/480 ticks","View/Snap/960 ticks"}) + menu->add(label,0,[](Fl_Widget *widget,void *c) { + auto *m = static_cast(widget); + (void)static_cast(c)->grid(std::strtoull(m->mvalue()->label(),nullptr,10)); + },sequencer_); sequencer_->callback( [](Fl_Widget *, void *c) { static_cast(c)->mark_dirty(); diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index 40cc18a..5beea6a 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -49,7 +50,7 @@ void SequencerWidget::set_project(xtc_project *project, bool reset) uint64_t SequencerWidget::tick(int px) const { return std::min(XTC_PROJECT_MAX_TICK, static_cast(std::max(0, px - x() - 120)) * 960U / - static_cast(scale_) / 240U * 240U); + static_cast(scale_) / grid_ * grid_); } /** @@ -244,7 +245,9 @@ void SequencerWidget::draw() fl_rectf(a, py + 5, b - a, 44); fl_push_clip(a, py + 5, b - a, 44); fl_color(FL_WHITE); - fl_draw(c.midi ? "MIDI" : c.asset_path[0] ? "Audio" : c.name, std::max(a, left + 6), py + 31); + char label[96]; + std::snprintf(label,sizeof(label),"%s: %s",c.midi ? "MIDI" : c.asset_path[0] ? "Audio" : "Region",c.name); + fl_draw(label,std::max(a,left+6),py+31); fl_pop_clip(); } } @@ -264,7 +267,7 @@ void SequencerWidget::draw() */ int SequencerWidget::handle(int event) { - if (event == FL_PUSH && Fl::event_button() == FL_LEFT_MOUSE) { + if (event == FL_PUSH && (Fl::event_button() == FL_LEFT_MOUSE || Fl::event_button() == FL_RIGHT_MOUSE)) { take_focus(); mode_ = 0; if (Fl::event_y() < y() + 28) { @@ -299,6 +302,15 @@ int SequencerWidget::handle(int event) break; } } + if (Fl::event_button() == FL_RIGHT_MOUSE) { + mode_ = 0; + Fl_Menu_Item items[4]{}; + items[0].text = "Duplicate clip (Ctrl+D)"; items[1].text = "Rename (F2)"; items[2].text = "Delete (undoable)"; + const Fl_Menu_Item *picked = items->popup(Fl::event_x(),Fl::event_y()); + if (picked == items) duplicate_selection(); + else if (picked == items+1) { const char *name = fl_input("Name (1..63 UTF-8 bytes)",""); if (name) rename_selection(name); } + else if (picked == items+2) delete_selection(); + } redraw(); return 1; } @@ -307,7 +319,7 @@ int SequencerWidget::handle(int event) if (event == FL_RELEASE && mode_) { int mode = mode_; mode_ = 0; - int64_t delta = static_cast(Fl::event_x() - drag_x_) * 960 / scale_ / 240 * 240; + int64_t delta = static_cast(Fl::event_x() - drag_x_) * 960 / scale_ / static_cast(grid_) * static_cast(grid_); int64_t start = static_cast(original_.start_tick); int64_t length = static_cast(original_.length_tick); int64_t offset = static_cast(original_.offset_tick); @@ -337,6 +349,12 @@ int SequencerWidget::handle(int event) static_cast(length), static_cast(offset))); return 1; } + if (event == FL_KEYDOWN && Fl::event_state(FL_CTRL) && Fl::event_key() == 'd') { duplicate_selection(); return 1; } + if (event == FL_KEYDOWN && Fl::event_key() == FL_F+2) { + const char *name = fl_input("Name (1..63 UTF-8 bytes)",""); if (name) rename_selection(name); return 1; + } + if (event == FL_KEYDOWN && Fl::event_state(FL_ALT) && + (Fl::event_key() == FL_Up || Fl::event_key() == FL_Down)) { move_track(Fl::event_key() == FL_Up ? -1 : 1); return 1; } if (event == FL_KEYDOWN && Fl::event_key() == FL_Escape) { mode_ = 0; return 1; @@ -349,3 +367,34 @@ int SequencerWidget::handle(int event) return 1; return Fl_Widget::handle(event); } + +xtc_result SequencerWidget::grid(uint64_t ticks) +{ + if (ticks != 1 && ticks != 120 && ticks != 240 && ticks != 480 && ticks != 960) return XTC_ERR_INVALID_ARGUMENT; + grid_ = ticks; redraw(); return XTC_OK; +} +xtc_result SequencerWidget::duplicate_selection() +{ + xtc_clip_info c{}; uint64_t id; + xtc_result r = xtc_project_find_clip(project_,clip_,&c); + if (r == XTC_OK && before_edit) r = before_edit(context_); + if (r == XTC_OK) r = xtc_project_duplicate_clip(project_,clip_,c.track_id,c.start_tick+c.length_tick,&id); + if (r == XTC_OK) clip_ = id; + changed(r); return r; +} +xtc_result SequencerWidget::rename_selection(const char *name) +{ + xtc_result r = before_edit ? before_edit(context_) : XTC_OK; + if (r == XTC_OK) r = clip_ != UINT64_MAX ? xtc_project_rename_clip(project_,clip_,name) : xtc_project_rename_track(project_,track_,name); + changed(r); return r; +} +xtc_result SequencerWidget::move_track(int direction) +{ + size_t tracks, clips, i; xtc_project_get_counts(project_,&tracks,&clips); + xtc_track_info track{}; + for (i = 0; i < tracks; ++i) { xtc_project_get_track(project_,i,&track); if (track.id == track_) break; } + if (i == tracks || (direction < 0 ? i == 0 : i+1 == tracks)) return XTC_ERR_NOT_FOUND; + xtc_result r = before_edit ? before_edit(context_) : XTC_OK; + if (r == XTC_OK) r = xtc_project_move_track(project_,track_,direction < 0 ? i-1 : i+1); + changed(r); return r; +} diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index 40dd412..db6385c 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -10,6 +10,11 @@ class SequencerWidget : public Fl_Widget { * * @return XTC_OK to allow the edit, or an error to cancel it. */ + xtc_result duplicate_selection(); + xtc_result rename_selection(const char *name); + xtc_result move_track(int direction); + xtc_result grid(uint64_t ticks); + uint64_t grid() const { return grid_; } using BeforeEdit = xtc_result (*)(void *); BeforeEdit before_edit = nullptr; @@ -129,7 +134,7 @@ class SequencerWidget : public Fl_Widget { xtc_project *project_; Seek seek_; void *context_; - uint64_t track_ = UINT64_MAX, clip_ = UINT64_MAX, cursor_ = 0; + uint64_t track_ = UINT64_MAX, clip_ = UINT64_MAX, cursor_ = 0, grid_ = 240; int scale_ = 48, drag_x_ = 0, mode_ = 0; xtc_clip_info original_{}; /** diff --git a/src/project/project.c b/src/project/project.c index 2b8c2f4..85a38a0 100644 --- a/src/project/project.c +++ b/src/project/project.c @@ -600,3 +600,49 @@ xtc_result xtc_project_set_clip_asset(xtc_project *project, uint64_t id, const c memmove(project->clips[index].asset_path, path, length + 1U); return XTC_OK; } + +xtc_result xtc_project_duplicate_clip(xtc_project *p, uint64_t id, uint64_t track, uint64_t start, uint64_t *out) +{ + size_t index, i, count = 0, original_notes; + xtc_clip_info c; + if (!p || !out) return XTC_ERR_INVALID_ARGUMENT; + index = clip_index(p,id); + if (index == SIZE_MAX || track_index(p,track) == SIZE_MAX) return XTC_ERR_NOT_FOUND; + c = p->clips[index]; + if (!valid_range(start,c.length_tick,c.offset_tick,c.source_length_tick)) return XTC_ERR_INVALID_ARGUMENT; + for (i = 0; i < p->note_count; ++i) if (p->notes[i].clip_id == id) ++count; + if (p->clip_count == XTC_PROJECT_MAX_CLIPS || p->next_clip_id == UINT64_MAX || + count > XTC_PROJECT_MAX_NOTES-p->note_count || p->next_note_id > UINT64_MAX-count) return XTC_ERR_CAPACITY; + c.id = p->next_clip_id++; c.track_id = track; c.start_tick = start; + p->clips[p->clip_count++] = c; + original_notes = p->note_count; + for (i = 0; i < original_notes; ++i) if (p->notes[i].clip_id == id) { + xtc_note_info n = p->notes[i]; n.id = p->next_note_id++; n.clip_id = c.id; + p->notes[p->note_count++] = n; + } + *out = c.id; return XTC_OK; +} +xtc_result xtc_project_rename_track(xtc_project *p, uint64_t id, const char *name) +{ + size_t i; + if (!p || !xtc_project_valid_name(name)) return XTC_ERR_INVALID_ARGUMENT; + i = track_index(p,id); if (i == SIZE_MAX) return XTC_ERR_NOT_FOUND; + memmove(p->tracks[i].name,name,strlen(name)+1); return XTC_OK; +} +xtc_result xtc_project_rename_clip(xtc_project *p, uint64_t id, const char *name) +{ + size_t i; + if (!p || !xtc_project_valid_name(name)) return XTC_ERR_INVALID_ARGUMENT; + i = clip_index(p,id); if (i == SIZE_MAX) return XTC_ERR_NOT_FOUND; + memmove(p->clips[i].name,name,strlen(name)+1); return XTC_OK; +} +xtc_result xtc_project_move_track(xtc_project *p, uint64_t id, size_t destination) +{ + size_t i; xtc_track_info track; + if (!p || destination >= p->track_count) return XTC_ERR_INVALID_ARGUMENT; + i = track_index(p,id); if (i == SIZE_MAX) return XTC_ERR_NOT_FOUND; + track = p->tracks[i]; + if (i < destination) memmove(p->tracks+i,p->tracks+i+1,(destination-i)*sizeof(track)); + else if (i > destination) memmove(p->tracks+destination+1,p->tracks+destination,(i-destination)*sizeof(track)); + p->tracks[destination] = track; return XTC_OK; +} diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index d01ce3c..9776e71 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -457,6 +457,28 @@ int main(int argc, char **argv) window.do_callback(); } CHECK(alive == 0U); + reset(); + { + MainWindow window(false); auto *s = timeline(&window); + CHECK(s->add_track() == XTC_OK && s->rename_selection("Keys") == XTC_OK); + CHECK(s->add_region() == XTC_OK && s->rename_selection("Verse") == XTC_OK); + CHECK(s->grid(120) == XTC_OK && s->grid() == 120 && s->grid(0) == XTC_ERR_INVALID_ARGUMENT); + CHECK(s->handle(FL_FOCUS) == 1); + int old_key = Fl::e_keysym, old_state = Fl::e_state; + Fl::e_keysym = 'd'; Fl::e_state = FL_CTRL; + CHECK(s->handle(FL_KEYDOWN) == 1); + Fl::e_keysym = old_key; Fl::e_state = old_state; + size_t tracks, clips; xtc_project_get_counts(s->project(),&tracks,&clips); + CHECK(clips == 2); + xtc_clip_info c{}; CHECK(xtc_project_get_clip(s->project(),1,&c) == XTC_OK && c.start_tick == 3840 && !std::strcmp(c.name,"Verse")); + CHECK(window.undo(false) == XTC_OK); xtc_project_get_counts(s->project(),&tracks,&clips); CHECK(clips == 1); + CHECK(window.undo(true) == XTC_OK); xtc_project_get_counts(s->project(),&tracks,&clips); CHECK(clips == 2); + CHECK(s->add_track() == XTC_OK && s->rename_selection("Bass") == XTC_OK && s->move_track(-1) == XTC_OK); + xtc_track_info t{}; CHECK(xtc_project_get_track(s->project(),0,&t) == XTC_OK && !std::strcmp(t.name,"Bass")); + CHECK(window.undo(false) == XTC_OK); + CHECK(xtc_project_get_track(s->project(),0,&t) == XTC_OK && !std::strcmp(t.name,"Keys")); + window.do_callback(); + } if (const char *module = std::getenv("XTC_TEST_PLUGIN")) { reset(); MainWindow window(false); diff --git a/tests/unit/test_editing.c b/tests/unit/test_editing.c new file mode 100644 index 0000000..7fafe65 --- /dev/null +++ b/tests/unit/test_editing.c @@ -0,0 +1,42 @@ +#include "xtc/xtc_midi.h" +#include "xtc/xtc_mixer.h" +#include +#include +#include +int main(void) +{ + xtc_project *p = NULL, *undo = NULL; xtc_history *h = NULL; + uint64_t track, second, clip, duplicate, note; size_t notes, tracks, clips; + xtc_clip_info c; xtc_note_info n, original; + assert(xtc_project_create(&p) == XTC_OK); + assert(xtc_project_add_track(p,"Keys",&track) == XTC_OK && xtc_project_add_track(p,"Bass",&second) == XTC_OK); + assert(xtc_project_add_midi_clip(p,track,0,3840,&clip) == XTC_OK); + assert(xtc_project_add_note(p,clip,0,960,69,100,1,¬e) == XTC_OK); + assert(xtc_project_edit_clip(p,clip,track,960,1920,960) == XTC_OK); + assert(xtc_project_get_note(p,0,&original) == XTC_OK); + assert(xtc_history_create(p,&h) == XTC_OK); + assert(xtc_project_duplicate_clip(p,clip,second,3840,&duplicate) == XTC_OK && duplicate != clip); + assert(xtc_project_find_clip(p,duplicate,&c) == XTC_OK && c.offset_tick == 960 && c.source_length_tick == 3840 && c.midi); + assert(xtc_project_get_note(p,1,&n) == XTC_OK && n.clip_id == duplicate && n.id != original.id && n.start_tick == original.start_tick); + assert(xtc_project_rename_clip(p,duplicate,"Chorus ä") == XTC_OK); + assert(xtc_project_rename_track(p,second,"Bass 2") == XTC_OK); + assert(xtc_project_move_track(p,second,0) == XTC_OK); + assert(xtc_history_record(h,p) == XTC_OK && xtc_history_checkout(h,0,&undo) == XTC_OK); + assert(xtc_project_find_clip(undo,duplicate,&c) == XTC_ERR_NOT_FOUND); + assert(xtc_project_get_counts(undo,&tracks,&clips) == XTC_OK && clips == 1); + xtc_project_destroy(undo); + assert(xtc_project_duplicate_clip(p,clip,track,XTC_PROJECT_MAX_TICK,&duplicate) == XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_rename_track(p,track,"Bad\n") == XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_clone(p,&undo) == XTC_OK); + xtc_project_destroy(undo); + for (;;) { + assert(xtc_project_note_count(p,¬es) == XTC_OK); + if (notes == XTC_PROJECT_MAX_NOTES) break; + assert(xtc_project_add_note(p,clip,0,1,60,100,0,¬e) == XTC_OK); + } + assert(xtc_project_duplicate_clip(p,clip,track,0,&duplicate) == XTC_ERR_CAPACITY); + assert(xtc_project_get_counts(p,&tracks,&clips) == XTC_OK && clips == 2); + xtc_history_destroy(h); xtc_project_destroy(p); + puts("Duplicate source notes/trim, naming/order, undo and capacity rollback passed."); + return 0; +}