diff --git a/docs/MODERN_EDITING.md b/docs/MODERN_EDITING.md index daaf623..7b94e01 100644 --- a/docs/MODERN_EDITING.md +++ b/docs/MODERN_EDITING.md @@ -11,3 +11,7 @@ Shift-click clips to toggle selection; Ctrl+A in the sequencer or Edit/Select al ## 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. + +## Phase 49 + +Ctrl+C/Ctrl+V in the sequencer, or Edit/Copy clips and Paste clips at cursor, copy/paste the selection. Earliest clip lands on the cursor. The earliest source track maps to the currently selected destination track; intervening track spacing is retained. All required tracks must already exist and must not be buses. Instruments, automation and media bytes are not copied. The clipboard survives document changes within the window but is not the OS clipboard. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6539eeb..7faaa6c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1348,12 +1348,12 @@ Implemented: atomic C99 batch move/duplicate/delete APIs and GUI selection comma Copy selected clips into an independent session clipboard and paste at the cursor with fresh IDs, notes and media references. ### 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: independent control-thread clipboard snapshots, fresh-ID paste at the cursor and relative existing-track mapping. Notes, source trims, audio properties and references survive copying independently of later edits. Core rollback/lifetime and GUI copy/paste tests pass. --- diff --git a/include/xtc/xtc_editing.h b/include/xtc/xtc_editing.h index 60b6b93..3f18987 100644 --- a/include/xtc/xtc_editing.h +++ b/include/xtc/xtc_editing.h @@ -13,6 +13,17 @@ extern "C" { 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); +/* Independent clipboard snapshot; captures notes/trim/media references, no media + * bytes or instrument assignments. Paste maps source track spacing to existing + * destination tracks beginning at destination_track; rejects missing/bus tracks. + * start is the new earliest clip position. No OS clipboard integration. + * out_ids, if non-NULL, has clipboard_count elements; unchanged on failure. */ +typedef struct xtc_clipboard xtc_clipboard; +xtc_result xtc_clipboard_create(const xtc_project *project,const uint64_t *ids,size_t count,xtc_clipboard **out); +void xtc_clipboard_destroy(xtc_clipboard *clipboard); +size_t xtc_clipboard_count(const xtc_clipboard *clipboard); +xtc_result xtc_clipboard_paste(const xtc_clipboard *clipboard,xtc_project *project, + uint64_t destination_track,uint64_t start,uint64_t *out_ids); #ifdef __cplusplus } #endif diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index 9a63281..bb7ccc1 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -322,6 +322,8 @@ MainWindow::MainWindow(bool prompt_unsaved,const char *configuration) 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/Copy clips",0,[](Fl_Widget *,void *c){static_cast(c)->copy_selection();},sequencer_); + menu->add("Edit/Paste clips at cursor",0,[](Fl_Widget *,void *c){static_cast(c)->paste_selection();},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 7a0a545..e1fe444 100644 --- a/src/gui/sequencer_widget.cpp +++ b/src/gui/sequencer_widget.cpp @@ -490,6 +490,8 @@ int SequencerWidget::handle(int event) 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()=='c'){copy_selection();return 1;} + if(event==FL_KEYDOWN && Fl::event_state(FL_CTRL) && Fl::event_key()=='v'){paste_selection();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) { @@ -553,6 +555,24 @@ xtc_result SequencerWidget::transform_selection(xtc_clip_operation op,int64_t am } changed(r);return r; } +xtc_result SequencerWidget::copy_selection() +{ + auto ids=selected_clips();xtc_clipboard *copy=nullptr; + xtc_result r=xtc_clipboard_create(project_,ids.data(),ids.size(),©); + if(r==XTC_OK)clipboard_.reset(copy); + else fl_alert("Copy unchanged: %s",xtc_result_string(r)); + return r; +} +xtc_result SequencerWidget::paste_selection() +{ + const uint64_t start=cursor_,track=track_; + std::vector output(xtc_clipboard_count(clipboard_.get())); + xtc_result r=output.empty() ? XTC_ERR_INVALID_STATE : XTC_OK; + if(r==XTC_OK && before_edit)r=before_edit(context_); + if(r==XTC_OK)r=xtc_clipboard_paste(clipboard_.get(),project_,track,start,output.data()); + if(r==XTC_OK){selection_=output;clip_=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_)); diff --git a/src/gui/sequencer_widget.hpp b/src/gui/sequencer_widget.hpp index c268c0e..f079e70 100644 --- a/src/gui/sequencer_widget.hpp +++ b/src/gui/sequencer_widget.hpp @@ -38,6 +38,8 @@ class SequencerWidget : public Fl_Group { 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 copy_selection(); + xtc_result paste_selection(); xtc_result nudge_selection(int direction); xtc_result duplicate_selection(); xtc_result split_selection(); @@ -176,6 +178,7 @@ class SequencerWidget : public Fl_Group { }; std::vector waves_; std::vector selection_; + std::unique_ptr clipboard_{nullptr,xtc_clipboard_destroy}; void draw_waveform(const xtc_clip_info &clip, int left, int top); xtc_project *project_; Seek seek_; diff --git a/src/project/editing.c b/src/project/editing.c index 1ecfff4..e6f159e 100644 --- a/src/project/editing.c +++ b/src/project/editing.c @@ -38,3 +38,53 @@ xtc_result xtc_project_transform_clips(xtc_project *p,const uint64_t *ids,size_t if(r==XTC_OK){publish(p,copy);if(out_ids)memcpy(out_ids,output,count*sizeof(*output));} xtc_project_destroy(copy);return r; } + +struct xtc_clipboard {xtc_project *source;size_t count,first_track;uint64_t begin,ids[XTC_PROJECT_MAX_CLIPS];}; +void xtc_clipboard_destroy(xtc_clipboard *b) +{ + if(b){xtc_project_destroy(b->source);free(b);} +} +size_t xtc_clipboard_count(const xtc_clipboard *b){return b ? b->count : 0;} +xtc_result xtc_clipboard_create(const xtc_project *p,const uint64_t *ids,size_t count,xtc_clipboard **out) +{ + xtc_clipboard *b;size_t i,j;xtc_result r; + if(!out)return XTC_ERR_INVALID_ARGUMENT; + r=selection_valid(p,ids,count);if(r!=XTC_OK)return r; + b=calloc(1,sizeof(*b));if(!b)return XTC_ERR_OUT_OF_MEMORY; + r=xtc_project_clone(p,&b->source); + if(r!=XTC_OK){free(b);return r;} + b->count=count;b->begin=XTC_PROJECT_MAX_TICK;b->first_track=p->track_count; + memcpy(b->ids,ids,count*sizeof(*ids)); + for(i=0;ibegin)b->begin=c.start_tick; + for(j=0;jtrack_count;++j)if(p->tracks[j].id==c.track_id && jfirst_track)b->first_track=j; + } + *out=b;return XTC_OK; +} +xtc_result xtc_clipboard_paste(const xtc_clipboard *b,xtc_project *p,uint64_t track,uint64_t start,uint64_t *out_ids) +{ + xtc_project *copy=NULL;size_t base,i,j,k;uint64_t output[XTC_PROJECT_MAX_CLIPS];xtc_result r; + if(!b || !p || start>XTC_PROJECT_MAX_TICK)return XTC_ERR_INVALID_ARGUMENT; + for(base=0;basetrack_count;++base)if(p->tracks[base].id==track)break; + if(base==p->track_count)return XTC_ERR_NOT_FOUND; + r=xtc_project_clone(p,©); + for(i=0;r==XTC_OK && icount;++i) { + xtc_clip_info c;uint64_t id,target;xtc_project_find_clip(b->source,b->ids[i],&c); + for(j=0;jsource->track_count;++j)if(b->source->tracks[j].id==c.track_id)break; + k=base+j-b->first_track; + if(k>=copy->track_count){r=XTC_ERR_NOT_FOUND;break;} + if(copy->tracks[k].is_bus){r=XTC_ERR_INVALID_STATE;break;} + if(c.start_tick-b->begin>XTC_PROJECT_MAX_TICK-start){r=XTC_ERR_INVALID_ARGUMENT;break;} + target=start+c.start_tick-b->begin; + r=xtc_project_add_clip(copy,copy->tracks[k].id,c.name,target,c.length_tick,&id); + if(r!=XTC_OK)break; + c.id=id;c.track_id=copy->tracks[k].id;c.start_tick=target;copy->clips[copy->clip_count-1]=c;output[i]=id; + if(c.midi)r=xtc_project_ensure_midi_route(copy); + for(j=0;r==XTC_OK && jsource->note_count;++j)if(b->source->notes[j].clip_id==b->ids[i]) { + xtc_note_info n=b->source->notes[j];uint64_t note; + r=xtc_project_add_note(copy,id,n.start_tick,n.length_tick,n.pitch,n.velocity,n.channel,¬e); + } + } + if(r==XTC_OK){publish(p,copy);if(out_ids)memcpy(out_ids,output,b->count*sizeof(*output));} + xtc_project_destroy(copy);return r; +} diff --git a/tests/integration/test_gui.cpp b/tests/integration/test_gui.cpp index e5e8ee6..000a8f2 100644 --- a/tests/integration/test_gui.cpp +++ b/tests/integration/test_gui.cpp @@ -1320,6 +1320,9 @@ int main(int argc, char **argv) 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); + s->select_all_clips();CHECK(s->copy_selection()==XTC_OK);s->position(7680); + CHECK(s->paste_selection()==XTC_OK && s->selected_clips().size()==2); + xtc_project_get_counts(s->project(),&tc,&cc);CHECK(cc==4); window.do_callback(); } if (failures != 0) return 1; diff --git a/tests/unit/test_modern_editing.c b/tests/unit/test_modern_editing.c index cda1a5a..027e587 100644 --- a/tests/unit/test_modern_editing.c +++ b/tests/unit/test_modern_editing.c @@ -24,5 +24,18 @@ int main(void) 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_clipboard *board=NULL;xtc_project *dest=NULL;uint64_t dt; + ids[0]=a;ids[1]=b;assert(xtc_clipboard_create(p,ids,2,&board)==XTC_OK); + assert(xtc_clipboard_count(board)==2);assert(xtc_project_create(&dest)==XTC_OK); + assert(xtc_project_add_track(dest,"Destination",&dt)==XTC_OK); + assert(xtc_project_remove_clip(p,a)==XTC_OK); + assert(xtc_clipboard_paste(board,dest,dt,0,out)==XTC_OK); + assert(xtc_project_find_clip(dest,out[0],&c)==XTC_OK && c.start_tick==0 && c.midi); + assert(xtc_project_note_count(dest,¬es)==XTC_OK && notes==1); + assert(xtc_clipboard_paste(board,dest,dt,XTC_PROJECT_MAX_TICK,out)==XTC_ERR_INVALID_ARGUMENT); + assert(xtc_project_get_counts(dest,&tracks,&clips)==XTC_OK && clips==2); + xtc_clipboard_destroy(board);xtc_project_destroy(dest); + } xtc_project_destroy(p);puts("Modern editing tests passed.");return 0; }