Skip to main content

acorde_core/model/
commands.rs

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