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