feat: edit arrangement selections atomically with grouped undo

This commit is contained in:
Johannes Findeisen 2026-11-06 00:13:48 +01:00
commit 7379545ced
10 changed files with 139 additions and 25 deletions

View file

@ -27,7 +27,7 @@ CXXFLAGS ?= -O2 -g
CXXFLAGS += -std=c++17 -Wall -Wextra -Wpedantic -Iinclude
LDFLAGS ?=
CORE_SOURCES := src/audio/export.c src/plugin/builtin.c src/settings/settings.c src/control/model.c src/audio/waveform.c src/plugin/ladspa.c src/plugin/host.c src/plugin/modern.c src/native/processor.c src/audio/recovery.c src/dsp/ramp.c src/native/native.c src/mixer/model.c src/mixer/render.c src/project/history.c src/midi/model.c src/midi/instrument.c src/audio/wave.c src/audio/arrangement.c src/audio/recorder.c src/core/xtc.c src/project/project.c src/persistence/project_file.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c
CORE_SOURCES := src/project/editing.c src/audio/export.c src/plugin/builtin.c src/settings/settings.c src/control/model.c src/audio/waveform.c src/plugin/ladspa.c src/plugin/host.c src/plugin/modern.c src/native/processor.c src/audio/recovery.c src/dsp/ramp.c src/native/native.c src/mixer/model.c src/mixer/render.c src/project/history.c src/midi/model.c src/midi/instrument.c src/audio/wave.c src/audio/arrangement.c src/audio/recorder.c src/core/xtc.c src/project/project.c src/persistence/project_file.c src/graph/graph.c src/dsp/gain.c src/engine/offline_engine.c src/engine/realtime.c src/backend/jack_backend.c
JACK_AVAILABLE := $(shell $(PKG_CONFIG) --exists jack 2>/dev/null && echo yes)
ifeq ($(JACK_AVAILABLE),yes)
JACK_CFLAGS := $(shell $(PKG_CONFIG) --cflags jack)
@ -104,7 +104,7 @@ $(BUILD_DIR)/test_native: $(BUILD_DIR)/tests/unit/test_native.o $(BUILD_DIR)/lib
$(BUILD_DIR)/native-gain: $(BUILD_DIR)/examples/native_gain.o $(BUILD_DIR)/libxtc.a
$(CC) $(LDFLAGS) -o $@ $^ -lm -ldl
test: $(BUILD_DIR)/test_native $(BUILD_DIR)/native-gain $(BUILD_DIR)/test_mixer $(BUILD_DIR)/test_midi $(BUILD_DIR)/test_audio $(BUILD_DIR)/test_project $(BUILD_DIR)/test_graph_edits $(BACKEND_TEST) $(BUILD_DIR)/test_realtime $(BUILD_DIR)/test_core $(BUILD_DIR)/test_offline $(BUILD_DIR)/xtc-check
test: test-modern-editing $(BUILD_DIR)/test_native $(BUILD_DIR)/native-gain $(BUILD_DIR)/test_mixer $(BUILD_DIR)/test_midi $(BUILD_DIR)/test_audio $(BUILD_DIR)/test_project $(BUILD_DIR)/test_graph_edits $(BACKEND_TEST) $(BUILD_DIR)/test_realtime $(BUILD_DIR)/test_core $(BUILD_DIR)/test_offline $(BUILD_DIR)/xtc-check
$(if $(BACKEND_TEST),./$(BACKEND_TEST),@echo "JACK fault tests skipped: JACK development files unavailable")
./$(BUILD_DIR)/test_native
./$(BUILD_DIR)/native-gain
@ -534,3 +534,10 @@ $(BUILD_DIR)/builtin-preview: $(BUILD_DIR)/examples/builtin_preview.o $(BUILD_DI
builtin-preview: $(BUILD_DIR)/builtin-preview
./$(BUILD_DIR)/builtin-preview "$(abspath $(BUILD_DIR))/builtin"
-include $(BUILD_DIR)/examples/builtin_preview.d
$(BUILD_DIR)/test_modern_editing: tests/unit/test_modern_editing.c $(BUILD_DIR)/libxtc.a
$(CC) $(CPPFLAGS) $(CFLAGS) $(LDFLAGS) -o $@ $< $(BUILD_DIR)/libxtc.a -lm -ldl
.PHONY: test-modern-editing
test-modern-editing: $(BUILD_DIR)/test_modern_editing
./$(BUILD_DIR)/test_modern_editing

View file

@ -7,3 +7,7 @@ continue to consume prepared plans; edits use the existing stop/history workflow
## Phase 47
Shift-click clips to toggle selection; Ctrl+A in the sequencer or Edit/Select all clips selects every clip. Plain clicking replaces the selection. Selection is session UI state; no marquee selection yet.
## Phase 48
Use Left/Right in the sequencer (or Edit/Nudge selection) to move the selected phrase by the current snap step. Ctrl+D duplicates after its complete bounding span; Delete removes selected clips. Edits are atomic and undoable. Mouse dragging remains a single-clip gesture.

View file

@ -1333,12 +1333,12 @@ Implemented: additive Shift-click selection, Ctrl+A/Select all and selected clip
Move, duplicate and delete selected clips atomically, preserving relative positions and a single undo step.
### Tasks and acceptance criteria
- [ ] Provide the control/model behavior and accessible GUI commands.
- [ ] Validate bounds and failure rollback; preserve existing project compatibility and callback safety.
- [ ] Test the new workflow and document scope and limitations.
- [x] Provide the control/model behavior and accessible GUI commands.
- [x] Validate bounds and failure rollback; preserve existing project compatibility and callback safety.
- [x] Test the new workflow and document scope and limitations.
### Implementation status
Planned.
Implemented: atomic C99 batch move/duplicate/delete APIs and GUI selection commands. One published edit preserves relative spacing, fresh duplicate IDs and single undo history entries. Core and GUI tests pass, including negative-position rollback and invalid selections.
---

19
include/xtc/xtc_editing.h Normal file
View file

@ -0,0 +1,19 @@
#ifndef XTC_EDITING_H
#define XTC_EDITING_H
#include "xtc_midi.h"
#ifdef __cplusplus
extern "C" {
#endif
/* Control thread only. Nonempty unique stable IDs, at most MAX_CLIPS.
* All operations clone/validate before publication: errors preserve the project,
* ID counters and optional count-element output. No media loading or DSP work.
* MOVE/DUPLICATE amount is a signed tick displacement, bounded by MAX_TICK.
* DUPLICATE retains tracks, source ranges, notes and media with fresh IDs.
* out_ids receives resulting IDs (removed IDs for DELETE). May be NULL. */
typedef enum { XTC_CLIPS_MOVE, XTC_CLIPS_DUPLICATE, XTC_CLIPS_DELETE } xtc_clip_operation;
xtc_result xtc_project_transform_clips(xtc_project *project,const uint64_t *ids,size_t count,
xtc_clip_operation operation,int64_t amount,uint64_t *out_ids);
#ifdef __cplusplus
}
#endif
#endif

View file

@ -320,6 +320,8 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration)
(void)static_cast<MainWindow *>(c)->sequencer_->grid(xtc_ui::snap_ticks[value]);
},this);
Fl_Group::current(parent);
menu->add("Edit/Nudge selection left",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->nudge_selection(-1);},sequencer_);
menu->add("Edit/Nudge selection right",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->nudge_selection(1);},sequencer_);
menu->add("Edit/Select all clips",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->select_all_clips();},sequencer_);
menu->add("Edit/Duplicate clip",FL_CTRL+'d',[](Fl_Widget *, void *c) { (void)static_cast<SequencerWidget *>(c)->duplicate_selection(); },sequencer_);
menu->add("Edit/Split audio at cursor", 0, [](Fl_Widget *, void *c) { (void)static_cast<SequencerWidget *>(c)->split_selection(); }, sequencer_);

