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