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