1
0
Fork 0
forked from hanez/xtc

Phase 17: add undoable duplication, naming, ordering and timeline commands

This commit is contained in:
Johannes Findeisen 2026-10-27 06:28:25 +01:00
commit 440e98f2b5
10 changed files with 231 additions and 5 deletions

View file

@ -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

View file

@ -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.

View file

@ -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`.

View file

@ -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;

View file

@ -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<SequencerWidget *>(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<SequencerWidget *>(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<Fl_Menu_Bar *>(widget);
(void)static_cast<SequencerWidget *>(c)->grid(std::strtoull(m->mvalue()->label(),nullptr,10));
},sequencer_);
sequencer_->callback(
[](Fl_Widget *, void *c) {
static_cast<MainWindow *>(c)->mark_dirty();

View file

@ -4,6 +4,7 @@
#include <FL/Fl_Scroll.H>
#include <FL/fl_draw.H>
#include <FL/fl_ask.H>
#include <FL/Fl_Menu_Item.H>
#include <algorithm>
#include <cstdio>
@ -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<uint64_t>(std::max(0, px - x() - 120)) * 960U /
static_cast<unsigned int>(scale_) / 240U * 240U);
static_cast<unsigned int>(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<int64_t>(Fl::event_x() - drag_x_) * 960 / scale_ / 240 * 240;
int64_t delta = static_cast<int64_t>(Fl::event_x() - drag_x_) * 960 / scale_ / static_cast<int64_t>(grid_) * static_cast<int64_t>(grid_);
int64_t start = static_cast<int64_t>(original_.start_tick);
int64_t length = static_cast<int64_t>(original_.length_tick);
int64_t offset = static_cast<int64_t>(original_.offset_tick);
@ -337,6 +349,12 @@ int SequencerWidget::handle(int event)
static_cast<uint64_t>(length), static_cast<uint64_t>(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;
}

View file

@ -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_{};
/**

View file

@ -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;
}

View file

@ -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);

42
tests/unit/test_editing.c Normal file
View file

@ -0,0 +1,42 @@
#include "xtc/xtc_midi.h"
#include "xtc/xtc_mixer.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
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,&note) == 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,&notes) == XTC_OK);
if (notes == XTC_PROJECT_MAX_NOTES) break;
assert(xtc_project_add_note(p,clip,0,1,60,100,0,&note) == 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;
}