diff --git a/Makefile b/Makefile index 9780124..82b3b56 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/docs/MODERN_EDITING.md b/docs/MODERN_EDITING.md index 3428bbd..daaf623 100644 --- a/docs/MODERN_EDITING.md +++ b/docs/MODERN_EDITING.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 78e92ff..6539eeb 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. --- diff --git a/include/xtc/xtc_editing.h b/include/xtc/xtc_editing.h new file mode 100644 index 0000000..60b6b93 --- /dev/null +++ b/include/xtc/xtc_editing.h @@ -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 diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 6fabaf8..9a63281 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -320,6 +320,8 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) (void)static_cast(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(c)->nudge_selection(-1);},sequencer_); + menu->add("Edit/Nudge selection right",0,[](Fl_Widget *,void *c){static_cast(c)->nudge_selection(1);},sequencer_); menu->add("Edit/Select all clips",0,[](Fl_Widget *,void *c){static_cast(c)->select_all_clips();},sequencer_); menu->add("Edit/Duplicate clip",FL_CTRL+'d',[](Fl_Widget *, void *c) { (void)static_cast(c)->duplicate_selection(); },sequencer_); menu->add("Edit/Split audio at cursor", 0, [](Fl_Widget *, void *c) { (void)static_cast(c)->split_selection(); }, sequencer_); diff --git a/src/gui/sequencer_widget.cpp b/src/gui/sequencer_widget.cpp index ef2a268..7a0a545 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -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 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{} : 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(grid_) : static_cast(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(end)-static_cast(begin)); } xtc_result SequencerWidget::rename_selection(const char *name) { diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index 6625459..c268c0e 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -3,6 +3,7 @@ #include #include #include "xtc/xtc_plugin.h" +#include "xtc/xtc_editing.h" #include #include "xtc/xtc_audio.h" #include @@ -36,6 +37,8 @@ class SequencerWidget : public Fl_Group { void select_all_clips(); bool clip_selected(uint64_t clip) const; std::vector 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); diff --git a/src/project/editing.c b/src/project/editing.c new file mode 100644 index 0000000..1ecfff4 --- /dev/null +++ b/src/project/editing.c @@ -0,0 +1,40 @@ +#include "project_internal.h" +#include "xtc/xtc_editing.h" +#include +#include + +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;igraph;*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(operationXTC_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,©); + for(i=0;r==XTC_OK && i(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; +} diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index a02c7bd..e5e8ee6 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -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; diff --git a/tests/unit/test_modern_editing.c b/tests/unit/test_modern_editing.c new file mode 100644 index 0000000..cda1a5a --- /dev/null +++ b/tests/unit/test_modern_editing.c @@ -0,0 +1,28 @@ +#include "xtc/xtc_editing.h" +#include +#include + +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,¬es)==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; +}