feat: copy and paste arrangement phrases with independent clip snapshots

This commit is contained in:
Johannes Findeisen 2026-11-06 00:15:01 +01:00
commit b60501f317
9 changed files with 110 additions and 4 deletions

View file

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

View file

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

View file

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

View file

@ -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<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/Copy clips",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->copy_selection();},sequencer_);
menu->add("Edit/Paste clips at cursor",0,[](Fl_Widget *,void *c){static_cast<SequencerWidget *>(c)->paste_selection();},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

@ -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(),&copy);
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<uint64_t> 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<int64_t>(grid_) : static_cast<int64_t>(grid_));

View file

@ -38,6 +38,8 @@ class SequencerWidget : public Fl_Group {
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 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<Wave> waves_;
std::vector<uint64_t> selection_;
std::unique_ptr<xtc_clipboard,decltype(&xtc_clipboard_destroy)> clipboard_{nullptr,xtc_clipboard_destroy};
void draw_waveform(const xtc_clip_info &clip, int left, int top);
xtc_project *project_;
Seek seek_;

View file

@ -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;i<count;++i){xtc_clip_info c;xtc_project_find_clip(p,ids[i],&c);
if(c.start_tick<b->begin)b->begin=c.start_tick;
for(j=0;j<p->track_count;++j)if(p->tracks[j].id==c.track_id && j<b->first_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;base<p->track_count;++base)if(p->tracks[base].id==track)break;
if(base==p->track_count)return XTC_ERR_NOT_FOUND;
r=xtc_project_clone(p,&copy);
for(i=0;r==XTC_OK && i<b->count;++i) {
xtc_clip_info c;uint64_t id,target;xtc_project_find_clip(b->source,b->ids[i],&c);
for(j=0;j<b->source->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 && j<b->source->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,&note);
}
}
if(r==XTC_OK){publish(p,copy);if(out_ids)memcpy(out_ids,output,b->count*sizeof(*output));}
xtc_project_destroy(copy);return r;
}

View file

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

View file

@ -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,&notes)==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;
}