feat: plan modern editing phases and add arrangement multi-selection

This commit is contained in:
Johannes Findeisen 2026-11-06 00:12:37 +01:00
commit c5c7df1d9d
6 changed files with 226 additions and 2 deletions

9
docs/MODERN_EDITING.md Normal file
View file

@ -0,0 +1,9 @@
# Modern editing workflows
These control-thread edits preserve project format 15. Session selections,
clipboard and comparison snapshots are not saved in projects. Audio callbacks
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.

View file

@ -1312,6 +1312,171 @@ BUILTIN_DEVICES.md. Application project format is now 15, reading versions 1–1
---
## Phase 47 — Arrangement Multi-selection
### Goal
Select clips across tracks with additive selection and Select all; preserve stable IDs across refresh and docking.
### Tasks and acceptance criteria
- [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
Implemented: additive Shift-click selection, Ctrl+A/Select all and selected clip outlines across tracks. Stable IDs survive refresh and panel reparenting. Core build/tests and GUI selection/docking tests pass.
---
## Phase 48 — Transactional Group Editing
### Goal
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.
### Implementation status
Planned.
---
## Phase 49 — Arrangement Clipboard
### Goal
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.
### Implementation status
Planned.
---
## Phase 50 — Repeat Arrangement Patterns
### Goal
Repeat a selected phrase by its complete bounding span with bounded copy counts and all-or-nothing capacity validation.
### 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.
### Implementation status
Planned.
---
## Phase 51 — Align and Slip Editing
### Goal
Align selected clip starts to the cursor and slip source offsets without moving timeline boundaries.
### 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.
### Implementation status
Planned.
---
## Phase 52 — MIDI Swing Quantization
### Goal
Apply bounded swing and quantize strength to source note starts with deterministic rounding and source-bound checks.
### 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.
### Implementation status
Planned.
---
## Phase 53 — MIDI Legato and Gate Tools
### Goal
Set note gates or extend notes to the next onset on the same channel, preserving IDs and validating all notes before publication.
### 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.
### Implementation status
Planned.
---
## Phase 54 — MIDI Scale Correction
### Goal
Snap notes to a chosen root and chromatic, major or minor scale with deterministic nearest-pitch ties.
### 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.
### Implementation status
Planned.
---
## Phase 55 — MIDI Velocity Shaping
### Goal
Apply bounded velocity scaling and offsets to selected MIDI clips, with explicit clipping to valid MIDI velocities.
### 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.
### Implementation status
Planned.
---
## Phase 56 — Batch Audio Fade Tools
### Goal
Apply symmetric boundary fades or remove fades on selected audio clips, preserving clip gain, media and timing.
### 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.
### Implementation status
Planned.
---
## Phase 57 — Mixer A/B Comparison
### Goal
Capture and recall two session mixer snapshots by stable track ID, with transactional validation, undo and explicit automation limitations.
### 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.
### Implementation status
Planned.
---
## Recommended Execution Order
Reorder the phases when the audit reveals a blocking dependency. Do not mark a phase complete merely because code exists: its acceptance criteria must be demonstrated by builds, tests, or documented manual verification.

View file

@ -320,6 +320,7 @@ 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/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_);
menu->add("Edit/Audio clip gain and fades", 0, [](Fl_Widget *, void *c) { static_cast<SequencerWidget *>(c)->audio_properties_dialog(); }, sequencer_);

View file