View file

@ -209,19 +209,11 @@ xtc_result SequencerWidget::add_midi_clip()
*/
xtc_result SequencerWidget::delete_selection()
{
if (before_edit) {
xtc_result r = before_edit(context_);
if (r != XTC_OK) {
changed(r);
return r;
}
}
xtc_result r =
clip_ != UINT64_MAX ? xtc_project_remove_clip(project_, clip_) : xtc_project_remove_track(project_, track_);
if (r == XTC_OK)
clip_ = track_ = UINT64_MAX;
changed(r);
return r;
if(!selected_clips().empty())return transform_selection(XTC_CLIPS_DELETE,0);
xtc_result r=before_edit ? before_edit(context_) : XTC_OK;
if(r==XTC_OK)r=xtc_project_remove_track(project_,track_);
if(r==XTC_OK)track_=clip_=UINT64_MAX;
changed(r);return r;
}
/**
@ -497,6 +489,7 @@ int SequencerWidget::handle(int event)
if (Fl::event_key() == 's') { split_selection(); return 1; }
if (Fl::event_key() == 'a') { audio_properties_dialog(); return 1; }
}
if(event==FL_KEYDOWN && !Fl::event_state(FL_CTRL|FL_ALT|FL_SHIFT) && (Fl::event_key()==FL_Left || Fl::event_key()==FL_Right)) {nudge_selection(Fl::event_key()==FL_Left ? -1 : 1);return 1;}
if (event == FL_KEYDOWN && Fl::event_state(FL_CTRL) && Fl::event_key() == 'a') {select_all_clips();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) {
@ -548,14 +541,27 @@ xtc_result SequencerWidget::grid(uint64_t ticks)
if (xtc_ui::snap_index(ticks) < 0) return XTC_ERR_INVALID_ARGUMENT;
grid_ = ticks; redraw(); return XTC_OK;
}
xtc_result SequencerWidget::transform_selection(xtc_clip_operation op,int64_t amount)
{
auto ids=selected_clips();std::vector<uint64_t> output(ids.size());
xtc_result r=ids.empty() ? XTC_ERR_NOT_FOUND : XTC_OK;
if(r==XTC_OK && before_edit)r=before_edit(context_);
if(r==XTC_OK)r=xtc_project_transform_clips(project_,ids.data(),ids.size(),op,amount,output.data());
if(r==XTC_OK) {
selection_=op==XTC_CLIPS_DELETE ? std::vector<uint64_t>{} : output;
clip_=selection_.empty() ? UINT64_MAX : selection_.back();
}
changed(r);return r;
}
xtc_result SequencerWidget::nudge_selection(int direction)
{
return transform_selection(XTC_CLIPS_MOVE,direction<0 ? -static_cast<int64_t>(grid_) : static_cast<int64_t>(grid_));
}
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;
auto ids=selected_clips();uint64_t begin=XTC_PROJECT_MAX_TICK,end=0;
for(auto id:ids){xtc_clip_info c{};xtc_project_find_clip(project_,id,&c);begin=std::min(begin,c.start_tick);end=std::max(end,c.start_tick+c.length_tick);}
return transform_selection(XTC_CLIPS_DUPLICATE,static_cast<int64_t>(end)-static_cast<int64_t>(begin));
}
xtc_result SequencerWidget::rename_selection(const char *name)
{

View file

@ -3,6 +3,7 @@
#include <FL/Fl_Group.H>
#include <FL/Fl_Choice.H>
#include "xtc/xtc_plugin.h"
#include "xtc/xtc_editing.h"
#include <array>
#include "xtc/xtc_audio.h"
#include <memory>
@ -36,6 +37,8 @@ class SequencerWidget : public Fl_Group {
void select_all_clips();
bool clip_selected(uint64_t clip) const;
std::vector<uint64_t> selected_clips() const;
xtc_result transform_selection(xtc_clip_operation operation,int64_t amount);
xtc_result nudge_selection(int direction);
xtc_result duplicate_selection();
xtc_result split_selection();
xtc_result audio_properties(float gain, uint64_t fade_in, uint64_t fade_out);

40
src/project/editing.c Normal file
View file

@ -0,0 +1,40 @@
#include "project_internal.h"
#include "xtc/xtc_editing.h"
#include <stdlib.h>
#include <string.h>
static xtc_result selection_valid(const xtc_project *p,const uint64_t *ids,size_t count)
{
size_t i,j;xtc_clip_info c;
if(!p || !ids || !count || count>XTC_PROJECT_MAX_CLIPS)return XTC_ERR_INVALID_ARGUMENT;
for(i=0;i<count;++i) {
if(xtc_project_find_clip(p,ids[i],&c)!=XTC_OK)return XTC_ERR_NOT_FOUND;
for(j=0;j<i;++j)if(ids[i]==ids[j])return XTC_ERR_INVALID_ARGUMENT;
}
return XTC_OK;
}
static void publish(xtc_project *p,xtc_project *copy)
{
xtc_graph *old=p->graph;*p=*copy;copy->graph=old;
}
xtc_result xtc_project_transform_clips(xtc_project *p,const uint64_t *ids,size_t count,
xtc_clip_operation operation,int64_t amount,uint64_t *out_ids)
{
xtc_project *copy=NULL;size_t i;uint64_t output[XTC_PROJECT_MAX_CLIPS];
xtc_result r=selection_valid(p,ids,count);
if(r!=XTC_OK)return r;
if(operation<XTC_CLIPS_MOVE || operation>XTC_CLIPS_DELETE ||
amount<-(int64_t)XTC_PROJECT_MAX_TICK || amount>(int64_t)XTC_PROJECT_MAX_TICK)return XTC_ERR_INVALID_ARGUMENT;
r=xtc_project_clone(p,&copy);
for(i=0;r==XTC_OK && i<count;++i) {
xtc_clip_info c;int64_t start;
r=xtc_project_find_clip(copy,ids[i],&c);if(r!=XTC_OK)break;
start=(int64_t)c.start_tick+amount;output[i]=ids[i];
if(operation==XTC_CLIPS_DELETE)r=xtc_project_remove_clip(copy,ids[i]);
else if(start<0 || start>(int64_t)XTC_PROJECT_MAX_TICK)r=XTC_ERR_INVALID_ARGUMENT;
else if(operation==XTC_CLIPS_DUPLICATE)r=xtc_project_duplicate_clip(copy,ids[i],c.track_id,(uint64_t)start,output+i);
else r=xtc_project_edit_clip(copy,ids[i],c.track_id,(uint64_t)start,c.length_tick,c.offset_tick);
}
if(r==XTC_OK){publish(p,copy);if(out_ids)memcpy(out_ids,output,count*sizeof(*output));}
xtc_project_destroy(copy);return r;
}

View file

@ -1315,6 +1315,11 @@ int main(int argc, char **argv)
CHECK(s->clip_selected(first) && s->clip_selected(second));CHECK(!s->toggle_clip(UINT64_MAX));
window.detach_editor(0,true);CHECK(s->selected_clips().size()==2);window.detach_editor(0,false);
CHECK(s->toggle_clip(first) && !s->clip_selected(first));s->select_all_clips();CHECK(s->selected_clips().size()==2);
CHECK(s->nudge_selection(1)==XTC_OK);xtc_clip_info moved{};
CHECK(xtc_project_find_clip(s->project(),first,&moved)==XTC_OK && moved.start_tick==240);
CHECK(s->duplicate_selection()==XTC_OK && s->selected_clips().size()==2);
size_t tc=0,cc=0;xtc_project_get_counts(s->project(),&tc,&cc);CHECK(cc==4);
CHECK(s->delete_selection()==XTC_OK);xtc_project_get_counts(s->project(),&tc,&cc);CHECK(cc==2);
window.do_callback();
}
if (failures != 0) return 1;

View file

@ -0,0 +1,28 @@
#include "xtc/xtc_editing.h"
#include <assert.h>
#include <stdio.h>
int main(void)
{
xtc_project *p=NULL;uint64_t track,a,b,n,ids[2],out[2]={999,999};xtc_clip_info c;
size_t tracks,clips,notes;
assert(xtc_project_create(&p)==XTC_OK);
assert(xtc_project_add_track(p,"Keys",&track)==XTC_OK);
assert(xtc_project_add_midi_clip(p,track,960,1920,&a)==XTC_OK);
assert(xtc_project_add_note(p,a,0,240,60,100,0,&n)==XTC_OK);
assert(xtc_project_add_clip(p,track,"Audio",3840,960,&b)==XTC_OK);
ids[0]=a;ids[1]=b;
assert(xtc_project_transform_clips(p,ids,2,XTC_CLIPS_MOVE,-1920,out)==XTC_ERR_INVALID_ARGUMENT);
assert(out[0]==999 && xtc_project_find_clip(p,b,&c)==XTC_OK && c.start_tick==3840);
assert(xtc_project_transform_clips(p,ids,2,XTC_CLIPS_MOVE,240,out)==XTC_OK);
assert(xtc_project_find_clip(p,a,&c)==XTC_OK && c.start_tick==1200);
assert(xtc_project_transform_clips(p,ids,2,XTC_CLIPS_DUPLICATE,4800,out)==XTC_OK);
assert(out[0]!=a && out[1]!=b);
assert(xtc_project_note_count(p,&notes)==XTC_OK && notes==2);
assert(xtc_project_transform_clips(p,out,2,XTC_CLIPS_DELETE,0,NULL)==XTC_OK);
assert(xtc_project_get_counts(p,&tracks,&clips)==XTC_OK && clips==2);
ids[1]=a;assert(xtc_project_transform_clips(p,ids,2,XTC_CLIPS_DELETE,0,NULL)==XTC_ERR_INVALID_ARGUMENT);
ids[1]=UINT64_MAX;assert(xtc_project_transform_clips(p,ids,2,XTC_CLIPS_MOVE,1,NULL)==XTC_ERR_NOT_FOUND);
assert(xtc_project_transform_clips(p,ids,0,XTC_CLIPS_MOVE,1,NULL)==XTC_ERR_INVALID_ARGUMENT);
xtc_project_destroy(p);puts("Modern editing tests passed.");return 0;
}