Skip to main content

acorde_core/model/
commands.rs

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