@ -50,6 +50,7 @@ bool SequencerWidget::select(uint64_t track, uint64_t clip)
xtc_clip_info info{};
if (xtc_project_find_clip(project_,clip,&info) != XTC_OK || info.track_id != track) return false;
}
selection_.clear();
track_ = track; clip_ = clip; mode_ = 0; redraw();
return true;
}
@ -59,6 +60,7 @@ void SequencerWidget::set_project(xtc_project *project, bool reset)
project_ = project;
if (reset) {
waves_.clear();
selection_.clear();
track_ = clip_ = UINT64_MAX;
cursor_ = 0;
mode_ = 0;
@ -281,9 +283,9 @@ void SequencerWidget::draw()
int right = left+std::max(4,static_cast<int>(c.length_tick*static_cast<unsigned int>(scale_)/960U));
int a = std::max(left,cx), b = std::min(right,cx+cw);
if (b <= a) continue;
fl_color(fl_color_average(color,xtc_ui::background(),c.id == clip_ ? .75f : .45f));
fl_color(fl_color_average(color,xtc_ui::background(),clip_selected(c.id) ? .75f : .45f));
fl_rectf(a,py+5,b-a,46);
fl_color(c.id == clip_ ? xtc_ui::text() : color); fl_rect(a,py+5,b-a,46);
fl_color(clip_selected(c.id) ? xtc_ui::text() : color); fl_rect(a,py+5,b-a,46);
fl_push_clip(a+1,py+6,std::max(0,b-a-2),44);
if (c.midi) {
/* Real note positions, pitch and duration; no placeholder waveform. */
@ -405,6 +407,7 @@ int SequencerWidget::handle(int event)
return 1;
xtc_track_info t{};
xtc_project_get_track(project_, static_cast<size_t>(row), &t);
uint64_t previous_clip=clip_;
track_ = t.id;
clip_ = UINT64_MAX;
for (size_t j = clips; j > 0 && Fl::event_x()>=header_left()+header_width_; --j) {
@ -420,6 +423,10 @@ int SequencerWidget::handle(int event)
break;
}
}
if (Fl::event_state(FL_SHIFT) && clip_ != UINT64_MAX) {
uint64_t hit=clip_;clip_=previous_clip;toggle_clip(hit);mode_=0;return 1;
}
selection_.clear();
if (Fl::event_button() == FL_LEFT_MOUSE && Fl::event_clicks() && clip_ != UINT64_MAX && original_.midi) {
mode_ = 0;
if (open_clip) open_clip(context_);
@ -490,6 +497,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::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) {
const char *name = fl_input("Name (1..63 UTF-8 bytes)",""); if (name) rename_selection(name); return 1;
@ -509,6 +517,32 @@ int SequencerWidget::handle(int event)
return Fl_Group::handle(event);
}
std::vector<uint64_t> SequencerWidget::selected_clips() const
{
std::vector<uint64_t> ids;xtc_clip_info c{};
for(auto id:selection_)if(xtc_project_find_clip(project_,id,&c)==XTC_OK)ids.push_back(id);
if(ids.empty() && clip_!=UINT64_MAX && xtc_project_find_clip(project_,clip_,&c)==XTC_OK)ids.push_back(clip_);
return ids;
}
bool SequencerWidget::clip_selected(uint64_t id) const
{
return selection_.empty() ? id==clip_ : std::find(selection_.begin(),selection_.end(),id)!=selection_.end();
}
bool SequencerWidget::toggle_clip(uint64_t id)
{
xtc_clip_info c{};if(xtc_project_find_clip(project_,id,&c)!=XTC_OK)return false;
if(selection_.empty() && clip_!=UINT64_MAX)selection_.push_back(clip_);
auto found=std::find(selection_.begin(),selection_.end(),id);
if(found==selection_.end())selection_.push_back(id);else selection_.erase(found);
clip_=selection_.empty() ? UINT64_MAX : selection_.back();track_=c.track_id;redraw();return true;
}
void SequencerWidget::select_all_clips()
{
selection_.clear();size_t tracks=0,clips=0;xtc_project_get_counts(project_,&tracks,&clips);
for(size_t i=0;i<clips;++i){xtc_clip_info c{};xtc_project_get_clip(project_,i,&c);selection_.push_back(c.id);}
if(!selection_.empty())clip_=selection_.back();
redraw();
}
xtc_result SequencerWidget::grid(uint64_t ticks)
{
if (xtc_ui::snap_index(ticks) < 0) return XTC_ERR_INVALID_ARGUMENT;

View file

@ -32,6 +32,10 @@ class SequencerWidget : public Fl_Group {
Fl_Choice *instrument_choice(uint64_t track) const;
void refresh_instruments();
bool select(uint64_t track, uint64_t clip = UINT64_MAX);
bool toggle_clip(uint64_t clip);
void select_all_clips();
bool clip_selected(uint64_t clip) const;
std::vector<uint64_t> selected_clips() const;
xtc_result duplicate_selection();
xtc_result split_selection();
xtc_result audio_properties(float gain, uint64_t fade_in, uint64_t fade_out);
@ -168,6 +172,7 @@ class SequencerWidget : public Fl_Group {
std::unique_ptr<xtc_waveform, decltype(&xtc_waveform_destroy)> data{nullptr, xtc_waveform_destroy};
};
std::vector<Wave> waves_;
std::vector<uint64_t> selection_;
void draw_waveform(const xtc_clip_info &clip, int left, int top);
xtc_project *project_;
Seek seek_;

View file

@ -1307,6 +1307,16 @@ int main(int argc, char **argv)
}
unlink(path.c_str());
}
reset();
{
MainWindow window(false,"");window.show();CHECK(window.create_track(true)==XTC_OK);
auto *s=timeline(&window);uint64_t first=s->selected_clip();CHECK(s->duplicate_selection()==XTC_OK);
uint64_t second=s->selected_clip();CHECK(s->toggle_clip(first));CHECK(s->selected_clips().size()==2);
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);
window.do_callback();
}
if (failures != 0) return 1;
std::printf("GUI controls/errors/views/timers/cleanup passed: %u opens, %u closes.\n", opens, closes);
return 0;