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