Skip to main content

acorde_core/model/
commands.rs

1use super::change_hint::{ChangeHint, ChangeScope};
2use super::duration::Duration;
3use super::notation::{
4    Articulation, Barline, ChordSymbol, Clef, CrossStaff, Dynamic, GuitarTechnique, HairpinKind,
5    KeySignature, Lyric, NoteHead, OttavaKind, TimeSignature, TupletInfo,
6};
7use super::pitch::Pitch;
8use super::score::{
9    Measure, Note, NoteAddr, Part, PartGroup, Score, ScoreTemplate, Staff, respell_score,
10    respell_score_to_key,
11};
12use crate::Error;
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(tag = "type", rename_all = "snake_case")]
17pub enum Command {
18    AddNote(AddNoteCmd),
19    AddPitch(AddPitchCmd),
20    SetDuration(SetDurationCmd),
21    DeleteNote(DeleteNoteCmd),
22    AddMeasure(AddMeasureCmd),
23    DeleteMeasure(DeleteMeasureCmd),
24    SetTempo(SetTempoCmd),
25    NewScore(NewScoreCmd),
26    AddHairpin(AddHairpinCmd),
27    ToggleTie(ToggleTieCmd),
28    SetDynamic(SetDynamicCmd),
29    ToggleArticulation(ToggleArticulationCmd),
30    SetKeySignature(SetKeySignatureCmd),
31    SetTimeSignature(SetTimeSignatureCmd),
32    SetBarline(SetBarlineCmd),
33    AddPart(AddPartCmd),
34    DeletePart(DeletePartCmd),
35    SetMetadata(SetMetadataCmd),
36    SetRehearsalMark(SetRehearsalMarkCmd),
37    SetNavigationMark(SetNavigationMarkCmd),
38    SetChordSymbol(SetChordSymbolCmd),
39    SetGrace(SetGraceCmd),
40    SetOttava(SetOttavaCmd),
41    SetLyric(SetLyricCmd),
42    SetMultiRest(SetMultiRestCmd),
43    AddPedal(AddPedalCmd),
44    SetVolta(SetVoltaCmd),
45    SetClef(SetClefCmd),
46    SetPartName(SetPartNameCmd),
47    SetMidiInstrument(SetMidiInstrumentCmd),
48    SetTranspose(SetTransposeCmd),
49    SetTempoAtMeasure(SetTempoAtMeasureCmd),
50    PasteVoice(PasteVoiceCmd),
51    PasteRange(PasteRangeCmd),
52    SetSystemBreak(SetSystemBreakCmd),
53    SetPageBreak(SetPageBreakCmd),
54    ToggleSlur(ToggleSlurCmd),
55    AddStaff(AddStaffCmd),
56    DeleteStaff(DeleteStaffCmd),
57    SetTuplet(SetTupletCmd),
58    RespellScore(RespellScoreCmd),
59    RespellScoreToKey(RespellScoreToKeyCmd),
60    SetStem(SetStemCmd),
61    SetArpeggio(SetArpeggioCmd),
62    SetTechniqueText(SetTechniqueTextCmd),
63    SetFingering(SetFingeringCmd),
64    SetStringNumber(SetStringNumberCmd),
65    SetNoteHead(SetNoteHeadCmd),
66    SetCue(SetCueCmd),
67    SetGuitarTechnique(SetGuitarTechniqueCmd),
68    SetExpressionText(SetExpressionTextCmd),
69    ToggleTrillLine(ToggleTrillLineCmd),
70    SetGlissando(SetGlissandoCmd),
71    SetCrossStaff(SetCrossStaffCmd),
72    SetPartGroup(SetPartGroupCmd),
73    Batch(BatchCmd),
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct AddNoteCmd {
78    pub part_index: usize,
79    pub staff_index: usize,
80    pub measure_index: usize,
81    pub voice: usize,
82    pub position: usize,
83    pub pitch: Option<Pitch>,
84    pub duration: Duration,
85    pub dot_count: u8,
86    pub is_rest: bool,
87    #[serde(default)]
88    pub tuplet: Option<TupletInfo>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct AddPitchCmd {
93    pub part_index: usize,
94    pub staff_index: usize,
95    pub measure_index: usize,
96    pub voice: usize,
97    pub note_index: usize,
98    pub pitch: Pitch,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct SetDurationCmd {
103    pub part_index: usize,
104    pub staff_index: usize,
105    pub measure_index: usize,
106    pub voice: usize,
107    pub note_index: usize,
108    pub duration: Duration,
109    #[serde(default)]
110    pub dot_count: u8,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct DeleteNoteCmd {
115    pub note_id: String,
116    pub part_index: usize,
117    pub staff_index: usize,
118    pub measure_index: usize,
119    pub voice: usize,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct AddMeasureCmd {
124    pub after_index: usize,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct DeleteMeasureCmd {
129    pub measure_index: usize,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct SetTempoCmd {
134    pub bpm: u16,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct NewScoreCmd {
139    pub title: String,
140    pub composer: String,
141    pub tempo_bpm: u16,
142    pub time_numerator: u8,
143    pub time_denominator: u8,
144    pub key_fifths: i8,
145    pub measure_count: u32,
146    /// When set, creates the score from an ensemble template instead of a blank single-part score.
147    #[serde(default)]
148    pub template: Option<ScoreTemplate>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct AddHairpinCmd {
153    pub part_index: usize,
154    pub staff_index: usize,
155    pub measure_index: usize,
156    pub voice: usize,
157    pub start_note_idx: usize,
158    pub end_note_idx: usize,
159    pub kind: HairpinKind,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct ToggleTieCmd {
164    pub part_index: usize,
165    pub staff_index: usize,
166    pub measure_index: usize,
167    pub voice: usize,
168    pub note_index: usize,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct SetDynamicCmd {
173    pub part_index: usize,
174    pub staff_index: usize,
175    pub measure_index: usize,
176    pub voice: usize,
177    pub note_index: usize,
178    pub dynamic: Option<Dynamic>,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct ToggleArticulationCmd {
183    pub part_index: usize,
184    pub staff_index: usize,
185    pub measure_index: usize,
186    pub voice: usize,
187    pub note_index: usize,
188    pub articulation: Articulation,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct SetKeySignatureCmd {
193    pub fifths: i8,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct SetTimeSignatureCmd {
198    pub numerator: u8,
199    pub denominator: u8,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct SetBarlineCmd {
204    pub measure_index: usize,
205    /// "left" or "right"
206    pub side: String,
207    pub barline: Barline,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct AddPartCmd {
212    pub name: String,
213    pub short_name: String,
214    /// Clef variant names for each staff, e.g. ["Treble"] or ["Treble", "Bass"]
215    pub clefs: Vec<String>,
216    /// MIDI channel (0–15). Default 0.
217    #[serde(default)]
218    pub midi_channel: u8,
219    /// General MIDI program (0–127). Default 0 = Acoustic Grand Piano.
220    #[serde(default)]
221    pub midi_program: u8,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct DeletePartCmd {
226    pub part_index: usize,
227}
228
229#[derive(Debug, Clone, Default, Serialize, Deserialize)]
230pub struct SetMetadataCmd {
231    pub title: Option<String>,
232    pub composer: Option<String>,
233    pub lyricist: Option<String>,
234    pub copyright: Option<String>,
235    pub work_number: Option<String>,
236    pub movement_title: Option<String>,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct SetRehearsalMarkCmd {
241    pub measure_index: usize,
242    pub text: Option<String>,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct SetNavigationMarkCmd {
247    pub measure_index: usize,
248    /// Known values: "Segno", "Coda", "Fine", "DaCapo", "DaCapoAlFine",
249    /// "DaCapoAlCoda", "DalSegno", "DalSegnoAlFine", "DalSegnoAlCoda", "ToCoda".
250    pub mark: Option<String>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct SetChordSymbolCmd {
255    pub part_index: usize,
256    pub staff_index: usize,
257    pub measure_index: usize,
258    pub voice: usize,
259    pub note_index: usize,
260    pub chord: Option<ChordSymbol>,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct SetGraceCmd {
265    pub part_index: usize,
266    pub staff_index: usize,
267    pub measure_index: usize,
268    pub voice: usize,
269    pub note_index: usize,
270    pub is_grace: bool,
271    /// true = acciaccatura (slash), false = appoggiatura (no slash).
272    pub slash: bool,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct SetOttavaCmd {
277    pub part_index: usize,
278    pub staff_index: usize,
279    pub measure_index: usize,
280    pub voice: usize,
281    pub note_index: usize,
282    pub ottava_start: Option<OttavaKind>,
283    pub ottava_end: bool,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct SetLyricCmd {
288    pub part_index: usize,
289    pub staff_index: usize,
290    pub measure_index: usize,
291    pub voice: usize,
292    pub note_index: usize,
293    pub lyric: Option<Lyric>,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct SetMultiRestCmd {
298    pub measure_index: usize,
299    pub count: Option<u8>,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct SetVoltaCmd {
304    pub measure_index: usize,
305    pub volta: Option<super::score::VoltaBracket>,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct SetClefCmd {
310    pub part_index: usize,
311    pub staff_index: usize,
312    pub clef: Clef,
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct SetPartNameCmd {
317    pub part_index: usize,
318    pub name: String,
319    pub short_name: String,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct SetMidiInstrumentCmd {
324    pub part_index: usize,
325    /// MIDI channel (0–15).
326    pub midi_channel: u8,
327    /// General MIDI program number (0–127).
328    pub midi_program: u8,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct SetTransposeCmd {
333    pub part_index: usize,
334    pub staff_index: usize,
335    /// Semitones to transpose (negative = down). E.g. -2 for Bb clarinet.
336    pub semitones: i8,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct SetTempoAtMeasureCmd {
341    pub measure_index: usize,
342    /// New BPM at this measure. `None` clears any measure-level override.
343    pub bpm: Option<u16>,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct PasteVoiceCmd {
348    pub part_index: usize,
349    pub staff_index: usize,
350    pub measure_index: usize,
351    pub voice_index: usize,
352    /// Snapshot of the clipboard at paste time — embedded in the command for undo/redo.
353    pub notes: Vec<Note>,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct SetSystemBreakCmd {
358    pub measure_index: usize,
359    pub value: bool,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct SetPageBreakCmd {
364    pub measure_index: usize,
365    pub value: bool,
366}
367
368/// A group of commands applied and undone as a single unit.
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct BatchCmd {
371    pub commands: Vec<Command>,
372    /// Optional i18n key / display label override shown in the undo menu.
373    /// E.g. `"ApplyAI"`, `"PasteSelection"`. `None` falls back to `"Batch"`.
374    #[serde(default)]
375    pub label: Option<String>,
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct AddPedalCmd {
380    pub part_index: usize,
381    pub staff_index: usize,
382    pub measure_index: usize,
383    pub voice: usize,
384    pub start_note_idx: usize,
385    pub end_note_idx: usize,
386}
387
388/// Replace a contiguous range of voice measures with stored notes (undo-able).
389///
390/// `measures` contains one `Vec<Note>` per measure to paste, starting at `target_measure`.
391/// The target voice of each measure is replaced entirely.
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct PasteRangeCmd {
394    pub part_index: usize,
395    pub staff_index: usize,
396    pub voice_index: usize,
397    pub target_measure: usize,
398    /// One note list per measure, in order.
399    pub measures: Vec<Vec<Note>>,
400}
401
402/// Toggle slur_start on `start` note and slur_end on `end` note (cross-measure aware).
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct ToggleSlurCmd {
405    pub start: NoteAddr,
406    pub end: NoteAddr,
407}
408
409/// Add or replace a part group. `None` removes all groups that overlap the range.
410#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct SetPartGroupCmd {
412    /// `Some(group)` to add/replace; `None` removes any group whose `[first_part, last_part]` matches.
413    pub group: Option<PartGroup>,
414}
415
416/// Toggle a trill line span between two notes (start note gets `trill_line_start`, end gets `trill_line_end`).
417#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct ToggleTrillLineCmd {
419    pub start: NoteAddr,
420    pub end: NoteAddr,
421}
422
423/// Add a new staff to an existing part with the given clef.
424///
425/// The new staff is appended with empty measures matching the current measure count.
426/// Clef values: `"Treble"` | `"Bass"` | `"Alto"` | `"Tenor"` | `"Percussion"`.
427#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct AddStaffCmd {
429    pub part_index: usize,
430    pub clef: Clef,
431}
432
433/// Remove a staff from a part. Fails if it is the last remaining staff.
434#[derive(Debug, Clone, Serialize, Deserialize)]
435pub struct DeleteStaffCmd {
436    pub part_index: usize,
437    pub staff_index: usize,
438}
439
440/// Set (or clear) the tuplet info on an existing note.
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct SetTupletCmd {
443    pub part_index: usize,
444    pub staff_index: usize,
445    pub measure_index: usize,
446    pub voice_index: usize,
447    pub note_index: usize,
448    /// `None` removes the tuplet; `Some(TupletInfo)` sets it.
449    pub tuplet: Option<TupletInfo>,
450}
451
452/// Set or clear the stem direction on a note (override). `None` means auto.
453#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct SetStemCmd {
455    pub part_index: usize,
456    pub staff_index: usize,
457    pub measure_index: usize,
458    pub voice_index: usize,
459    pub note_index: usize,
460    /// `None` = auto, `Some(true)` = stem up, `Some(false)` = stem down.
461    pub stem_up: Option<bool>,
462}
463
464#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct SetArpeggioCmd {
466    pub part_index: usize,
467    pub staff_index: usize,
468    pub measure_index: usize,
469    pub voice_index: usize,
470    pub note_index: usize,
471    /// `Some(true)` = up, `Some(false)` = down, `None` = clear.
472    pub direction: Option<bool>,
473}
474
475/// Set (or clear) the technique-text annotation on a note ("pizz.", "arco", "con sord.", etc.).
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct SetTechniqueTextCmd {
478    pub part_index: usize,
479    pub staff_index: usize,
480    pub measure_index: usize,
481    pub voice: usize,
482    pub note_index: usize,
483    /// `None` clears the annotation.
484    pub text: Option<String>,
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize)]
488pub struct SetGlissandoCmd {
489    pub part_index: usize,
490    pub staff_index: usize,
491    pub measure_index: usize,
492    pub voice: usize,
493    pub note_index: usize,
494    pub start: bool,
495    pub end: bool,
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize)]
499pub struct SetCrossStaffCmd {
500    pub part_index: usize,
501    pub staff_index: usize,
502    pub measure_index: usize,
503    pub voice: usize,
504    pub note_index: usize,
505    pub placement: Option<CrossStaff>,
506}
507
508/// Set (or clear) the fingering number on a note (0 = open/thumb, 1–5 = fingers).
509#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct SetFingeringCmd {
511    pub part_index: usize,
512    pub staff_index: usize,
513    pub measure_index: usize,
514    pub voice: usize,
515    pub note_index: usize,
516    /// `None` clears the fingering.
517    pub fingering: Option<u8>,
518}
519
520/// Set (or clear) the string number on a note (1 = highest string).
521#[derive(Debug, Clone, Serialize, Deserialize)]
522pub struct SetStringNumberCmd {
523    pub part_index: usize,
524    pub staff_index: usize,
525    pub measure_index: usize,
526    pub voice: usize,
527    pub note_index: usize,
528    /// `None` clears the string number.
529    pub string_number: Option<u8>,
530}
531
532/// Set (or clear) the guitar playing technique on a note (bend, slide, hammer-on, pull-off).
533#[derive(Debug, Clone, Serialize, Deserialize)]
534pub struct SetGuitarTechniqueCmd {
535    pub part_index: usize,
536    pub staff_index: usize,
537    pub measure_index: usize,
538    pub voice: usize,
539    pub note_index: usize,
540    /// `None` clears the technique.
541    pub technique: Option<GuitarTechnique>,
542}
543
544/// Set (or clear) the expression/performance text on a measure ("dolce", "espressivo", etc.).
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct SetExpressionTextCmd {
547    pub measure_index: usize,
548    /// `None` clears the expression text.
549    pub text: Option<String>,
550}
551
552/// Mark or unmark a note as a cue note (cue notes have zero beats).
553#[derive(Debug, Clone, Serialize, Deserialize)]
554pub struct SetCueCmd {
555    pub part_index: usize,
556    pub staff_index: usize,
557    pub measure_index: usize,
558    pub voice: usize,
559    pub note_index: usize,
560    pub is_cue: bool,
561}
562
563/// Set the note head shape on a note.
564#[derive(Debug, Clone, Serialize, Deserialize)]
565pub struct SetNoteHeadCmd {
566    pub part_index: usize,
567    pub staff_index: usize,
568    pub measure_index: usize,
569    pub voice: usize,
570    pub note_index: usize,
571    pub note_head: NoteHead,
572}
573
574/// Respell all pitches in the score to prefer flats or sharps.
575#[derive(Debug, Clone, Serialize, Deserialize)]
576pub struct RespellScoreCmd {
577    pub prefer_flat: bool,
578}
579
580/// Respell all pitches to match the score's key signature (auto-selects flat vs sharp).
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct RespellScoreToKeyCmd {}
583
584struct UndoEntry {
585    command: Command,
586    snapshot: Score,
587}
588
589pub struct CommandStack {
590    history: Vec<UndoEntry>,
591    future: Vec<(Command, Score)>,
592    max_depth: usize,
593}
594
595impl CommandStack {
596    pub fn new(max_depth: usize) -> Self {
597        Self {
598            history: Vec::new(),
599            future: Vec::new(),
600            max_depth,
601        }
602    }
603
604    pub fn can_undo(&self) -> bool {
605        !self.history.is_empty()
606    }
607
608    pub fn can_redo(&self) -> bool {
609        !self.future.is_empty()
610    }
611
612    pub fn execute(&mut self, cmd: Command, score: &mut Score) -> Result<(), Error> {
613        let snapshot = score.clone();
614        apply_command(&cmd, score)?;
615        self.history.push(UndoEntry {
616            command: cmd,
617            snapshot,
618        });
619        self.future.clear();
620        if self.history.len() > self.max_depth {
621            self.history.remove(0);
622        }
623        Ok(())
624    }
625
626    pub fn undo(&mut self, score: &mut Score) -> Result<ChangeHint, Error> {
627        let entry = self.history.pop().ok_or(Error::NothingToUndo)?;
628        let hint = command_hint(&entry.command);
629        let post_snapshot = score.clone();
630        *score = entry.snapshot;
631        self.future.push((entry.command, post_snapshot));
632        if self.future.len() > self.max_depth {
633            self.future.remove(0);
634        }
635        Ok(hint)
636    }
637
638    pub fn redo(&mut self, score: &mut Score) -> Result<ChangeHint, Error> {
639        let (cmd, post) = self.future.pop().ok_or(Error::NothingToRedo)?;
640        let hint = command_hint(&cmd);
641        let snapshot = score.clone();
642        *score = post;
643        self.history.push(UndoEntry {
644            command: cmd,
645            snapshot,
646        });
647        Ok(hint)
648    }
649
650    /// Return the commands applied so far, in execution order.
651    /// Suitable for use with [`ScoreEngine::export_history`].
652    pub fn history_commands(&self) -> Vec<Command> {
653        self.history.iter().map(|e| e.command.clone()).collect()
654    }
655
656    /// Label of the command that would be undone next, for UI display (e.g. "Undo: Add Note").
657    pub fn undo_label(&self) -> Option<String> {
658        self.history.last().map(|e| command_label(&e.command))
659    }
660
661    /// Label of the command that would be redone next, for UI display (e.g. "Redo: Add Note").
662    pub fn redo_label(&self) -> Option<String> {
663        self.future.last().map(|(cmd, _)| command_label(cmd))
664    }
665
666    /// i18n key of the command that would be undone next.
667    pub fn undo_key(&self) -> Option<String> {
668        self.history.last().map(|e| command_key(&e.command))
669    }
670
671    /// i18n key of the command that would be redone next.
672    pub fn redo_key(&self) -> Option<String> {
673        self.future.last().map(|(cmd, _)| command_key(cmd))
674    }
675
676    /// Apply a batch of commands as a single undo entry (rollback-safe).
677    pub fn batch_execute(&mut self, cmds: Vec<Command>, score: &mut Score) -> Result<(), Error> {
678        if cmds.is_empty() {
679            return Ok(());
680        }
681        let snapshot = score.clone();
682        for cmd in &cmds {
683            if let Err(e) = apply_command(cmd, score) {
684                *score = snapshot;
685                return Err(e);
686            }
687        }
688        self.history.push(UndoEntry {
689            command: Command::Batch(BatchCmd {
690                commands: cmds,
691                label: None,
692            }),
693            snapshot,
694        });
695        self.future.clear();
696        if self.history.len() > self.max_depth {
697            self.history.remove(0);
698        }
699        Ok(())
700    }
701
702    /// Apply a batch with an explicit undo-label, as a single rollback-safe entry.
703    ///
704    /// The `label` appears as the [`command_key`] for undo/redo UI (e.g. `"ApplyAI"`).
705    pub fn batch_execute_labeled(
706        &mut self,
707        cmds: Vec<Command>,
708        label: String,
709        score: &mut Score,
710    ) -> Result<(), Error> {
711        if cmds.is_empty() {
712            return Ok(());
713        }
714        let snapshot = score.clone();
715        for cmd in &cmds {
716            if let Err(e) = apply_command(cmd, score) {
717                *score = snapshot;
718                return Err(e);
719            }
720        }
721        self.history.push(UndoEntry {
722            command: Command::Batch(BatchCmd {
723                commands: cmds,
724                label: Some(label),
725            }),
726            snapshot,
727        });
728        self.future.clear();
729        if self.history.len() > self.max_depth {
730            self.history.remove(0);
731        }
732        Ok(())
733    }
734}
735
736/// Return a [`ChangeHint`] describing the scope and dirty flags for a command,
737/// without executing it.
738pub fn command_hint(cmd: &Command) -> ChangeHint {
739    use ChangeScope::*;
740    macro_rules! hint {
741        ($scope:expr, $layout:expr, $playback:expr) => {
742            ChangeHint {
743                scope: $scope,
744                layout_dirty: $layout,
745                playback_dirty: $playback,
746            }
747        };
748    }
749    macro_rules! meas {
750        ($c:expr) => {
751            Measures {
752                part: $c.part_index,
753                staff: $c.staff_index,
754                start: $c.measure_index,
755                end: $c.measure_index + 1,
756            }
757        };
758    }
759    match cmd {
760        // Global — full score affected
761        Command::NewScore(_)
762        | Command::AddPart(_)
763        | Command::DeletePart(_)
764        | Command::AddMeasure(_)
765        | Command::DeleteMeasure(_) => hint!(Global, true, true),
766
767        Command::SetTempo(_) => hint!(Global, false, true),
768
769        Command::SetMetadata(_) => hint!(Global, false, false),
770
771        Command::SetKeySignature(_) => hint!(Global, true, false),
772
773        Command::SetTimeSignature(_) => hint!(Global, true, true),
774
775        Command::SetBarline(_)
776        | Command::SetVolta(_)
777        | Command::SetRehearsalMark(_)
778        | Command::SetNavigationMark(_)
779        | Command::SetExpressionText(_) => hint!(Global, false, false),
780
781        Command::SetMultiRest(_) => hint!(Global, true, false),
782
783        Command::SetTempoAtMeasure(_) => hint!(Global, false, true),
784
785        // Part scope
786        Command::SetPartName(c) => hint!(Part(c.part_index), false, false),
787
788        Command::SetMidiInstrument(c) => hint!(Part(c.part_index), false, true),
789
790        Command::SetTranspose(c) => hint!(Part(c.part_index), false, true),
791
792        Command::SetClef(c) => hint!(Part(c.part_index), true, false),
793
794        // Measure scope
795        Command::AddNote(c) => hint!(meas!(c), false, true),
796        Command::AddPitch(c) => hint!(meas!(c), false, true),
797        Command::SetDuration(c) => hint!(meas!(c), false, true),
798        Command::DeleteNote(c) => hint!(meas!(c), false, true),
799        Command::PasteVoice(c) => hint!(meas!(c), false, true),
800        Command::PasteRange(c) => hint!(
801            Measures {
802                part: c.part_index,
803                staff: c.staff_index,
804                start: c.target_measure,
805                end: c.target_measure + c.measures.len()
806            },
807            false,
808            true
809        ),
810        Command::AddHairpin(c) => hint!(meas!(c), false, true),
811        Command::ToggleTie(c) => hint!(meas!(c), false, true),
812        Command::SetDynamic(c) => hint!(meas!(c), false, true),
813        Command::ToggleArticulation(c) => hint!(meas!(c), false, true),
814        Command::SetGrace(c) => hint!(meas!(c), false, true),
815        Command::SetOttava(c) => hint!(meas!(c), false, true),
816        Command::SetLyric(c) => hint!(meas!(c), false, true),
817        Command::AddPedal(c) => hint!(meas!(c), false, true),
818        Command::SetChordSymbol(c) => hint!(meas!(c), false, true),
819
820        Command::SetSystemBreak(_) | Command::SetPageBreak(_) => hint!(Global, true, false),
821
822        Command::ToggleSlur(_) | Command::ToggleTrillLine(_) => hint!(Global, true, false),
823        Command::SetGlissando(c) => hint!(meas!(c), true, true),
824        Command::SetCrossStaff(c) => hint!(meas!(c), true, true),
825
826        Command::SetPartGroup(_) => hint!(Global, false, false),
827
828        Command::AddStaff(_) | Command::DeleteStaff(_) => hint!(Global, true, true),
829
830        Command::SetTuplet(c) => hint!(meas!(c), false, true),
831
832        Command::RespellScore(_) | Command::RespellScoreToKey(_) => hint!(Global, true, true),
833
834        Command::SetStem(c) => hint!(meas!(c), false, false),
835
836        Command::SetArpeggio(c) => hint!(meas!(c), false, false),
837
838        Command::SetTechniqueText(c) => hint!(meas!(c), false, false),
839        Command::SetFingering(c) => hint!(meas!(c), false, false),
840        Command::SetStringNumber(c) => hint!(meas!(c), false, false),
841        Command::SetGuitarTechnique(c) => hint!(meas!(c), false, false),
842        Command::SetNoteHead(c) => hint!(meas!(c), false, false),
843        Command::SetCue(c) => hint!(meas!(c), false, true),
844
845        Command::Batch(c) => {
846            let Some(first) = c.commands.first() else {
847                return hint!(Global, false, false);
848            };
849            let mut merged = command_hint(first);
850            for cmd in c.commands.iter().skip(1) {
851                merged = merged.merge(command_hint(cmd));
852            }
853            merged
854        }
855    }
856}
857
858/// Human-readable label for an undoable command (for menu display).
859pub fn command_label(cmd: &Command) -> String {
860    match cmd {
861        Command::AddNote(_) => "Add Note".to_string(),
862        Command::AddPitch(_) => "Add Pitch".to_string(),
863        Command::SetDuration(_) => "Set Duration".to_string(),
864        Command::DeleteNote(_) => "Delete Note".to_string(),
865        Command::AddMeasure(_) => "Add Measure".to_string(),
866        Command::DeleteMeasure(_) => "Delete Measure".to_string(),
867        Command::SetTempo(_) => "Set Tempo".to_string(),
868        Command::NewScore(_) => "New Score".to_string(),
869        Command::AddHairpin(_) => "Add Hairpin".to_string(),
870        Command::ToggleTie(_) => "Toggle Tie".to_string(),
871        Command::SetDynamic(_) => "Set Dynamic".to_string(),
872        Command::ToggleArticulation(_) => "Toggle Articulation".to_string(),
873        Command::SetKeySignature(_) => "Set Key Signature".to_string(),
874        Command::SetTimeSignature(_) => "Set Time Signature".to_string(),
875        Command::SetBarline(_) => "Set Barline".to_string(),
876        Command::AddPart(_) => "Add Part".to_string(),
877        Command::DeletePart(_) => "Delete Part".to_string(),
878        Command::SetMetadata(_) => "Set Metadata".to_string(),
879        Command::SetRehearsalMark(_) => "Set Rehearsal Mark".to_string(),
880        Command::SetNavigationMark(_) => "Set Navigation Mark".to_string(),
881        Command::SetChordSymbol(_) => "Set Chord Symbol".to_string(),
882        Command::SetGrace(_) => "Set Grace Note".to_string(),
883        Command::SetOttava(_) => "Set Ottava".to_string(),
884        Command::SetLyric(_) => "Set Lyric".to_string(),
885        Command::SetMultiRest(_) => "Set Multi-Rest".to_string(),
886        Command::AddPedal(_) => "Add Pedal".to_string(),
887        Command::SetVolta(_) => "Set Volta".to_string(),
888        Command::SetClef(_) => "Set Clef".to_string(),
889        Command::SetPartName(_) => "Set Part Name".to_string(),
890        Command::SetMidiInstrument(_) => "Set MIDI Instrument".to_string(),
891        Command::SetTranspose(_) => "Set Transpose".to_string(),
892        Command::SetTempoAtMeasure(_) => "Set Tempo".to_string(),
893        Command::PasteVoice(_) => "Paste Voice".to_string(),
894        Command::PasteRange(_) => "Paste Range".to_string(),
895        Command::SetSystemBreak(_) => "Set System Break".to_string(),
896        Command::SetPageBreak(_) => "Set Page Break".to_string(),
897        Command::ToggleSlur(_) => "Toggle Slur".to_string(),
898        Command::AddStaff(_) => "Add Staff".to_string(),
899        Command::DeleteStaff(_) => "Delete Staff".to_string(),
900        Command::SetTuplet(c) => if c.tuplet.is_some() {
901            "Set Tuplet"
902        } else {
903            "Clear Tuplet"
904        }
905        .to_string(),
906        Command::RespellScore(c) => if c.prefer_flat {
907            "Respell Score (flat)"
908        } else {
909            "Respell Score (sharp)"
910        }
911        .to_string(),
912        Command::RespellScoreToKey(_) => "Respell Score to Key".to_string(),
913        Command::SetStem(_) => "Set Stem".to_string(),
914        Command::SetArpeggio(_) => "Set Arpeggio".to_string(),
915        Command::SetTechniqueText(_) => "Set Technique Text".to_string(),
916        Command::SetFingering(_) => "Set Fingering".to_string(),
917        Command::SetStringNumber(_) => "Set String Number".to_string(),
918        Command::SetGuitarTechnique(_) => "Set Guitar Technique".to_string(),
919        Command::SetNoteHead(_) => "Set Note Head".to_string(),
920        Command::SetCue(c) => if c.is_cue {
921            "Set Cue Note"
922        } else {
923            "Clear Cue Note"
924        }
925        .to_string(),
926        Command::SetExpressionText(_) => "Set Expression Text".to_string(),
927        Command::ToggleTrillLine(_) => "Toggle Trill Line".to_string(),
928        Command::SetGlissando(_) => "Set Glissando".to_string(),
929        Command::SetCrossStaff(_) => "Set Cross-Staff Placement".to_string(),
930        Command::SetPartGroup(_) => "Set Part Group".to_string(),
931        Command::Batch(c) => c.label.clone().unwrap_or_else(|| {
932            c.commands
933                .first()
934                .map(command_label)
935                .unwrap_or_else(|| "Batch".to_string())
936        }),
937    }
938}
939
940/// Stable i18n key for an undoable command — camelCase variant name.
941///
942/// Use this instead of [`command_label`] when the UI translates labels itself.
943pub fn command_key(cmd: &Command) -> String {
944    match cmd {
945        Command::AddNote(_) => "AddNote".to_string(),
946        Command::AddPitch(_) => "AddPitch".to_string(),
947        Command::SetDuration(_) => "SetDuration".to_string(),
948        Command::DeleteNote(_) => "DeleteNote".to_string(),
949        Command::AddMeasure(_) => "AddMeasure".to_string(),
950        Command::DeleteMeasure(_) => "DeleteMeasure".to_string(),
951        Command::SetTempo(_) => "SetTempo".to_string(),
952        Command::NewScore(_) => "NewScore".to_string(),
953        Command::AddHairpin(_) => "AddHairpin".to_string(),
954        Command::ToggleTie(_) => "ToggleTie".to_string(),
955        Command::SetDynamic(_) => "SetDynamic".to_string(),
956        Command::ToggleArticulation(_) => "ToggleArticulation".to_string(),
957        Command::SetKeySignature(_) => "SetKeySignature".to_string(),
958        Command::SetTimeSignature(_) => "SetTimeSignature".to_string(),
959        Command::SetBarline(_) => "SetBarline".to_string(),
960        Command::AddPart(_) => "AddPart".to_string(),
961        Command::DeletePart(_) => "DeletePart".to_string(),
962        Command::SetMetadata(_) => "SetMetadata".to_string(),
963        Command::SetRehearsalMark(_) => "SetRehearsalMark".to_string(),
964        Command::SetNavigationMark(_) => "SetNavigationMark".to_string(),
965        Command::SetChordSymbol(_) => "SetChordSymbol".to_string(),
966        Command::SetGrace(_) => "SetGrace".to_string(),
967        Command::SetOttava(_) => "SetOttava".to_string(),
968        Command::SetLyric(_) => "SetLyric".to_string(),
969        Command::SetMultiRest(_) => "SetMultiRest".to_string(),
970        Command::AddPedal(_) => "AddPedal".to_string(),
971        Command::SetVolta(_) => "SetVolta".to_string(),
972        Command::SetClef(_) => "SetClef".to_string(),
973        Command::SetPartName(_) => "SetPartName".to_string(),
974        Command::SetMidiInstrument(_) => "SetMidiInstrument".to_string(),
975        Command::SetTranspose(_) => "SetTranspose".to_string(),
976        Command::SetTempoAtMeasure(_) => "SetTempoAtMeasure".to_string(),
977        Command::PasteVoice(_) => "PasteVoice".to_string(),
978        Command::PasteRange(_) => "PasteRange".to_string(),
979        Command::SetSystemBreak(_) => "SetSystemBreak".to_string(),
980        Command::SetPageBreak(_) => "SetPageBreak".to_string(),
981        Command::ToggleSlur(_) => "ToggleSlur".to_string(),
982        Command::AddStaff(_) => "AddStaff".to_string(),
983        Command::DeleteStaff(_) => "DeleteStaff".to_string(),
984        Command::SetTuplet(_) => "SetTuplet".to_string(),
985        Command::RespellScore(_) => "RespellScore".to_string(),
986        Command::RespellScoreToKey(_) => "RespellScoreToKey".to_string(),
987        Command::SetStem(_) => "SetStem".to_string(),
988        Command::SetArpeggio(_) => "SetArpeggio".to_string(),
989        Command::SetTechniqueText(_) => "SetTechniqueText".to_string(),
990        Command::SetFingering(_) => "SetFingering".to_string(),
991        Command::SetStringNumber(_) => "SetStringNumber".to_string(),
992        Command::SetGuitarTechnique(_) => "SetGuitarTechnique".to_string(),
993        Command::SetNoteHead(_) => "SetNoteHead".to_string(),
994        Command::SetCue(_) => "SetCue".to_string(),
995        Command::SetExpressionText(_) => "SetExpressionText".to_string(),
996        Command::ToggleTrillLine(_) => "ToggleTrillLine".to_string(),
997        Command::SetGlissando(_) => "SetGlissando".to_string(),
998        Command::SetCrossStaff(_) => "SetCrossStaff".to_string(),
999        Command::SetPartGroup(_) => "SetPartGroup".to_string(),
1000        Command::Batch(c) => c.label.clone().unwrap_or_else(|| "Batch".to_string()),
1001    }
1002}
1003
1004pub fn apply_command(cmd: &Command, score: &mut Score) -> Result<(), Error> {
1005    match cmd {
1006        Command::AddNote(c) => apply_add_note(c, score),
1007        Command::AddPitch(c) => apply_add_pitch(c, score),
1008        Command::SetDuration(c) => apply_set_duration(c, score),
1009        Command::DeleteNote(c) => apply_delete_note(c, score),
1010        Command::AddMeasure(c) => apply_add_measure(c, score),
1011        Command::DeleteMeasure(c) => apply_delete_measure(c, score),
1012        Command::SetTempo(c) => {
1013            score.settings.tempo_bpm = c.bpm;
1014            Ok(())
1015        }
1016        Command::NewScore(c) => {
1017            let mut s = match c.template {
1018                Some(kind) => Score::template(kind),
1019                None => Score::new(
1020                    &c.title,
1021                    c.tempo_bpm,
1022                    c.time_numerator,
1023                    c.time_denominator,
1024                    c.key_fifths,
1025                    c.measure_count,
1026                ),
1027            };
1028            if c.template.is_some() {
1029                s.metadata.title = c.title.clone();
1030                s.metadata.composer = c.composer.clone();
1031                s.settings.tempo_bpm = c.tempo_bpm;
1032                s.settings.time_signature = TimeSignature {
1033                    numerator: c.time_numerator,
1034                    denominator: c.time_denominator,
1035                };
1036                s.settings.key_signature = KeySignature {
1037                    fifths: c.key_fifths,
1038                    mode: "major".to_string(),
1039                };
1040                for part in &mut s.parts {
1041                    for staff in &mut part.staves {
1042                        staff.measures.clear();
1043                        for i in 0..c.measure_count {
1044                            let mut m = Measure::empty(c.time_numerator, c.time_denominator);
1045                            m.number = i + 1;
1046                            staff.measures.push(m);
1047                        }
1048                    }
1049                }
1050            }
1051            *score = s;
1052            Ok(())
1053        }
1054        Command::AddHairpin(c) => apply_add_hairpin(c, score),
1055        Command::ToggleTie(c) => apply_toggle_tie(c, score),
1056        Command::SetDynamic(c) => apply_set_dynamic(c, score),
1057        Command::ToggleArticulation(c) => apply_toggle_articulation(c, score),
1058        Command::SetKeySignature(c) => {
1059            score.settings.key_signature = KeySignature {
1060                fifths: c.fifths,
1061                mode: "major".to_string(),
1062            };
1063            Ok(())
1064        }
1065        Command::SetTimeSignature(c) => apply_set_time_signature(c, score),
1066        Command::SetBarline(c) => apply_set_barline(c, score),
1067        Command::AddPart(c) => apply_add_part(c, score),
1068        Command::DeletePart(c) => apply_delete_part(c, score),
1069        Command::SetMetadata(c) => apply_set_metadata(c, score),
1070        Command::SetRehearsalMark(c) => {
1071            for_each_measure_at(score, c.measure_index, |m| {
1072                m.rehearsal = c.text.clone();
1073            });
1074            Ok(())
1075        }
1076        Command::SetNavigationMark(c) => {
1077            for_each_measure_at(score, c.measure_index, |m| {
1078                m.navigation = c.mark.clone();
1079            });
1080            Ok(())
1081        }
1082        Command::SetChordSymbol(c) => {
1083            get_note_mut(
1084                score,
1085                c.part_index,
1086                c.staff_index,
1087                c.measure_index,
1088                c.voice,
1089                c.note_index,
1090            )?
1091            .chord_symbol = c.chord.clone();
1092            Ok(())
1093        }
1094        Command::SetGrace(c) => {
1095            let note = get_note_mut(
1096                score,
1097                c.part_index,
1098                c.staff_index,
1099                c.measure_index,
1100                c.voice,
1101                c.note_index,
1102            )?;
1103            if note.is_rest {
1104                return Err(Error::InvalidCommand(
1105                    "cannot make a rest into a grace note".into(),
1106                ));
1107            }
1108            note.is_grace = c.is_grace;
1109            note.grace_slash = c.slash;
1110            Ok(())
1111        }
1112        Command::SetOttava(c) => {
1113            let note = get_note_mut(
1114                score,
1115                c.part_index,
1116                c.staff_index,
1117                c.measure_index,
1118                c.voice,
1119                c.note_index,
1120            )?;
1121            note.ottava_start = c.ottava_start;
1122            note.ottava_end = c.ottava_end;
1123            Ok(())
1124        }
1125        Command::SetLyric(c) => {
1126            get_note_mut(
1127                score,
1128                c.part_index,
1129                c.staff_index,
1130                c.measure_index,
1131                c.voice,
1132                c.note_index,
1133            )?
1134            .lyric = c.lyric.clone();
1135            Ok(())
1136        }
1137        Command::SetMultiRest(c) => {
1138            for_each_measure_at(score, c.measure_index, |m| {
1139                m.multi_rest_count = c.count;
1140            });
1141            Ok(())
1142        }
1143        Command::AddPedal(c) => apply_add_pedal(c, score),
1144        Command::SetVolta(c) => {
1145            for_each_measure_at(score, c.measure_index, |m| {
1146                m.volta = c.volta.clone();
1147            });
1148            Ok(())
1149        }
1150        Command::SetClef(c) => apply_set_clef(c, score),
1151        Command::SetPartName(c) => apply_set_part_name(c, score),
1152        Command::SetMidiInstrument(c) => apply_set_midi_instrument(c, score),
1153        Command::SetTranspose(c) => apply_set_transpose(c, score),
1154        Command::SetTempoAtMeasure(c) => {
1155            for_each_measure_at(score, c.measure_index, |m| {
1156                m.tempo = c.bpm;
1157            });
1158            Ok(())
1159        }
1160        Command::PasteVoice(c) => apply_paste_voice(c, score),
1161        Command::PasteRange(c) => apply_paste_range(c, score),
1162        Command::SetSystemBreak(c) => {
1163            for_each_measure_at(score, c.measure_index, |m| {
1164                m.system_break = c.value;
1165            });
1166            Ok(())
1167        }
1168        Command::SetPageBreak(c) => {
1169            for_each_measure_at(score, c.measure_index, |m| {
1170                m.page_break = c.value;
1171            });
1172            Ok(())
1173        }
1174        Command::ToggleSlur(c) => apply_toggle_slur(c, score),
1175        Command::AddStaff(c) => apply_add_staff(c, score),
1176        Command::DeleteStaff(c) => apply_delete_staff(c, score),
1177        Command::SetTuplet(c) => {
1178            get_note_mut(
1179                score,
1180                c.part_index,
1181                c.staff_index,
1182                c.measure_index,
1183                c.voice_index,
1184                c.note_index,
1185            )?
1186            .tuplet = c.tuplet.clone();
1187            Ok(())
1188        }
1189        Command::RespellScore(c) => {
1190            respell_score(score, c.prefer_flat);
1191            Ok(())
1192        }
1193        Command::RespellScoreToKey(_) => {
1194            respell_score_to_key(score);
1195            Ok(())
1196        }
1197        Command::SetStem(c) => {
1198            get_note_mut(
1199                score,
1200                c.part_index,
1201                c.staff_index,
1202                c.measure_index,
1203                c.voice_index,
1204                c.note_index,
1205            )?
1206            .stem_up = c.stem_up;
1207            Ok(())
1208        }
1209        Command::SetArpeggio(c) => {
1210            get_note_mut(
1211                score,
1212                c.part_index,
1213                c.staff_index,
1214                c.measure_index,
1215                c.voice_index,
1216                c.note_index,
1217            )?
1218            .arpeggiate = c.direction;
1219            Ok(())
1220        }
1221        Command::SetTechniqueText(c) => {
1222            get_note_mut(
1223                score,
1224                c.part_index,
1225                c.staff_index,
1226                c.measure_index,
1227                c.voice,
1228                c.note_index,
1229            )?
1230            .technique_text = c.text.clone();
1231            Ok(())
1232        }
1233        Command::SetGlissando(c) => {
1234            let note = get_note_mut(
1235                score,
1236                c.part_index,
1237                c.staff_index,
1238                c.measure_index,
1239                c.voice,
1240                c.note_index,
1241            )?;
1242            note.glissando_start = c.start;
1243            note.glissando_end = c.end;
1244            Ok(())
1245        }
1246        Command::SetCrossStaff(c) => {
1247            let staff_count = score
1248                .parts
1249                .get(c.part_index)
1250                .ok_or(Error::PartNotFound(c.part_index))?
1251                .staves
1252                .len();
1253            let note = get_note_mut(
1254                score,
1255                c.part_index,
1256                c.staff_index,
1257                c.measure_index,
1258                c.voice,
1259                c.note_index,
1260            )?;
1261            if let Some(ref placement) = c.placement
1262                && placement.target_staff == c.staff_index
1263            {
1264                return Err(Error::InvalidCommand(
1265                    "cross-staff target must differ from source staff".into(),
1266                ));
1267            }
1268            if let Some(ref placement) = c.placement
1269                && placement.target_staff >= staff_count
1270            {
1271                return Err(Error::StaffNotFound(placement.target_staff));
1272            }
1273            note.cross_staff = c.placement.clone();
1274            Ok(())
1275        }
1276        Command::SetFingering(c) => {
1277            get_note_mut(
1278                score,
1279                c.part_index,
1280                c.staff_index,
1281                c.measure_index,
1282                c.voice,
1283                c.note_index,
1284            )?
1285            .fingering = c.fingering;
1286            Ok(())
1287        }
1288        Command::SetStringNumber(c) => {
1289            get_note_mut(
1290                score,
1291                c.part_index,
1292                c.staff_index,
1293                c.measure_index,
1294                c.voice,
1295                c.note_index,
1296            )?
1297            .string_number = c.string_number;
1298            Ok(())
1299        }
1300        Command::SetGuitarTechnique(c) => {
1301            get_note_mut(
1302                score,
1303                c.part_index,
1304                c.staff_index,
1305                c.measure_index,
1306                c.voice,
1307                c.note_index,
1308            )?
1309            .guitar_technique = c.technique.clone();
1310            Ok(())
1311        }
1312        Command::SetNoteHead(c) => {
1313            get_note_mut(
1314                score,
1315                c.part_index,
1316                c.staff_index,
1317                c.measure_index,
1318                c.voice,
1319                c.note_index,
1320            )?
1321            .note_head = c.note_head.clone();
1322            Ok(())
1323        }
1324        Command::SetCue(c) => {
1325            get_note_mut(
1326                score,
1327                c.part_index,
1328                c.staff_index,
1329                c.measure_index,
1330                c.voice,
1331                c.note_index,
1332            )?
1333            .is_cue = c.is_cue;
1334            Ok(())
1335        }
1336        Command::SetExpressionText(c) => {
1337            for_each_measure_at(score, c.measure_index, |m| {
1338                m.expression_text = c.text.clone();
1339            });
1340            Ok(())
1341        }
1342        Command::ToggleTrillLine(c) => apply_toggle_trill_line(c, score),
1343        Command::SetPartGroup(c) => {
1344            if let Some(group) = &c.group {
1345                score
1346                    .part_groups
1347                    .retain(|g| g.first_part != group.first_part || g.last_part != group.last_part);
1348                score.part_groups.push(group.clone());
1349            } else {
1350                // When None, the command carries no range info so we clear all groups.
1351                score.part_groups.clear();
1352            }
1353            Ok(())
1354        }
1355        Command::Batch(c) => {
1356            for cmd in &c.commands {
1357                apply_command(cmd, score)?;
1358            }
1359            Ok(())
1360        }
1361    }
1362}
1363
1364// ── helpers ──────────────────────────────────────────────────────────────────
1365
1366fn get_note_mut(
1367    score: &mut Score,
1368    part_index: usize,
1369    staff_index: usize,
1370    measure_index: usize,
1371    voice: usize,
1372    note_index: usize,
1373) -> Result<&mut Note, Error> {
1374    score
1375        .parts
1376        .get_mut(part_index)
1377        .ok_or(Error::PartNotFound(part_index))?
1378        .staves
1379        .get_mut(staff_index)
1380        .ok_or(Error::StaffNotFound(staff_index))?
1381        .measures
1382        .get_mut(measure_index)
1383        .ok_or(Error::MeasureNotFound(measure_index))?
1384        .voices
1385        .get_mut(voice)
1386        .ok_or(Error::VoiceOutOfRange(voice))?
1387        .get_mut(note_index)
1388        .ok_or(Error::NoteNotFound(note_index))
1389}
1390
1391fn for_each_measure_at(score: &mut Score, index: usize, mut f: impl FnMut(&mut Measure)) {
1392    for part in &mut score.parts {
1393        for staff in &mut part.staves {
1394            if let Some(m) = staff.measures.get_mut(index) {
1395                f(m);
1396            }
1397        }
1398    }
1399}
1400
1401fn apply_add_note(cmd: &AddNoteCmd, score: &mut Score) -> Result<(), Error> {
1402    let ts_beats = score.settings.time_signature.total_beats();
1403    let voice = score
1404        .parts
1405        .get_mut(cmd.part_index)
1406        .ok_or(Error::PartNotFound(cmd.part_index))?
1407        .staves
1408        .get_mut(cmd.staff_index)
1409        .ok_or(Error::StaffNotFound(cmd.staff_index))?
1410        .measures
1411        .get_mut(cmd.measure_index)
1412        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
1413        .voices
1414        .get_mut(cmd.voice)
1415        .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
1416
1417    let note = if cmd.is_rest {
1418        let mut n = Note::rest(cmd.duration.clone());
1419        n.dot_count = cmd.dot_count;
1420        n.tuplet = cmd.tuplet.clone();
1421        n
1422    } else {
1423        let pitch = cmd
1424            .pitch
1425            .clone()
1426            .ok_or_else(|| Error::InvalidCommand("pitch required for non-rest note".into()))?;
1427        let mut n = Note::new(pitch, cmd.duration.clone());
1428        n.dot_count = cmd.dot_count;
1429        n.tuplet = cmd.tuplet.clone();
1430        n
1431    };
1432
1433    let pos = cmd.position.min(voice.len());
1434    voice.insert(pos, note);
1435    trim_voice_to_measure(voice, ts_beats);
1436    Ok(())
1437}
1438
1439fn apply_add_pitch(cmd: &AddPitchCmd, score: &mut Score) -> Result<(), Error> {
1440    let voice = score
1441        .parts
1442        .get_mut(cmd.part_index)
1443        .ok_or(Error::PartNotFound(cmd.part_index))?
1444        .staves
1445        .get_mut(cmd.staff_index)
1446        .ok_or(Error::StaffNotFound(cmd.staff_index))?
1447        .measures
1448        .get_mut(cmd.measure_index)
1449        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
1450        .voices
1451        .get_mut(cmd.voice)
1452        .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
1453    let note = voice
1454        .get_mut(cmd.note_index)
1455        .ok_or(Error::NoteNotFound(cmd.note_index))?;
1456    if note.is_rest {
1457        return Err(Error::InvalidCommand("cannot add pitch to a rest".into()));
1458    }
1459    if !note
1460        .pitches
1461        .iter()
1462        .any(|p| p.step == cmd.pitch.step && p.octave == cmd.pitch.octave)
1463    {
1464        note.pitches.push(cmd.pitch.clone());
1465    }
1466    Ok(())
1467}
1468
1469fn apply_set_duration(cmd: &SetDurationCmd, score: &mut Score) -> Result<(), Error> {
1470    let ts_beats = score.settings.time_signature.total_beats();
1471    let voice = score
1472        .parts
1473        .get_mut(cmd.part_index)
1474        .ok_or(Error::PartNotFound(cmd.part_index))?
1475        .staves
1476        .get_mut(cmd.staff_index)
1477        .ok_or(Error::StaffNotFound(cmd.staff_index))?
1478        .measures
1479        .get_mut(cmd.measure_index)
1480        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
1481        .voices
1482        .get_mut(cmd.voice)
1483        .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
1484    let note = voice
1485        .get_mut(cmd.note_index)
1486        .ok_or(Error::NoteNotFound(cmd.note_index))?;
1487    note.duration = cmd.duration.clone();
1488    note.dot_count = cmd.dot_count;
1489    trim_voice_to_measure(voice, ts_beats);
1490    Ok(())
1491}
1492
1493fn apply_delete_note(cmd: &DeleteNoteCmd, score: &mut Score) -> Result<(), Error> {
1494    let ts_beats = score.settings.time_signature.total_beats();
1495    let voice = score
1496        .parts
1497        .get_mut(cmd.part_index)
1498        .ok_or(Error::PartNotFound(cmd.part_index))?
1499        .staves
1500        .get_mut(cmd.staff_index)
1501        .ok_or(Error::StaffNotFound(cmd.staff_index))?
1502        .measures
1503        .get_mut(cmd.measure_index)
1504        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
1505        .voices
1506        .get_mut(cmd.voice)
1507        .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
1508    voice.retain(|n| n.id != cmd.note_id);
1509    pad_voice_to_measure(voice, ts_beats);
1510    Ok(())
1511}
1512
1513fn apply_add_measure(cmd: &AddMeasureCmd, score: &mut Score) -> Result<(), Error> {
1514    let ts = score.settings.time_signature.clone();
1515    for part in &mut score.parts {
1516        for staff in &mut part.staves {
1517            let insert_at = (cmd.after_index + 1).min(staff.measures.len());
1518            let mut m = Measure::empty(ts.numerator, ts.denominator);
1519            m.number = insert_at as u32 + 1;
1520            staff.measures.insert(insert_at, m);
1521            for (i, measure) in staff.measures.iter_mut().enumerate() {
1522                measure.number = i as u32 + 1;
1523            }
1524        }
1525    }
1526    Ok(())
1527}
1528
1529fn apply_delete_measure(cmd: &DeleteMeasureCmd, score: &mut Score) -> Result<(), Error> {
1530    for part in &mut score.parts {
1531        for staff in &mut part.staves {
1532            if cmd.measure_index < staff.measures.len() {
1533                staff.measures.remove(cmd.measure_index);
1534                for (i, m) in staff.measures.iter_mut().enumerate() {
1535                    m.number = i as u32 + 1;
1536                }
1537            }
1538        }
1539    }
1540    Ok(())
1541}
1542
1543fn apply_add_hairpin(cmd: &AddHairpinCmd, score: &mut Score) -> Result<(), Error> {
1544    let voice = score
1545        .parts
1546        .get_mut(cmd.part_index)
1547        .ok_or(Error::PartNotFound(cmd.part_index))?
1548        .staves
1549        .get_mut(cmd.staff_index)
1550        .ok_or(Error::StaffNotFound(cmd.staff_index))?
1551        .measures
1552        .get_mut(cmd.measure_index)
1553        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
1554        .voices
1555        .get_mut(cmd.voice)
1556        .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
1557    if cmd.start_note_idx >= voice.len() {
1558        return Err(Error::NoteNotFound(cmd.start_note_idx));
1559    }
1560    if cmd.end_note_idx >= voice.len() {
1561        return Err(Error::NoteNotFound(cmd.end_note_idx));
1562    }
1563    if cmd.start_note_idx >= cmd.end_note_idx {
1564        return Err(Error::InvalidCommand(
1565            "start_note_idx must be less than end_note_idx".into(),
1566        ));
1567    }
1568    for note in voice
1569        .iter_mut()
1570        .take(cmd.end_note_idx + 1)
1571        .skip(cmd.start_note_idx)
1572    {
1573        note.hairpin_start = None;
1574        note.hairpin_end = false;
1575    }
1576    voice[cmd.start_note_idx].hairpin_start = Some(cmd.kind);
1577    voice[cmd.end_note_idx].hairpin_end = true;
1578    Ok(())
1579}
1580
1581fn apply_add_pedal(cmd: &AddPedalCmd, score: &mut Score) -> Result<(), Error> {
1582    let voice = score
1583        .parts
1584        .get_mut(cmd.part_index)
1585        .ok_or(Error::PartNotFound(cmd.part_index))?
1586        .staves
1587        .get_mut(cmd.staff_index)
1588        .ok_or(Error::StaffNotFound(cmd.staff_index))?
1589        .measures
1590        .get_mut(cmd.measure_index)
1591        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
1592        .voices
1593        .get_mut(cmd.voice)
1594        .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
1595    if cmd.start_note_idx >= voice.len() {
1596        return Err(Error::NoteNotFound(cmd.start_note_idx));
1597    }
1598    if cmd.end_note_idx >= voice.len() {
1599        return Err(Error::NoteNotFound(cmd.end_note_idx));
1600    }
1601    if cmd.start_note_idx >= cmd.end_note_idx {
1602        return Err(Error::InvalidCommand(
1603            "start_note_idx must be less than end_note_idx".into(),
1604        ));
1605    }
1606    for note in voice
1607        .iter_mut()
1608        .take(cmd.end_note_idx + 1)
1609        .skip(cmd.start_note_idx)
1610    {
1611        note.pedal_start = false;
1612        note.pedal_end = false;
1613    }
1614    voice[cmd.start_note_idx].pedal_start = true;
1615    voice[cmd.end_note_idx].pedal_end = true;
1616    Ok(())
1617}
1618
1619fn apply_toggle_tie(cmd: &ToggleTieCmd, score: &mut Score) -> Result<(), Error> {
1620    let current_tie_start = {
1621        let v = score
1622            .parts
1623            .get(cmd.part_index)
1624            .ok_or(Error::PartNotFound(cmd.part_index))?
1625            .staves
1626            .get(cmd.staff_index)
1627            .ok_or(Error::StaffNotFound(cmd.staff_index))?
1628            .measures
1629            .get(cmd.measure_index)
1630            .ok_or(Error::MeasureNotFound(cmd.measure_index))?
1631            .voices
1632            .get(cmd.voice)
1633            .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
1634        v.get(cmd.note_index)
1635            .ok_or(Error::NoteNotFound(cmd.note_index))?
1636            .tie_start
1637    };
1638    let voice_len = score.parts[cmd.part_index].staves[cmd.staff_index].measures[cmd.measure_index]
1639        .voices[cmd.voice]
1640        .len();
1641    let total_measures = score.parts[cmd.part_index].staves[cmd.staff_index]
1642        .measures
1643        .len();
1644
1645    let new_tie = !current_tie_start;
1646    score.parts[cmd.part_index].staves[cmd.staff_index].measures[cmd.measure_index].voices
1647        [cmd.voice][cmd.note_index]
1648        .tie_start = new_tie;
1649
1650    if cmd.note_index + 1 < voice_len {
1651        score.parts[cmd.part_index].staves[cmd.staff_index].measures[cmd.measure_index].voices
1652            [cmd.voice][cmd.note_index + 1]
1653            .tie_end = new_tie;
1654    } else {
1655        let next_mi = cmd.measure_index + 1;
1656        if next_mi < total_measures {
1657            let next_voice = &mut score.parts[cmd.part_index].staves[cmd.staff_index].measures
1658                [next_mi]
1659                .voices[cmd.voice];
1660            if let Some(n) = next_voice.get_mut(0) {
1661                n.tie_end = new_tie;
1662            }
1663        }
1664    }
1665    Ok(())
1666}
1667
1668fn apply_set_dynamic(cmd: &SetDynamicCmd, score: &mut Score) -> Result<(), Error> {
1669    get_note_mut(
1670        score,
1671        cmd.part_index,
1672        cmd.staff_index,
1673        cmd.measure_index,
1674        cmd.voice,
1675        cmd.note_index,
1676    )?
1677    .dynamic = cmd.dynamic.clone();
1678    Ok(())
1679}
1680
1681fn apply_toggle_articulation(cmd: &ToggleArticulationCmd, score: &mut Score) -> Result<(), Error> {
1682    let note = get_note_mut(
1683        score,
1684        cmd.part_index,
1685        cmd.staff_index,
1686        cmd.measure_index,
1687        cmd.voice,
1688        cmd.note_index,
1689    )?;
1690    if let Some(pos) = note
1691        .articulations
1692        .iter()
1693        .position(|a| a == &cmd.articulation)
1694    {
1695        note.articulations.remove(pos);
1696    } else {
1697        note.articulations.push(cmd.articulation.clone());
1698    }
1699    Ok(())
1700}
1701
1702fn apply_set_time_signature(cmd: &SetTimeSignatureCmd, score: &mut Score) -> Result<(), Error> {
1703    if cmd.numerator == 0 || cmd.denominator == 0 {
1704        return Err(Error::InvalidCommand(
1705            "time signature numerator and denominator must be > 0".into(),
1706        ));
1707    }
1708    if ![1u8, 2, 4, 8, 16, 32].contains(&cmd.denominator) {
1709        return Err(Error::InvalidCommand(format!(
1710            "invalid time signature denominator: {}",
1711            cmd.denominator
1712        )));
1713    }
1714    score.settings.time_signature = TimeSignature {
1715        numerator: cmd.numerator,
1716        denominator: cmd.denominator,
1717    };
1718    let max_beats = score.settings.time_signature.total_beats();
1719    for part in &mut score.parts {
1720        for staff in &mut part.staves {
1721            for measure in &mut staff.measures {
1722                for voice in &mut measure.voices {
1723                    trim_voice_to_measure(voice, max_beats);
1724                    pad_voice_to_measure(voice, max_beats);
1725                }
1726            }
1727        }
1728    }
1729    Ok(())
1730}
1731
1732fn apply_set_barline(cmd: &SetBarlineCmd, score: &mut Score) -> Result<(), Error> {
1733    for part in &mut score.parts {
1734        for staff in &mut part.staves {
1735            let measure = staff
1736                .measures
1737                .get_mut(cmd.measure_index)
1738                .ok_or(Error::MeasureNotFound(cmd.measure_index))?;
1739            match cmd.side.as_str() {
1740                "left" => measure.barline_left = cmd.barline.clone(),
1741                "right" => measure.barline_right = cmd.barline.clone(),
1742                _ => {
1743                    return Err(Error::InvalidCommand(format!(
1744                        "invalid barline side: '{}'",
1745                        cmd.side
1746                    )));
1747                }
1748            }
1749        }
1750    }
1751    Ok(())
1752}
1753
1754fn apply_add_part(cmd: &AddPartCmd, score: &mut Score) -> Result<(), Error> {
1755    if cmd.clefs.is_empty() {
1756        return Err(Error::InvalidCommand(
1757            "AddPart requires at least one clef".into(),
1758        ));
1759    }
1760    let measure_count = score.measure_count();
1761    let ts = score.settings.time_signature.clone();
1762    let mut part = Part::new(&cmd.name, &cmd.short_name);
1763    part.midi_channel = cmd.midi_channel.min(15);
1764    part.midi_program = cmd.midi_program;
1765    for clef_str in &cmd.clefs {
1766        let clef = match clef_str.as_str() {
1767            "Bass" => Clef::Bass,
1768            "Alto" => Clef::Alto,
1769            "Tenor" => Clef::Tenor,
1770            "Percussion" => Clef::Percussion,
1771            _ => Clef::Treble,
1772        };
1773        let mut staff = Staff::new(clef);
1774        for i in 0..measure_count {
1775            let mut m = Measure::empty(ts.numerator, ts.denominator);
1776            m.number = i as u32 + 1;
1777            staff.measures.push(m);
1778        }
1779        part.staves.push(staff);
1780    }
1781    score.parts.push(part);
1782    Ok(())
1783}
1784
1785fn apply_set_clef(cmd: &SetClefCmd, score: &mut Score) -> Result<(), Error> {
1786    score
1787        .parts
1788        .get_mut(cmd.part_index)
1789        .ok_or(Error::PartNotFound(cmd.part_index))?
1790        .staves
1791        .get_mut(cmd.staff_index)
1792        .ok_or(Error::StaffNotFound(cmd.staff_index))?
1793        .clef = cmd.clef.clone();
1794    Ok(())
1795}
1796
1797fn apply_set_part_name(cmd: &SetPartNameCmd, score: &mut Score) -> Result<(), Error> {
1798    let part = score
1799        .parts
1800        .get_mut(cmd.part_index)
1801        .ok_or(Error::PartNotFound(cmd.part_index))?;
1802    part.name = cmd.name.clone();
1803    part.short_name = cmd.short_name.clone();
1804    Ok(())
1805}
1806
1807fn apply_delete_part(cmd: &DeletePartCmd, score: &mut Score) -> Result<(), Error> {
1808    if cmd.part_index >= score.parts.len() {
1809        return Err(Error::PartNotFound(cmd.part_index));
1810    }
1811    score.parts.remove(cmd.part_index);
1812    Ok(())
1813}
1814
1815fn apply_set_metadata(cmd: &SetMetadataCmd, score: &mut Score) -> Result<(), Error> {
1816    if let Some(v) = &cmd.title {
1817        score.metadata.title = v.clone();
1818    }
1819    if let Some(v) = &cmd.composer {
1820        score.metadata.composer = v.clone();
1821    }
1822    if let Some(v) = &cmd.lyricist {
1823        score.metadata.lyricist = v.clone();
1824    }
1825    if let Some(v) = &cmd.copyright {
1826        score.metadata.copyright = v.clone();
1827    }
1828    if let Some(v) = &cmd.work_number {
1829        score.metadata.work_number = v.clone();
1830    }
1831    if let Some(v) = &cmd.movement_title {
1832        score.metadata.movement_title = v.clone();
1833    }
1834    Ok(())
1835}
1836
1837fn apply_set_midi_instrument(cmd: &SetMidiInstrumentCmd, score: &mut Score) -> Result<(), Error> {
1838    let part = score
1839        .parts
1840        .get_mut(cmd.part_index)
1841        .ok_or(Error::PartNotFound(cmd.part_index))?;
1842    part.midi_channel = cmd.midi_channel.min(15);
1843    part.midi_program = cmd.midi_program;
1844    Ok(())
1845}
1846
1847fn apply_set_transpose(cmd: &SetTransposeCmd, score: &mut Score) -> Result<(), Error> {
1848    score
1849        .parts
1850        .get_mut(cmd.part_index)
1851        .ok_or(Error::PartNotFound(cmd.part_index))?
1852        .staves
1853        .get_mut(cmd.staff_index)
1854        .ok_or(Error::StaffNotFound(cmd.staff_index))?
1855        .transpose_semitones = cmd.semitones;
1856    Ok(())
1857}
1858
1859fn apply_paste_voice(cmd: &PasteVoiceCmd, score: &mut Score) -> Result<(), Error> {
1860    let voice = score
1861        .parts
1862        .get_mut(cmd.part_index)
1863        .ok_or(Error::PartNotFound(cmd.part_index))?
1864        .staves
1865        .get_mut(cmd.staff_index)
1866        .ok_or(Error::StaffNotFound(cmd.staff_index))?
1867        .measures
1868        .get_mut(cmd.measure_index)
1869        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
1870        .voices
1871        .get_mut(cmd.voice_index)
1872        .ok_or(Error::VoiceOutOfRange(cmd.voice_index))?;
1873    *voice = cmd.notes.clone();
1874    Ok(())
1875}
1876
1877fn apply_paste_range(cmd: &PasteRangeCmd, score: &mut Score) -> Result<(), Error> {
1878    let part = score
1879        .parts
1880        .get_mut(cmd.part_index)
1881        .ok_or(Error::PartNotFound(cmd.part_index))?;
1882    let staff = part
1883        .staves
1884        .get_mut(cmd.staff_index)
1885        .ok_or(Error::StaffNotFound(cmd.staff_index))?;
1886    if cmd.voice_index >= 4 {
1887        return Err(Error::VoiceOutOfRange(cmd.voice_index));
1888    }
1889    for (offset, notes) in cmd.measures.iter().enumerate() {
1890        let mi = cmd.target_measure + offset;
1891        let measure = staff
1892            .measures
1893            .get_mut(mi)
1894            .ok_or(Error::MeasureNotFound(mi))?;
1895        measure.voices[cmd.voice_index] = notes.clone();
1896    }
1897    Ok(())
1898}
1899
1900fn apply_toggle_slur(cmd: &ToggleSlurCmd, score: &mut Score) -> Result<(), Error> {
1901    let new_start = !{
1902        score
1903            .parts
1904            .get(cmd.start.part)
1905            .ok_or(Error::PartNotFound(cmd.start.part))?
1906            .staves
1907            .get(cmd.start.staff)
1908            .ok_or(Error::StaffNotFound(cmd.start.staff))?
1909            .measures
1910            .get(cmd.start.measure)
1911            .ok_or(Error::MeasureNotFound(cmd.start.measure))?
1912            .voices
1913            .get(cmd.start.voice)
1914            .ok_or(Error::VoiceOutOfRange(cmd.start.voice))?
1915            .get(cmd.start.note)
1916            .ok_or(Error::NoteNotFound(cmd.start.note))?
1917            .slur_start
1918    };
1919    let new_end = !{
1920        score
1921            .parts
1922            .get(cmd.end.part)
1923            .ok_or(Error::PartNotFound(cmd.end.part))?
1924            .staves
1925            .get(cmd.end.staff)
1926            .ok_or(Error::StaffNotFound(cmd.end.staff))?
1927            .measures
1928            .get(cmd.end.measure)
1929            .ok_or(Error::MeasureNotFound(cmd.end.measure))?
1930            .voices
1931            .get(cmd.end.voice)
1932            .ok_or(Error::VoiceOutOfRange(cmd.end.voice))?
1933            .get(cmd.end.note)
1934            .ok_or(Error::NoteNotFound(cmd.end.note))?
1935            .slur_end
1936    };
1937    score.parts[cmd.start.part].staves[cmd.start.staff].measures[cmd.start.measure].voices
1938        [cmd.start.voice][cmd.start.note]
1939        .slur_start = new_start;
1940    score.parts[cmd.end.part].staves[cmd.end.staff].measures[cmd.end.measure].voices
1941        [cmd.end.voice][cmd.end.note]
1942        .slur_end = new_end;
1943    Ok(())
1944}
1945
1946fn apply_toggle_trill_line(cmd: &ToggleTrillLineCmd, score: &mut Score) -> Result<(), Error> {
1947    let new_start = !{
1948        score
1949            .parts
1950            .get(cmd.start.part)
1951            .ok_or(Error::PartNotFound(cmd.start.part))?
1952            .staves
1953            .get(cmd.start.staff)
1954            .ok_or(Error::StaffNotFound(cmd.start.staff))?
1955            .measures
1956            .get(cmd.start.measure)
1957            .ok_or(Error::MeasureNotFound(cmd.start.measure))?
1958            .voices
1959            .get(cmd.start.voice)
1960            .ok_or(Error::VoiceOutOfRange(cmd.start.voice))?
1961            .get(cmd.start.note)
1962            .ok_or(Error::NoteNotFound(cmd.start.note))?
1963            .trill_line_start
1964    };
1965    let new_end = !{
1966        score
1967            .parts
1968            .get(cmd.end.part)
1969            .ok_or(Error::PartNotFound(cmd.end.part))?
1970            .staves
1971            .get(cmd.end.staff)
1972            .ok_or(Error::StaffNotFound(cmd.end.staff))?
1973            .measures
1974            .get(cmd.end.measure)
1975            .ok_or(Error::MeasureNotFound(cmd.end.measure))?
1976            .voices
1977            .get(cmd.end.voice)
1978            .ok_or(Error::VoiceOutOfRange(cmd.end.voice))?
1979            .get(cmd.end.note)
1980            .ok_or(Error::NoteNotFound(cmd.end.note))?
1981            .trill_line_end
1982    };
1983    score.parts[cmd.start.part].staves[cmd.start.staff].measures[cmd.start.measure].voices
1984        [cmd.start.voice][cmd.start.note]
1985        .trill_line_start = new_start;
1986    score.parts[cmd.end.part].staves[cmd.end.staff].measures[cmd.end.measure].voices
1987        [cmd.end.voice][cmd.end.note]
1988        .trill_line_end = new_end;
1989    Ok(())
1990}
1991
1992fn apply_add_staff(cmd: &AddStaffCmd, score: &mut Score) -> Result<(), Error> {
1993    let ts = score.settings.time_signature.clone();
1994    let measure_count = score
1995        .parts
1996        .get(cmd.part_index)
1997        .ok_or(Error::PartNotFound(cmd.part_index))?
1998        .staves
1999        .first()
2000        .map_or(0, |s| s.measures.len());
2001    let mut staff = Staff::new(cmd.clef.clone());
2002    for i in 0..measure_count {
2003        let mut m = Measure::empty(ts.numerator, ts.denominator);
2004        m.number = i as u32 + 1;
2005        staff.measures.push(m);
2006    }
2007    score.parts[cmd.part_index].staves.push(staff);
2008    Ok(())
2009}
2010
2011fn apply_delete_staff(cmd: &DeleteStaffCmd, score: &mut Score) -> Result<(), Error> {
2012    let part = score
2013        .parts
2014        .get_mut(cmd.part_index)
2015        .ok_or(Error::PartNotFound(cmd.part_index))?;
2016    if part.staves.len() <= 1 {
2017        return Err(Error::CannotDeleteLastStaff);
2018    }
2019    if cmd.staff_index >= part.staves.len() {
2020        return Err(Error::StaffNotFound(cmd.staff_index));
2021    }
2022    part.staves.remove(cmd.staff_index);
2023    Ok(())
2024}
2025
2026fn trim_voice_to_measure(voice: &mut Vec<Note>, max_beats: f64) {
2027    let mut total = 0.0f64;
2028    let mut cutoff = voice.len();
2029    for (i, n) in voice.iter().enumerate() {
2030        total += n.beats();
2031        if total > max_beats + 1e-9 {
2032            cutoff = i;
2033            break;
2034        }
2035    }
2036    voice.truncate(cutoff);
2037    pad_voice_to_measure(voice, max_beats);
2038}
2039
2040fn pad_voice_to_measure(voice: &mut Vec<Note>, max_beats: f64) {
2041    let mut used: f64 = voice.iter().map(|n| n.beats()).sum();
2042    while max_beats - used > 1e-9 {
2043        let remaining = max_beats - used;
2044        let rest = Note::rest(Duration::whole_filling_beats(remaining));
2045        used += rest.beats();
2046        voice.push(rest);
2047    }
2048}
2049
2050#[cfg(test)]
2051mod tests {
2052    use super::*;
2053    use crate::model::pitch::Step;
2054
2055    fn default_engine_score() -> Score {
2056        let mut s = Score::default();
2057        for part in &mut s.parts {
2058            for staff in &mut part.staves {
2059                for (i, m) in staff.measures.iter_mut().enumerate() {
2060                    m.number = i as u32 + 1;
2061                }
2062            }
2063        }
2064        s
2065    }
2066
2067    #[test]
2068    fn add_note_inserts_into_voice() {
2069        let mut score = default_engine_score();
2070        let cmd = Command::AddNote(AddNoteCmd {
2071            part_index: 0,
2072            staff_index: 0,
2073            measure_index: 0,
2074            voice: 0,
2075            position: 0,
2076            pitch: Some(Pitch::new(Step::C, 4)),
2077            duration: Duration::Quarter,
2078            dot_count: 0,
2079            is_rest: false,
2080            tuplet: None,
2081        });
2082        apply_command(&cmd, &mut score).unwrap();
2083        let first = &score.parts[0].staves[0].measures[0].voices[0][0];
2084        assert!(!first.is_rest);
2085        assert_eq!(first.pitches[0].step, Step::C);
2086    }
2087
2088    #[test]
2089    fn set_duration_updates_note_and_preserves_measure_capacity() {
2090        let mut score = default_engine_score();
2091        apply_command(
2092            &Command::AddNote(AddNoteCmd {
2093                part_index: 0,
2094                staff_index: 0,
2095                measure_index: 0,
2096                voice: 0,
2097                position: 0,
2098                pitch: Some(Pitch::new(Step::C, 4)),
2099                duration: Duration::Quarter,
2100                dot_count: 0,
2101                is_rest: false,
2102                tuplet: None,
2103            }),
2104            &mut score,
2105        )
2106        .unwrap();
2107        apply_command(
2108            &Command::SetDuration(SetDurationCmd {
2109                part_index: 0,
2110                staff_index: 0,
2111                measure_index: 0,
2112                voice: 0,
2113                note_index: 0,
2114                duration: Duration::Half,
2115                dot_count: 1,
2116            }),
2117            &mut score,
2118        )
2119        .unwrap();
2120        let voice = &score.parts[0].staves[0].measures[0].voices[0];
2121        assert_eq!(voice[0].duration, Duration::Half);
2122        assert_eq!(voice[0].dot_count, 1);
2123        assert!((voice.iter().map(|note| note.beats()).sum::<f64>() - 4.0).abs() < 1e-9);
2124    }
2125
2126    #[test]
2127    fn set_tempo_updates_score() {
2128        let mut score = default_engine_score();
2129        let cmd = Command::SetTempo(SetTempoCmd { bpm: 160 });
2130        apply_command(&cmd, &mut score).unwrap();
2131        assert_eq!(score.settings.tempo_bpm, 160);
2132    }
2133
2134    #[test]
2135    fn add_measure_increases_count() {
2136        let mut score = default_engine_score();
2137        let before = score.measure_count();
2138        apply_command(
2139            &Command::AddMeasure(AddMeasureCmd { after_index: 0 }),
2140            &mut score,
2141        )
2142        .unwrap();
2143        assert_eq!(score.measure_count(), before + 1);
2144    }
2145
2146    #[test]
2147    fn delete_measure_decreases_count() {
2148        let mut score = default_engine_score();
2149        let before = score.measure_count();
2150        apply_command(
2151            &Command::DeleteMeasure(DeleteMeasureCmd { measure_index: 0 }),
2152            &mut score,
2153        )
2154        .unwrap();
2155        assert_eq!(score.measure_count(), before - 1);
2156    }
2157
2158    #[test]
2159    fn undo_restores_score() {
2160        let mut stack = CommandStack::new(50);
2161        let mut score = default_engine_score();
2162        let before = score.settings.tempo_bpm;
2163        stack
2164            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
2165            .unwrap();
2166        assert_eq!(score.settings.tempo_bpm, 200);
2167        stack.undo(&mut score).unwrap();
2168        assert_eq!(score.settings.tempo_bpm, before);
2169    }
2170
2171    #[test]
2172    fn redo_reapplies_command() {
2173        let mut stack = CommandStack::new(50);
2174        let mut score = default_engine_score();
2175        stack
2176            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
2177            .unwrap();
2178        stack.undo(&mut score).unwrap();
2179        stack.redo(&mut score).unwrap();
2180        assert_eq!(score.settings.tempo_bpm, 200);
2181    }
2182
2183    #[test]
2184    fn undo_nothing_returns_error() {
2185        let mut stack = CommandStack::new(50);
2186        let mut score = default_engine_score();
2187        assert!(stack.undo(&mut score).is_err());
2188    }
2189
2190    #[test]
2191    fn add_part_appends_part() {
2192        let mut score = default_engine_score();
2193        let before = score.parts.len();
2194        apply_command(
2195            &Command::AddPart(AddPartCmd {
2196                name: "Violin".into(),
2197                short_name: "Vln.".into(),
2198                clefs: vec!["Treble".into()],
2199                midi_channel: 0,
2200                midi_program: 0,
2201            }),
2202            &mut score,
2203        )
2204        .unwrap();
2205        assert_eq!(score.parts.len(), before + 1);
2206    }
2207
2208    #[test]
2209    fn delete_part_removes_part() {
2210        let mut score = default_engine_score();
2211        apply_command(
2212            &Command::AddPart(AddPartCmd {
2213                name: "Violin".into(),
2214                short_name: "V.".into(),
2215                clefs: vec!["Treble".into()],
2216                midi_channel: 0,
2217                midi_program: 0,
2218            }),
2219            &mut score,
2220        )
2221        .unwrap();
2222        let before = score.parts.len();
2223        apply_command(
2224            &Command::DeletePart(DeletePartCmd { part_index: 0 }),
2225            &mut score,
2226        )
2227        .unwrap();
2228        assert_eq!(score.parts.len(), before - 1);
2229    }
2230
2231    #[test]
2232    fn delete_part_out_of_range_returns_err() {
2233        let mut score = default_engine_score();
2234        assert!(
2235            apply_command(
2236                &Command::DeletePart(DeletePartCmd { part_index: 99 }),
2237                &mut score
2238            )
2239            .is_err()
2240        );
2241    }
2242
2243    #[test]
2244    fn delete_part_undo_restores_part() {
2245        let mut stack = CommandStack::new(50);
2246        let mut score = default_engine_score();
2247        apply_command(
2248            &Command::AddPart(AddPartCmd {
2249                name: "Violin".into(),
2250                short_name: "V.".into(),
2251                clefs: vec!["Treble".into()],
2252                midi_channel: 0,
2253                midi_program: 0,
2254            }),
2255            &mut score,
2256        )
2257        .unwrap();
2258        let before = score.parts.len();
2259        stack
2260            .execute(
2261                Command::DeletePart(DeletePartCmd { part_index: 0 }),
2262                &mut score,
2263            )
2264            .unwrap();
2265        assert_eq!(score.parts.len(), before - 1);
2266        stack.undo(&mut score).unwrap();
2267        assert_eq!(score.parts.len(), before);
2268    }
2269
2270    #[test]
2271    fn set_metadata_updates_title() {
2272        let mut score = default_engine_score();
2273        apply_command(
2274            &Command::SetMetadata(SetMetadataCmd {
2275                title: Some("New Title".into()),
2276                ..Default::default()
2277            }),
2278            &mut score,
2279        )
2280        .unwrap();
2281        assert_eq!(score.metadata.title, "New Title");
2282    }
2283
2284    #[test]
2285    fn set_metadata_none_fields_skipped() {
2286        let mut score = default_engine_score();
2287        let original_composer = score.metadata.composer.clone();
2288        apply_command(
2289            &Command::SetMetadata(SetMetadataCmd {
2290                title: Some("X".into()),
2291                ..Default::default()
2292            }),
2293            &mut score,
2294        )
2295        .unwrap();
2296        assert_eq!(score.metadata.composer, original_composer);
2297    }
2298
2299    #[test]
2300    fn set_volta_sets_bracket() {
2301        use crate::model::score::VoltaBracket;
2302        let mut score = default_engine_score();
2303        let volta = VoltaBracket {
2304            number: 1,
2305            kind: "begin_end".into(),
2306        };
2307        apply_command(
2308            &Command::SetVolta(SetVoltaCmd {
2309                measure_index: 0,
2310                volta: Some(volta.clone()),
2311            }),
2312            &mut score,
2313        )
2314        .unwrap();
2315        assert!(score.parts[0].staves[0].measures[0].volta.is_some());
2316    }
2317
2318    #[test]
2319    fn set_volta_none_clears_bracket() {
2320        use crate::model::score::VoltaBracket;
2321        let mut score = default_engine_score();
2322        score.parts[0].staves[0].measures[0].volta = Some(VoltaBracket {
2323            number: 1,
2324            kind: "begin_end".into(),
2325        });
2326        apply_command(
2327            &Command::SetVolta(SetVoltaCmd {
2328                measure_index: 0,
2329                volta: None,
2330            }),
2331            &mut score,
2332        )
2333        .unwrap();
2334        assert!(score.parts[0].staves[0].measures[0].volta.is_none());
2335    }
2336
2337    #[test]
2338    fn set_volta_undo_restores_old() {
2339        use crate::model::score::VoltaBracket;
2340        let mut stack = CommandStack::new(50);
2341        let mut score = default_engine_score();
2342        stack
2343            .execute(
2344                Command::SetVolta(SetVoltaCmd {
2345                    measure_index: 0,
2346                    volta: Some(VoltaBracket {
2347                        number: 1,
2348                        kind: "begin_end".into(),
2349                    }),
2350                }),
2351                &mut score,
2352            )
2353            .unwrap();
2354        stack.undo(&mut score).unwrap();
2355        assert!(score.parts[0].staves[0].measures[0].volta.is_none());
2356    }
2357
2358    #[test]
2359    fn set_clef_updates_staff_clef() {
2360        use crate::model::notation::Clef;
2361        let mut score = default_engine_score();
2362        apply_command(
2363            &Command::SetClef(SetClefCmd {
2364                part_index: 0,
2365                staff_index: 0,
2366                clef: Clef::Bass,
2367            }),
2368            &mut score,
2369        )
2370        .unwrap();
2371        assert_eq!(score.parts[0].staves[0].clef, Clef::Bass);
2372    }
2373
2374    #[test]
2375    fn set_clef_out_of_range_returns_err() {
2376        use crate::model::notation::Clef;
2377        let mut score = default_engine_score();
2378        assert!(
2379            apply_command(
2380                &Command::SetClef(SetClefCmd {
2381                    part_index: 99,
2382                    staff_index: 0,
2383                    clef: Clef::Bass,
2384                }),
2385                &mut score
2386            )
2387            .is_err()
2388        );
2389    }
2390
2391    #[test]
2392    fn set_part_name_updates_name() {
2393        let mut score = default_engine_score();
2394        apply_command(
2395            &Command::SetPartName(SetPartNameCmd {
2396                part_index: 0,
2397                name: "Violin".into(),
2398                short_name: "Vln.".into(),
2399            }),
2400            &mut score,
2401        )
2402        .unwrap();
2403        assert_eq!(score.parts[0].name, "Violin");
2404        assert_eq!(score.parts[0].short_name, "Vln.");
2405    }
2406
2407    #[test]
2408    fn set_part_name_undo_restores_old() {
2409        let mut stack = CommandStack::new(50);
2410        let mut score = default_engine_score();
2411        let original = score.parts[0].name.clone();
2412        stack
2413            .execute(
2414                Command::SetPartName(SetPartNameCmd {
2415                    part_index: 0,
2416                    name: "Flute".into(),
2417                    short_name: "Fl.".into(),
2418                }),
2419                &mut score,
2420            )
2421            .unwrap();
2422        stack.undo(&mut score).unwrap();
2423        assert_eq!(score.parts[0].name, original);
2424    }
2425
2426    #[test]
2427    fn set_metadata_undo_restores_old_title() {
2428        let mut stack = CommandStack::new(50);
2429        let mut score = default_engine_score();
2430        let original = score.metadata.title.clone();
2431        stack
2432            .execute(
2433                Command::SetMetadata(SetMetadataCmd {
2434                    title: Some("Changed".into()),
2435                    ..Default::default()
2436                }),
2437                &mut score,
2438            )
2439            .unwrap();
2440        assert_ne!(score.metadata.title, original);
2441        stack.undo(&mut score).unwrap();
2442        assert_eq!(score.metadata.title, original);
2443    }
2444
2445    #[test]
2446    fn set_midi_instrument_updates_channel_and_program() {
2447        let mut score = default_engine_score();
2448        apply_command(
2449            &Command::SetMidiInstrument(SetMidiInstrumentCmd {
2450                part_index: 0,
2451                midi_channel: 2,
2452                midi_program: 40,
2453            }),
2454            &mut score,
2455        )
2456        .unwrap();
2457        assert_eq!(score.parts[0].midi_channel, 2);
2458        assert_eq!(score.parts[0].midi_program, 40);
2459    }
2460
2461    #[test]
2462    fn set_midi_instrument_clamps_channel_to_15() {
2463        let mut score = default_engine_score();
2464        apply_command(
2465            &Command::SetMidiInstrument(SetMidiInstrumentCmd {
2466                part_index: 0,
2467                midi_channel: 20,
2468                midi_program: 0,
2469            }),
2470            &mut score,
2471        )
2472        .unwrap();
2473        assert_eq!(score.parts[0].midi_channel, 15);
2474    }
2475
2476    #[test]
2477    fn set_midi_instrument_undo_restores_old() {
2478        let mut stack = CommandStack::new(50);
2479        let mut score = default_engine_score();
2480        score.parts[0].midi_channel = 3;
2481        score.parts[0].midi_program = 10;
2482        stack
2483            .execute(
2484                Command::SetMidiInstrument(SetMidiInstrumentCmd {
2485                    part_index: 0,
2486                    midi_channel: 9,
2487                    midi_program: 114,
2488                }),
2489                &mut score,
2490            )
2491            .unwrap();
2492        stack.undo(&mut score).unwrap();
2493        assert_eq!(score.parts[0].midi_channel, 3);
2494        assert_eq!(score.parts[0].midi_program, 10);
2495    }
2496
2497    #[test]
2498    fn set_transpose_updates_staff() {
2499        let mut score = default_engine_score();
2500        apply_command(
2501            &Command::SetTranspose(SetTransposeCmd {
2502                part_index: 0,
2503                staff_index: 0,
2504                semitones: -2,
2505            }),
2506            &mut score,
2507        )
2508        .unwrap();
2509        assert_eq!(score.parts[0].staves[0].transpose_semitones, -2);
2510    }
2511
2512    #[test]
2513    fn set_transpose_out_of_range_returns_err() {
2514        let mut score = default_engine_score();
2515        assert!(
2516            apply_command(
2517                &Command::SetTranspose(SetTransposeCmd {
2518                    part_index: 99,
2519                    staff_index: 0,
2520                    semitones: -2,
2521                }),
2522                &mut score
2523            )
2524            .is_err()
2525        );
2526    }
2527
2528    #[test]
2529    fn set_tempo_at_measure_sets_tempo() {
2530        let mut score = default_engine_score();
2531        apply_command(
2532            &Command::SetTempoAtMeasure(SetTempoAtMeasureCmd {
2533                measure_index: 0,
2534                bpm: Some(80),
2535            }),
2536            &mut score,
2537        )
2538        .unwrap();
2539        assert_eq!(score.parts[0].staves[0].measures[0].tempo, Some(80));
2540    }
2541
2542    #[test]
2543    fn set_tempo_at_measure_none_clears_tempo() {
2544        let mut score = default_engine_score();
2545        score.parts[0].staves[0].measures[0].tempo = Some(120);
2546        apply_command(
2547            &Command::SetTempoAtMeasure(SetTempoAtMeasureCmd {
2548                measure_index: 0,
2549                bpm: None,
2550            }),
2551            &mut score,
2552        )
2553        .unwrap();
2554        assert!(score.parts[0].staves[0].measures[0].tempo.is_none());
2555    }
2556
2557    // ── batch_execute ─────────────────────────────────────────────────────────
2558
2559    #[test]
2560    fn batch_execute_two_commands_single_undo() {
2561        let mut stack = CommandStack::new(50);
2562        let mut score = default_engine_score();
2563        let original_bpm = score.settings.tempo_bpm;
2564        stack
2565            .batch_execute(
2566                vec![
2567                    Command::SetTempo(SetTempoCmd { bpm: 160 }),
2568                    Command::SetTempo(SetTempoCmd { bpm: 180 }),
2569                ],
2570                &mut score,
2571            )
2572            .unwrap();
2573        assert_eq!(score.settings.tempo_bpm, 180);
2574        stack.undo(&mut score).unwrap();
2575        assert_eq!(score.settings.tempo_bpm, original_bpm);
2576    }
2577
2578    #[test]
2579    fn batch_execute_partial_failure_rollback() {
2580        let mut stack = CommandStack::new(50);
2581        let mut score = default_engine_score();
2582        let original_bpm = score.settings.tempo_bpm;
2583        let result = stack.batch_execute(
2584            vec![
2585                Command::SetTempo(SetTempoCmd { bpm: 160 }),
2586                Command::DeleteNote(DeleteNoteCmd {
2587                    note_id: "nonexistent".into(),
2588                    part_index: 99,
2589                    staff_index: 0,
2590                    measure_index: 0,
2591                    voice: 0,
2592                }),
2593            ],
2594            &mut score,
2595        );
2596        assert!(result.is_err());
2597        assert_eq!(score.settings.tempo_bpm, original_bpm);
2598    }
2599
2600    #[test]
2601    fn batch_execute_empty_is_noop() {
2602        let mut stack = CommandStack::new(50);
2603        let mut score = default_engine_score();
2604        stack.batch_execute(vec![], &mut score).unwrap();
2605        assert!(!stack.can_undo());
2606    }
2607
2608    // ── BatchCmd.label ────────────────────────────────────────────────────────
2609
2610    #[test]
2611    fn batch_label_used_as_command_key() {
2612        let cmd = Command::Batch(BatchCmd {
2613            commands: vec![],
2614            label: Some("ApplyAI".to_string()),
2615        });
2616        assert_eq!(command_key(&cmd), "ApplyAI");
2617    }
2618
2619    #[test]
2620    fn batch_no_label_key_is_batch() {
2621        let cmd = Command::Batch(BatchCmd {
2622            commands: vec![],
2623            label: None,
2624        });
2625        assert_eq!(command_key(&cmd), "Batch");
2626    }
2627
2628    #[test]
2629    fn batch_label_survives_json_roundtrip() {
2630        let cmd = Command::Batch(BatchCmd {
2631            commands: vec![Command::SetTempo(SetTempoCmd { bpm: 120 })],
2632            label: Some("PasteSelection".to_string()),
2633        });
2634        let json = serde_json::to_string(&cmd).unwrap();
2635        let cmd2: Command = serde_json::from_str(&json).unwrap();
2636        assert_eq!(command_key(&cmd2), "PasteSelection");
2637    }
2638
2639    #[test]
2640    fn batch_label_in_undo_key() {
2641        let mut stack = CommandStack::new(50);
2642        let mut score = default_engine_score();
2643        let cmd = Command::Batch(BatchCmd {
2644            commands: vec![Command::SetTempo(SetTempoCmd { bpm: 140 })],
2645            label: Some("ApplyAI".to_string()),
2646        });
2647        stack.execute(cmd, &mut score).unwrap();
2648        assert_eq!(stack.undo_key(), Some("ApplyAI".to_string()));
2649    }
2650
2651    #[test]
2652    fn undo_returns_change_hint() {
2653        use crate::model::change_hint::ChangeScope;
2654        let mut stack = CommandStack::new(50);
2655        let mut score = default_engine_score();
2656        stack
2657            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
2658            .unwrap();
2659        let hint = stack.undo(&mut score).unwrap();
2660        assert_eq!(hint.scope, ChangeScope::Global);
2661        assert!(hint.playback_dirty);
2662    }
2663
2664    #[test]
2665    fn redo_returns_change_hint() {
2666        use crate::model::change_hint::ChangeScope;
2667        let mut stack = CommandStack::new(50);
2668        let mut score = default_engine_score();
2669        stack
2670            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
2671            .unwrap();
2672        stack.undo(&mut score).unwrap();
2673        let hint = stack.redo(&mut score).unwrap();
2674        assert_eq!(hint.scope, ChangeScope::Global);
2675        assert!(hint.playback_dirty);
2676    }
2677
2678    // ── Feature A: ToggleSlur ─────────────────────────────────────────────
2679
2680    #[test]
2681    fn toggle_slur_sets_start_and_end() {
2682        let mut score = default_engine_score();
2683        let cmd = Command::AddNote(AddNoteCmd {
2684            part_index: 0,
2685            staff_index: 0,
2686            measure_index: 0,
2687            voice: 0,
2688            position: 0,
2689            pitch: Some(Pitch::new(Step::C, 4)),
2690            duration: Duration::Quarter,
2691            dot_count: 0,
2692            is_rest: false,
2693            tuplet: None,
2694        });
2695        apply_command(&cmd, &mut score).unwrap();
2696        apply_command(
2697            &Command::AddNote(AddNoteCmd {
2698                part_index: 0,
2699                staff_index: 0,
2700                measure_index: 0,
2701                voice: 0,
2702                position: 1,
2703                pitch: Some(Pitch::new(Step::D, 4)),
2704                duration: Duration::Quarter,
2705                dot_count: 0,
2706                is_rest: false,
2707                tuplet: None,
2708            }),
2709            &mut score,
2710        )
2711        .unwrap();
2712        let start = NoteAddr {
2713            part: 0,
2714            staff: 0,
2715            measure: 0,
2716            voice: 0,
2717            note: 0,
2718        };
2719        let end = NoteAddr {
2720            part: 0,
2721            staff: 0,
2722            measure: 0,
2723            voice: 0,
2724            note: 1,
2725        };
2726        apply_command(
2727            &Command::ToggleSlur(ToggleSlurCmd {
2728                start: start.clone(),
2729                end: end.clone(),
2730            }),
2731            &mut score,
2732        )
2733        .unwrap();
2734        assert!(score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
2735        assert!(score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
2736        // toggle off
2737        apply_command(
2738            &Command::ToggleSlur(ToggleSlurCmd { start, end }),
2739            &mut score,
2740        )
2741        .unwrap();
2742        assert!(!score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
2743        assert!(!score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
2744    }
2745
2746    // ── Feature B: AddStaff / DeleteStaff ────────────────────────────────
2747
2748    #[test]
2749    fn add_staff_appends_staff_with_correct_measure_count() {
2750        let mut score = default_engine_score();
2751        let before = score.parts[0].staves.len();
2752        let measure_count = score.parts[0].staves[0].measures.len();
2753        apply_command(
2754            &Command::AddStaff(AddStaffCmd {
2755                part_index: 0,
2756                clef: Clef::Bass,
2757            }),
2758            &mut score,
2759        )
2760        .unwrap();
2761        assert_eq!(score.parts[0].staves.len(), before + 1);
2762        let new_staff = score.parts[0].staves.last().unwrap();
2763        assert_eq!(new_staff.measures.len(), measure_count);
2764    }
2765
2766    #[test]
2767    fn add_staff_out_of_range_returns_err() {
2768        let mut score = default_engine_score();
2769        let result = apply_command(
2770            &Command::AddStaff(AddStaffCmd {
2771                part_index: 99,
2772                clef: Clef::Treble,
2773            }),
2774            &mut score,
2775        );
2776        assert!(result.is_err());
2777    }
2778
2779    #[test]
2780    fn delete_staff_removes_extra_staff() {
2781        let mut score = default_engine_score();
2782        apply_command(
2783            &Command::AddStaff(AddStaffCmd {
2784                part_index: 0,
2785                clef: Clef::Bass,
2786            }),
2787            &mut score,
2788        )
2789        .unwrap();
2790        assert_eq!(score.parts[0].staves.len(), 2);
2791        apply_command(
2792            &Command::DeleteStaff(DeleteStaffCmd {
2793                part_index: 0,
2794                staff_index: 1,
2795            }),
2796            &mut score,
2797        )
2798        .unwrap();
2799        assert_eq!(score.parts[0].staves.len(), 1);
2800    }
2801
2802    #[test]
2803    fn delete_last_staff_returns_err() {
2804        let mut score = default_engine_score();
2805        assert_eq!(score.parts[0].staves.len(), 1);
2806        let result = apply_command(
2807            &Command::DeleteStaff(DeleteStaffCmd {
2808                part_index: 0,
2809                staff_index: 0,
2810            }),
2811            &mut score,
2812        );
2813        assert!(result.is_err());
2814    }
2815
2816    // ── Feature D: SetTuplet ──────────────────────────────────────────────
2817
2818    #[test]
2819    fn set_tuplet_assigns_and_clears() {
2820        use crate::model::notation::TupletInfo;
2821        let mut score = default_engine_score();
2822        apply_command(
2823            &Command::AddNote(AddNoteCmd {
2824                part_index: 0,
2825                staff_index: 0,
2826                measure_index: 0,
2827                voice: 0,
2828                position: 0,
2829                pitch: Some(Pitch::new(Step::C, 4)),
2830                duration: Duration::Quarter,
2831                dot_count: 0,
2832                is_rest: false,
2833                tuplet: None,
2834            }),
2835            &mut score,
2836        )
2837        .unwrap();
2838        let ti = TupletInfo {
2839            actual_notes: 3,
2840            normal_notes: 2,
2841        };
2842        apply_command(
2843            &Command::SetTuplet(SetTupletCmd {
2844                part_index: 0,
2845                staff_index: 0,
2846                measure_index: 0,
2847                voice_index: 0,
2848                note_index: 0,
2849                tuplet: Some(ti.clone()),
2850            }),
2851            &mut score,
2852        )
2853        .unwrap();
2854        assert_eq!(
2855            score.parts[0].staves[0].measures[0].voices[0][0].tuplet,
2856            Some(ti)
2857        );
2858        apply_command(
2859            &Command::SetTuplet(SetTupletCmd {
2860                part_index: 0,
2861                staff_index: 0,
2862                measure_index: 0,
2863                voice_index: 0,
2864                note_index: 0,
2865                tuplet: None,
2866            }),
2867            &mut score,
2868        )
2869        .unwrap();
2870        assert!(
2871            score.parts[0].staves[0].measures[0].voices[0][0]
2872                .tuplet
2873                .is_none()
2874        );
2875    }
2876
2877    // ── Feature E: RespellScore ───────────────────────────────────────────
2878
2879    #[test]
2880    fn respell_score_cmd_changes_all_pitches() {
2881        use crate::model::pitch::Step;
2882        let mut score = default_engine_score();
2883        apply_command(
2884            &Command::AddNote(AddNoteCmd {
2885                part_index: 0,
2886                staff_index: 0,
2887                measure_index: 0,
2888                voice: 0,
2889                position: 0,
2890                pitch: Some(Pitch::with_alter(Step::C, 4, 1)), // C#4
2891                duration: Duration::Quarter,
2892                dot_count: 0,
2893                is_rest: false,
2894                tuplet: None,
2895            }),
2896            &mut score,
2897        )
2898        .unwrap();
2899        apply_command(
2900            &Command::RespellScore(RespellScoreCmd { prefer_flat: true }),
2901            &mut score,
2902        )
2903        .unwrap();
2904        let pitch = &score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
2905        assert_eq!(pitch.step, Step::D);
2906        assert_eq!(pitch.alter, -1); // Db4
2907    }
2908}