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