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 note = if cmd.is_rest {
1880        let mut n = Note::rest(cmd.duration.clone());
1881        n.dot_count = cmd.dot_count;
1882        n.tuplet = cmd.tuplet.clone();
1883        n
1884    } else {
1885        let pitch = cmd
1886            .pitch
1887            .clone()
1888            .ok_or_else(|| Error::InvalidCommand("pitch required for non-rest note".into()))?;
1889        let mut n = Note::new(pitch, cmd.duration.clone());
1890        n.dot_count = cmd.dot_count;
1891        n.tuplet = cmd.tuplet.clone();
1892        n
1893    };
1894
1895    let pos = {
1896        let voice = score
1897            .parts
1898            .get_mut(cmd.part_index)
1899            .ok_or(Error::PartNotFound(cmd.part_index))?
1900            .staves
1901            .get_mut(cmd.staff_index)
1902            .ok_or(Error::StaffNotFound(cmd.staff_index))?
1903            .measures
1904            .get_mut(cmd.measure_index)
1905            .ok_or(Error::MeasureNotFound(cmd.measure_index))?
1906            .voices
1907            .get_mut(cmd.voice)
1908            .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
1909        let pos = cmd.position.min(voice.len());
1910        voice.insert(pos, note);
1911        trim_voice_to_measure(voice, ts_beats);
1912        pos
1913    };
1914    remap_spanners(score, |address| {
1915        if same_voice(
1916            address,
1917            cmd.part_index,
1918            cmd.staff_index,
1919            cmd.measure_index,
1920            cmd.voice,
1921        ) && address.note >= pos
1922        {
1923            let mut shifted = address.clone();
1924            shifted.note += 1;
1925            Some(shifted)
1926        } else {
1927            Some(address.clone())
1928        }
1929    });
1930    Ok(())
1931}
1932
1933fn apply_add_pitch(cmd: &AddPitchCmd, score: &mut Score) -> Result<(), Error> {
1934    let voice = score
1935        .parts
1936        .get_mut(cmd.part_index)
1937        .ok_or(Error::PartNotFound(cmd.part_index))?
1938        .staves
1939        .get_mut(cmd.staff_index)
1940        .ok_or(Error::StaffNotFound(cmd.staff_index))?
1941        .measures
1942        .get_mut(cmd.measure_index)
1943        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
1944        .voices
1945        .get_mut(cmd.voice)
1946        .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
1947    let note = voice
1948        .get_mut(cmd.note_index)
1949        .ok_or(Error::NoteNotFound(cmd.note_index))?;
1950    if note.is_rest {
1951        return Err(Error::InvalidCommand("cannot add pitch to a rest".into()));
1952    }
1953    if !note
1954        .pitches
1955        .iter()
1956        .any(|p| p.step == cmd.pitch.step && p.octave == cmd.pitch.octave)
1957    {
1958        note.pitches.push(cmd.pitch.clone());
1959    }
1960    Ok(())
1961}
1962
1963fn apply_set_duration(cmd: &SetDurationCmd, score: &mut Score) -> Result<(), Error> {
1964    let ts_beats = score.settings.time_signature.total_beats();
1965    {
1966        let voice = score
1967            .parts
1968            .get_mut(cmd.part_index)
1969            .ok_or(Error::PartNotFound(cmd.part_index))?
1970            .staves
1971            .get_mut(cmd.staff_index)
1972            .ok_or(Error::StaffNotFound(cmd.staff_index))?
1973            .measures
1974            .get_mut(cmd.measure_index)
1975            .ok_or(Error::MeasureNotFound(cmd.measure_index))?
1976            .voices
1977            .get_mut(cmd.voice)
1978            .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
1979        let note = voice
1980            .get_mut(cmd.note_index)
1981            .ok_or(Error::NoteNotFound(cmd.note_index))?;
1982        note.duration = cmd.duration.clone();
1983        note.dot_count = cmd.dot_count;
1984        trim_voice_to_measure(voice, ts_beats);
1985    }
1986    prune_orphaned_spanners(score);
1987    Ok(())
1988}
1989
1990fn apply_delete_note(cmd: &DeleteNoteCmd, score: &mut Score) -> Result<(), Error> {
1991    let ts_beats = score.settings.time_signature.total_beats();
1992    let deleted_position = {
1993        let voice = score
1994            .parts
1995            .get_mut(cmd.part_index)
1996            .ok_or(Error::PartNotFound(cmd.part_index))?
1997            .staves
1998            .get_mut(cmd.staff_index)
1999            .ok_or(Error::StaffNotFound(cmd.staff_index))?
2000            .measures
2001            .get_mut(cmd.measure_index)
2002            .ok_or(Error::MeasureNotFound(cmd.measure_index))?
2003            .voices
2004            .get_mut(cmd.voice)
2005            .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
2006        let position = voice.iter().position(|note| note.id == cmd.note_id);
2007        voice.retain(|note| note.id != cmd.note_id);
2008        pad_voice_to_measure(voice, ts_beats);
2009        position
2010    };
2011    if let Some(position) = deleted_position {
2012        remap_spanners(score, |address| {
2013            if !same_voice(
2014                address,
2015                cmd.part_index,
2016                cmd.staff_index,
2017                cmd.measure_index,
2018                cmd.voice,
2019            ) {
2020                return Some(address.clone());
2021            }
2022            if address.note == position {
2023                None
2024            } else if address.note > position {
2025                let mut shifted = address.clone();
2026                shifted.note -= 1;
2027                Some(shifted)
2028            } else {
2029                Some(address.clone())
2030            }
2031        });
2032    } else {
2033        prune_orphaned_spanners(score);
2034    }
2035    Ok(())
2036}
2037
2038fn apply_add_measure(cmd: &AddMeasureCmd, score: &mut Score) -> Result<(), Error> {
2039    let ts = score.settings.time_signature.clone();
2040    let insert_at = cmd.after_index.saturating_add(1);
2041    for part in &mut score.parts {
2042        for staff in &mut part.staves {
2043            let insert_at = insert_at.min(staff.measures.len());
2044            let mut m = Measure::empty(ts.numerator, ts.denominator);
2045            m.number = insert_at as u32 + 1;
2046            staff.measures.insert(insert_at, m);
2047            for (i, measure) in staff.measures.iter_mut().enumerate() {
2048                measure.number = i as u32 + 1;
2049            }
2050        }
2051    }
2052    remap_spanners(score, |address| {
2053        if address.measure >= insert_at {
2054            let mut shifted = address.clone();
2055            shifted.measure += 1;
2056            Some(shifted)
2057        } else {
2058            Some(address.clone())
2059        }
2060    });
2061    Ok(())
2062}
2063
2064fn apply_delete_measure(cmd: &DeleteMeasureCmd, score: &mut Score) -> Result<(), Error> {
2065    for part in &mut score.parts {
2066        for staff in &mut part.staves {
2067            if cmd.measure_index < staff.measures.len() {
2068                staff.measures.remove(cmd.measure_index);
2069                for (i, m) in staff.measures.iter_mut().enumerate() {
2070                    m.number = i as u32 + 1;
2071                }
2072            }
2073        }
2074    }
2075    remap_spanners(score, |address| {
2076        if address.measure == cmd.measure_index {
2077            None
2078        } else if address.measure > cmd.measure_index {
2079            let mut shifted = address.clone();
2080            shifted.measure -= 1;
2081            Some(shifted)
2082        } else {
2083            Some(address.clone())
2084        }
2085    });
2086    Ok(())
2087}
2088
2089fn apply_add_hairpin(cmd: &AddHairpinCmd, score: &mut Score) -> Result<(), Error> {
2090    let voice = score
2091        .parts
2092        .get_mut(cmd.part_index)
2093        .ok_or(Error::PartNotFound(cmd.part_index))?
2094        .staves
2095        .get_mut(cmd.staff_index)
2096        .ok_or(Error::StaffNotFound(cmd.staff_index))?
2097        .measures
2098        .get_mut(cmd.measure_index)
2099        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
2100        .voices
2101        .get_mut(cmd.voice)
2102        .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
2103    if cmd.start_note_idx >= voice.len() {
2104        return Err(Error::NoteNotFound(cmd.start_note_idx));
2105    }
2106    if cmd.end_note_idx >= voice.len() {
2107        return Err(Error::NoteNotFound(cmd.end_note_idx));
2108    }
2109    if cmd.start_note_idx >= cmd.end_note_idx {
2110        return Err(Error::InvalidCommand(
2111            "start_note_idx must be less than end_note_idx".into(),
2112        ));
2113    }
2114    for note in voice
2115        .iter_mut()
2116        .take(cmd.end_note_idx + 1)
2117        .skip(cmd.start_note_idx)
2118    {
2119        note.hairpin_start = None;
2120        note.hairpin_end = false;
2121    }
2122    voice[cmd.start_note_idx].hairpin_start = Some(cmd.kind);
2123    voice[cmd.end_note_idx].hairpin_end = true;
2124    Ok(())
2125}
2126
2127fn apply_add_pedal(cmd: &AddPedalCmd, score: &mut Score) -> Result<(), Error> {
2128    let voice = score
2129        .parts
2130        .get_mut(cmd.part_index)
2131        .ok_or(Error::PartNotFound(cmd.part_index))?
2132        .staves
2133        .get_mut(cmd.staff_index)
2134        .ok_or(Error::StaffNotFound(cmd.staff_index))?
2135        .measures
2136        .get_mut(cmd.measure_index)
2137        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
2138        .voices
2139        .get_mut(cmd.voice)
2140        .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
2141    if cmd.start_note_idx >= voice.len() {
2142        return Err(Error::NoteNotFound(cmd.start_note_idx));
2143    }
2144    if cmd.end_note_idx >= voice.len() {
2145        return Err(Error::NoteNotFound(cmd.end_note_idx));
2146    }
2147    if cmd.start_note_idx >= cmd.end_note_idx {
2148        return Err(Error::InvalidCommand(
2149            "start_note_idx must be less than end_note_idx".into(),
2150        ));
2151    }
2152    for note in voice
2153        .iter_mut()
2154        .take(cmd.end_note_idx + 1)
2155        .skip(cmd.start_note_idx)
2156    {
2157        note.pedal_start = false;
2158        note.pedal_end = false;
2159    }
2160    voice[cmd.start_note_idx].pedal_start = true;
2161    voice[cmd.end_note_idx].pedal_end = true;
2162    Ok(())
2163}
2164
2165fn apply_toggle_tie(cmd: &ToggleTieCmd, score: &mut Score) -> Result<(), Error> {
2166    let current_tie_start = {
2167        let v = score
2168            .parts
2169            .get(cmd.part_index)
2170            .ok_or(Error::PartNotFound(cmd.part_index))?
2171            .staves
2172            .get(cmd.staff_index)
2173            .ok_or(Error::StaffNotFound(cmd.staff_index))?
2174            .measures
2175            .get(cmd.measure_index)
2176            .ok_or(Error::MeasureNotFound(cmd.measure_index))?
2177            .voices
2178            .get(cmd.voice)
2179            .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
2180        v.get(cmd.note_index)
2181            .ok_or(Error::NoteNotFound(cmd.note_index))?
2182            .tie_start
2183    };
2184    let voice_len = score.parts[cmd.part_index].staves[cmd.staff_index].measures[cmd.measure_index]
2185        .voices[cmd.voice]
2186        .len();
2187    let total_measures = score.parts[cmd.part_index].staves[cmd.staff_index]
2188        .measures
2189        .len();
2190
2191    let new_tie = !current_tie_start;
2192    score.parts[cmd.part_index].staves[cmd.staff_index].measures[cmd.measure_index].voices
2193        [cmd.voice][cmd.note_index]
2194        .tie_start = new_tie;
2195
2196    if cmd.note_index + 1 < voice_len {
2197        score.parts[cmd.part_index].staves[cmd.staff_index].measures[cmd.measure_index].voices
2198            [cmd.voice][cmd.note_index + 1]
2199            .tie_end = new_tie;
2200    } else {
2201        let next_mi = cmd.measure_index + 1;
2202        if next_mi < total_measures {
2203            let next_voice = &mut score.parts[cmd.part_index].staves[cmd.staff_index].measures
2204                [next_mi]
2205                .voices[cmd.voice];
2206            if let Some(n) = next_voice.get_mut(0) {
2207                n.tie_end = new_tie;
2208            }
2209        }
2210    }
2211    Ok(())
2212}
2213
2214fn apply_set_dynamic(cmd: &SetDynamicCmd, score: &mut Score) -> Result<(), Error> {
2215    get_note_mut(
2216        score,
2217        cmd.part_index,
2218        cmd.staff_index,
2219        cmd.measure_index,
2220        cmd.voice,
2221        cmd.note_index,
2222    )?
2223    .dynamic = cmd.dynamic.clone();
2224    Ok(())
2225}
2226
2227fn apply_toggle_articulation(cmd: &ToggleArticulationCmd, score: &mut Score) -> Result<(), Error> {
2228    let note = get_note_mut(
2229        score,
2230        cmd.part_index,
2231        cmd.staff_index,
2232        cmd.measure_index,
2233        cmd.voice,
2234        cmd.note_index,
2235    )?;
2236    if let Some(pos) = note
2237        .articulations
2238        .iter()
2239        .position(|a| a == &cmd.articulation)
2240    {
2241        note.articulations.remove(pos);
2242    } else {
2243        note.articulations.push(cmd.articulation.clone());
2244    }
2245    Ok(())
2246}
2247
2248fn apply_set_time_signature(cmd: &SetTimeSignatureCmd, score: &mut Score) -> Result<(), Error> {
2249    if cmd.numerator == 0 || cmd.denominator == 0 {
2250        return Err(Error::InvalidCommand(
2251            "time signature numerator and denominator must be > 0".into(),
2252        ));
2253    }
2254    if ![1u8, 2, 4, 8, 16, 32].contains(&cmd.denominator) {
2255        return Err(Error::InvalidCommand(format!(
2256            "invalid time signature denominator: {}",
2257            cmd.denominator
2258        )));
2259    }
2260    score.settings.time_signature = TimeSignature {
2261        numerator: cmd.numerator,
2262        denominator: cmd.denominator,
2263    };
2264    let max_beats = score.settings.time_signature.total_beats();
2265    for part in &mut score.parts {
2266        for staff in &mut part.staves {
2267            for measure in &mut staff.measures {
2268                for voice in &mut measure.voices {
2269                    trim_voice_to_measure(voice, max_beats);
2270                    pad_voice_to_measure(voice, max_beats);
2271                }
2272            }
2273        }
2274    }
2275    Ok(())
2276}
2277
2278fn apply_set_barline(cmd: &SetBarlineCmd, score: &mut Score) -> Result<(), Error> {
2279    for part in &mut score.parts {
2280        for staff in &mut part.staves {
2281            let measure = staff
2282                .measures
2283                .get_mut(cmd.measure_index)
2284                .ok_or(Error::MeasureNotFound(cmd.measure_index))?;
2285            match cmd.side.as_str() {
2286                "left" => measure.barline_left = cmd.barline.clone(),
2287                "right" => measure.barline_right = cmd.barline.clone(),
2288                _ => {
2289                    return Err(Error::InvalidCommand(format!(
2290                        "invalid barline side: '{}'",
2291                        cmd.side
2292                    )));
2293                }
2294            }
2295        }
2296    }
2297    Ok(())
2298}
2299
2300fn apply_add_part(cmd: &AddPartCmd, score: &mut Score) -> Result<(), Error> {
2301    if cmd.clefs.is_empty() {
2302        return Err(Error::InvalidCommand(
2303            "AddPart requires at least one clef".into(),
2304        ));
2305    }
2306    let measure_count = score.measure_count();
2307    let ts = score.settings.time_signature.clone();
2308    let mut part = Part::new(&cmd.name, &cmd.short_name);
2309    part.midi_channel = cmd.midi_channel.min(15);
2310    part.midi_program = cmd.midi_program;
2311    for clef_str in &cmd.clefs {
2312        let clef = match clef_str.as_str() {
2313            "Bass" => Clef::Bass,
2314            "Alto" => Clef::Alto,
2315            "Tenor" => Clef::Tenor,
2316            "Percussion" => Clef::Percussion,
2317            _ => Clef::Treble,
2318        };
2319        let mut staff = Staff::new(clef);
2320        for i in 0..measure_count {
2321            let mut m = Measure::empty(ts.numerator, ts.denominator);
2322            m.number = i as u32 + 1;
2323            staff.measures.push(m);
2324        }
2325        part.staves.push(staff);
2326    }
2327    score.parts.push(part);
2328    Ok(())
2329}
2330
2331fn apply_set_clef(cmd: &SetClefCmd, score: &mut Score) -> Result<(), Error> {
2332    score
2333        .parts
2334        .get_mut(cmd.part_index)
2335        .ok_or(Error::PartNotFound(cmd.part_index))?
2336        .staves
2337        .get_mut(cmd.staff_index)
2338        .ok_or(Error::StaffNotFound(cmd.staff_index))?
2339        .clef = cmd.clef.clone();
2340    Ok(())
2341}
2342
2343fn apply_set_part_name(cmd: &SetPartNameCmd, score: &mut Score) -> Result<(), Error> {
2344    let part = score
2345        .parts
2346        .get_mut(cmd.part_index)
2347        .ok_or(Error::PartNotFound(cmd.part_index))?;
2348    part.name = cmd.name.clone();
2349    part.short_name = cmd.short_name.clone();
2350    Ok(())
2351}
2352
2353fn apply_delete_part(cmd: &DeletePartCmd, score: &mut Score) -> Result<(), Error> {
2354    if cmd.part_index >= score.parts.len() {
2355        return Err(Error::PartNotFound(cmd.part_index));
2356    }
2357    score.parts.remove(cmd.part_index);
2358    remap_spanners(score, |address| {
2359        if address.part == cmd.part_index {
2360            None
2361        } else if address.part > cmd.part_index {
2362            let mut shifted = address.clone();
2363            shifted.part -= 1;
2364            Some(shifted)
2365        } else {
2366            Some(address.clone())
2367        }
2368    });
2369    Ok(())
2370}
2371
2372fn apply_set_metadata(cmd: &SetMetadataCmd, score: &mut Score) -> Result<(), Error> {
2373    if let Some(v) = &cmd.title {
2374        score.metadata.title = v.clone();
2375    }
2376    if let Some(v) = &cmd.composer {
2377        score.metadata.composer = v.clone();
2378    }
2379    if let Some(v) = &cmd.lyricist {
2380        score.metadata.lyricist = v.clone();
2381    }
2382    if let Some(v) = &cmd.copyright {
2383        score.metadata.copyright = v.clone();
2384    }
2385    if let Some(v) = &cmd.work_number {
2386        score.metadata.work_number = v.clone();
2387    }
2388    if let Some(v) = &cmd.movement_title {
2389        score.metadata.movement_title = v.clone();
2390    }
2391    Ok(())
2392}
2393
2394fn apply_set_midi_instrument(cmd: &SetMidiInstrumentCmd, score: &mut Score) -> Result<(), Error> {
2395    let part = score
2396        .parts
2397        .get_mut(cmd.part_index)
2398        .ok_or(Error::PartNotFound(cmd.part_index))?;
2399    part.midi_channel = cmd.midi_channel.min(15);
2400    part.midi_program = cmd.midi_program;
2401    Ok(())
2402}
2403
2404fn apply_set_transpose(cmd: &SetTransposeCmd, score: &mut Score) -> Result<(), Error> {
2405    score
2406        .parts
2407        .get_mut(cmd.part_index)
2408        .ok_or(Error::PartNotFound(cmd.part_index))?
2409        .staves
2410        .get_mut(cmd.staff_index)
2411        .ok_or(Error::StaffNotFound(cmd.staff_index))?
2412        .transpose_semitones = cmd.semitones;
2413    Ok(())
2414}
2415
2416fn apply_paste_voice(cmd: &PasteVoiceCmd, score: &mut Score) -> Result<(), Error> {
2417    let endpoint_ids = capture_replaced_endpoint_ids(score, |address| {
2418        same_voice(
2419            address,
2420            cmd.part_index,
2421            cmd.staff_index,
2422            cmd.measure_index,
2423            cmd.voice_index,
2424        )
2425    });
2426    {
2427        let voice = score
2428            .parts
2429            .get_mut(cmd.part_index)
2430            .ok_or(Error::PartNotFound(cmd.part_index))?
2431            .staves
2432            .get_mut(cmd.staff_index)
2433            .ok_or(Error::StaffNotFound(cmd.staff_index))?
2434            .measures
2435            .get_mut(cmd.measure_index)
2436            .ok_or(Error::MeasureNotFound(cmd.measure_index))?
2437            .voices
2438            .get_mut(cmd.voice_index)
2439            .ok_or(Error::VoiceOutOfRange(cmd.voice_index))?;
2440        *voice = cmd.notes.clone();
2441    }
2442    remap_replaced_spanner_endpoints(score, &endpoint_ids);
2443    Ok(())
2444}
2445
2446fn apply_paste_range(cmd: &PasteRangeCmd, score: &mut Score) -> Result<(), Error> {
2447    if cmd.voice_index >= 4 {
2448        return Err(Error::VoiceOutOfRange(cmd.voice_index));
2449    }
2450    let endpoint_ids = capture_replaced_endpoint_ids(score, |address| {
2451        address.part == cmd.part_index
2452            && address.staff == cmd.staff_index
2453            && address.voice == cmd.voice_index
2454            && address.measure >= cmd.target_measure
2455            && address.measure < cmd.target_measure.saturating_add(cmd.measures.len())
2456    });
2457    let part = score
2458        .parts
2459        .get_mut(cmd.part_index)
2460        .ok_or(Error::PartNotFound(cmd.part_index))?;
2461    let staff = part
2462        .staves
2463        .get_mut(cmd.staff_index)
2464        .ok_or(Error::StaffNotFound(cmd.staff_index))?;
2465    for (offset, notes) in cmd.measures.iter().enumerate() {
2466        let mi = cmd.target_measure + offset;
2467        let measure = staff
2468            .measures
2469            .get_mut(mi)
2470            .ok_or(Error::MeasureNotFound(mi))?;
2471        measure.voices[cmd.voice_index] = notes.clone();
2472    }
2473    remap_replaced_spanner_endpoints(score, &endpoint_ids);
2474    Ok(())
2475}
2476
2477fn apply_toggle_slur(cmd: &ToggleSlurCmd, score: &mut Score) -> Result<(), Error> {
2478    let new_start = !{
2479        score
2480            .parts
2481            .get(cmd.start.part)
2482            .ok_or(Error::PartNotFound(cmd.start.part))?
2483            .staves
2484            .get(cmd.start.staff)
2485            .ok_or(Error::StaffNotFound(cmd.start.staff))?
2486            .measures
2487            .get(cmd.start.measure)
2488            .ok_or(Error::MeasureNotFound(cmd.start.measure))?
2489            .voices
2490            .get(cmd.start.voice)
2491            .ok_or(Error::VoiceOutOfRange(cmd.start.voice))?
2492            .get(cmd.start.note)
2493            .ok_or(Error::NoteNotFound(cmd.start.note))?
2494            .slur_start
2495    };
2496    let new_end = !{
2497        score
2498            .parts
2499            .get(cmd.end.part)
2500            .ok_or(Error::PartNotFound(cmd.end.part))?
2501            .staves
2502            .get(cmd.end.staff)
2503            .ok_or(Error::StaffNotFound(cmd.end.staff))?
2504            .measures
2505            .get(cmd.end.measure)
2506            .ok_or(Error::MeasureNotFound(cmd.end.measure))?
2507            .voices
2508            .get(cmd.end.voice)
2509            .ok_or(Error::VoiceOutOfRange(cmd.end.voice))?
2510            .get(cmd.end.note)
2511            .ok_or(Error::NoteNotFound(cmd.end.note))?
2512            .slur_end
2513    };
2514    score.parts[cmd.start.part].staves[cmd.start.staff].measures[cmd.start.measure].voices
2515        [cmd.start.voice][cmd.start.note]
2516        .slur_start = new_start;
2517    score.parts[cmd.end.part].staves[cmd.end.staff].measures[cmd.end.measure].voices
2518        [cmd.end.voice][cmd.end.note]
2519        .slur_end = new_end;
2520    Ok(())
2521}
2522
2523fn apply_toggle_trill_line(cmd: &ToggleTrillLineCmd, score: &mut Score) -> Result<(), Error> {
2524    let new_start = !{
2525        score
2526            .parts
2527            .get(cmd.start.part)
2528            .ok_or(Error::PartNotFound(cmd.start.part))?
2529            .staves
2530            .get(cmd.start.staff)
2531            .ok_or(Error::StaffNotFound(cmd.start.staff))?
2532            .measures
2533            .get(cmd.start.measure)
2534            .ok_or(Error::MeasureNotFound(cmd.start.measure))?
2535            .voices
2536            .get(cmd.start.voice)
2537            .ok_or(Error::VoiceOutOfRange(cmd.start.voice))?
2538            .get(cmd.start.note)
2539            .ok_or(Error::NoteNotFound(cmd.start.note))?
2540            .trill_line_start
2541    };
2542    let new_end = !{
2543        score
2544            .parts
2545            .get(cmd.end.part)
2546            .ok_or(Error::PartNotFound(cmd.end.part))?
2547            .staves
2548            .get(cmd.end.staff)
2549            .ok_or(Error::StaffNotFound(cmd.end.staff))?
2550            .measures
2551            .get(cmd.end.measure)
2552            .ok_or(Error::MeasureNotFound(cmd.end.measure))?
2553            .voices
2554            .get(cmd.end.voice)
2555            .ok_or(Error::VoiceOutOfRange(cmd.end.voice))?
2556            .get(cmd.end.note)
2557            .ok_or(Error::NoteNotFound(cmd.end.note))?
2558            .trill_line_end
2559    };
2560    score.parts[cmd.start.part].staves[cmd.start.staff].measures[cmd.start.measure].voices
2561        [cmd.start.voice][cmd.start.note]
2562        .trill_line_start = new_start;
2563    score.parts[cmd.end.part].staves[cmd.end.staff].measures[cmd.end.measure].voices
2564        [cmd.end.voice][cmd.end.note]
2565        .trill_line_end = new_end;
2566    Ok(())
2567}
2568
2569fn apply_add_staff(cmd: &AddStaffCmd, score: &mut Score) -> Result<(), Error> {
2570    let ts = score.settings.time_signature.clone();
2571    let measure_count = score
2572        .parts
2573        .get(cmd.part_index)
2574        .ok_or(Error::PartNotFound(cmd.part_index))?
2575        .staves
2576        .first()
2577        .map_or(0, |s| s.measures.len());
2578    let mut staff = Staff::new(cmd.clef.clone());
2579    for i in 0..measure_count {
2580        let mut m = Measure::empty(ts.numerator, ts.denominator);
2581        m.number = i as u32 + 1;
2582        staff.measures.push(m);
2583    }
2584    score.parts[cmd.part_index].staves.push(staff);
2585    Ok(())
2586}
2587
2588fn apply_delete_staff(cmd: &DeleteStaffCmd, score: &mut Score) -> Result<(), Error> {
2589    let part = score
2590        .parts
2591        .get_mut(cmd.part_index)
2592        .ok_or(Error::PartNotFound(cmd.part_index))?;
2593    if part.staves.len() <= 1 {
2594        return Err(Error::CannotDeleteLastStaff);
2595    }
2596    if cmd.staff_index >= part.staves.len() {
2597        return Err(Error::StaffNotFound(cmd.staff_index));
2598    }
2599    part.staves.remove(cmd.staff_index);
2600    remap_spanners(score, |address| {
2601        if address.part != cmd.part_index {
2602            Some(address.clone())
2603        } else if address.staff == cmd.staff_index {
2604            None
2605        } else if address.staff > cmd.staff_index {
2606            let mut shifted = address.clone();
2607            shifted.staff -= 1;
2608            Some(shifted)
2609        } else {
2610            Some(address.clone())
2611        }
2612    });
2613    Ok(())
2614}
2615
2616/// Returns true when an address refers to the given canonical voice.
2617fn same_voice(address: &NoteAddr, part: usize, staff: usize, measure: usize, voice: usize) -> bool {
2618    address.part == part
2619        && address.staff == staff
2620        && address.measure == measure
2621        && address.voice == voice
2622}
2623
2624fn note_at<'a>(score: &'a Score, address: &NoteAddr) -> Option<&'a Note> {
2625    score
2626        .parts
2627        .get(address.part)
2628        .and_then(|part| part.staves.get(address.staff))
2629        .and_then(|staff| staff.measures.get(address.measure))
2630        .and_then(|measure| measure.voices.get(address.voice))
2631        .and_then(|voice| voice.get(address.note))
2632}
2633
2634/// Remap typed notation endpoints after a structural edit.
2635///
2636/// An endpoint that cannot be mapped, or no longer names an existing note, removes its complete
2637/// span. This deliberately favors an observable removed span over silently retargeting it.
2638fn remap_spanners(score: &mut Score, mut remap: impl FnMut(&NoteAddr) -> Option<NoteAddr>) {
2639    let spanners = std::mem::take(&mut score.spanners);
2640    score.spanners = spanners
2641        .into_iter()
2642        .filter_map(|mut spanner| {
2643            spanner.start = remap(&spanner.start)?;
2644            spanner.end = remap(&spanner.end)?;
2645            (note_at(score, &spanner.start).is_some() && note_at(score, &spanner.end).is_some())
2646                .then_some(spanner)
2647        })
2648        .collect();
2649}
2650
2651fn prune_orphaned_spanners(score: &mut Score) {
2652    remap_spanners(score, |address| Some(address.clone()));
2653}
2654
2655/// Capture the note identities behind endpoints that will be replaced wholesale.
2656fn capture_replaced_endpoint_ids(
2657    score: &Score,
2658    mut replaced: impl FnMut(&NoteAddr) -> bool,
2659) -> Vec<(NoteAddr, String)> {
2660    score
2661        .spanners
2662        .iter()
2663        .flat_map(|spanner| [&spanner.start, &spanner.end])
2664        .filter(|address| replaced(address))
2665        .filter_map(|address| {
2666            note_at(score, address).map(|note| (address.clone(), note.id.clone()))
2667        })
2668        .collect()
2669}
2670
2671/// Re-resolve endpoints in a replaced voice/range by their original note IDs.
2672/// Duplicate IDs are treated as ambiguous and remove the affected span instead of guessing.
2673fn remap_replaced_spanner_endpoints(score: &mut Score, endpoint_ids: &[(NoteAddr, String)]) {
2674    let mapped: Vec<(NoteAddr, Option<NoteAddr>)> = endpoint_ids
2675        .iter()
2676        .map(|(address, note_id)| {
2677            let matches: Vec<usize> = score
2678                .parts
2679                .get(address.part)
2680                .and_then(|part| part.staves.get(address.staff))
2681                .and_then(|staff| staff.measures.get(address.measure))
2682                .and_then(|measure| measure.voices.get(address.voice))
2683                .map(|voice| {
2684                    voice
2685                        .iter()
2686                        .enumerate()
2687                        .filter_map(|(index, note)| (note.id == *note_id).then_some(index))
2688                        .collect()
2689                })
2690                .unwrap_or_default();
2691            let replacement = (matches.len() == 1).then(|| NoteAddr {
2692                note: matches[0],
2693                ..address.clone()
2694            });
2695            (address.clone(), replacement)
2696        })
2697        .collect();
2698    remap_spanners(score, |address| {
2699        mapped
2700            .iter()
2701            .find(|(old, _)| old == address)
2702            .map(|(_, replacement)| replacement.clone())
2703            .unwrap_or_else(|| Some(address.clone()))
2704    });
2705}
2706
2707fn trim_voice_to_measure(voice: &mut Vec<Note>, max_beats: f64) {
2708    let mut total = 0.0f64;
2709    let mut cutoff = voice.len();
2710    for (i, n) in voice.iter().enumerate() {
2711        total += n.beats();
2712        if total > max_beats + 1e-9 {
2713            cutoff = i;
2714            break;
2715        }
2716    }
2717    voice.truncate(cutoff);
2718    pad_voice_to_measure(voice, max_beats);
2719}
2720
2721fn pad_voice_to_measure(voice: &mut Vec<Note>, max_beats: f64) {
2722    let mut used: f64 = voice.iter().map(|n| n.beats()).sum();
2723    while max_beats - used > 1e-9 {
2724        let remaining = max_beats - used;
2725        let rest = Note::rest(Duration::whole_filling_beats(remaining));
2726        used += rest.beats();
2727        voice.push(rest);
2728    }
2729}
2730
2731#[cfg(test)]
2732mod tests {
2733    use super::*;
2734    use crate::ScoreEngine;
2735    use crate::model::pitch::Step;
2736
2737    fn default_engine_score() -> Score {
2738        let mut s = Score::default();
2739        for part in &mut s.parts {
2740            for staff in &mut part.staves {
2741                for (i, m) in staff.measures.iter_mut().enumerate() {
2742                    m.number = i as u32 + 1;
2743                }
2744            }
2745        }
2746        s
2747    }
2748
2749    fn score_with_typed_spanner() -> Score {
2750        use crate::model::score::{NotationSpanner, NotationSpannerKind};
2751
2752        let mut score = Score::new("Spanner edits", 120, 4, 4, 0, 1);
2753        score.parts[0].staves[0].measures[0].voices[0] = (0..4)
2754            .map(|offset| Note::new(Pitch::new(Step::C, 4 + offset), Duration::Quarter))
2755            .collect();
2756        score.spanners.push(NotationSpanner {
2757            id: "typed-glissando".to_string(),
2758            kind: NotationSpannerKind::Glissando,
2759            start: NoteAddr {
2760                part: 0,
2761                staff: 0,
2762                measure: 0,
2763                voice: 0,
2764                note: 1,
2765            },
2766            end: NoteAddr {
2767                part: 0,
2768                staff: 0,
2769                measure: 0,
2770                voice: 0,
2771                note: 2,
2772            },
2773            number: Some(1),
2774            line_type: None,
2775            text: None,
2776            placement: None,
2777            ottava_size: None,
2778            ottava_type: None,
2779        });
2780        score
2781    }
2782
2783    fn insert_note_at(position: usize) -> Command {
2784        Command::AddNote(AddNoteCmd {
2785            part_index: 0,
2786            staff_index: 0,
2787            measure_index: 0,
2788            voice: 0,
2789            position,
2790            pitch: Some(Pitch::new(Step::D, 5)),
2791            duration: Duration::Quarter,
2792            dot_count: 0,
2793            is_rest: false,
2794            tuplet: None,
2795        })
2796    }
2797
2798    #[test]
2799    fn add_note_inserts_into_voice() {
2800        let mut score = default_engine_score();
2801        let cmd = Command::AddNote(AddNoteCmd {
2802            part_index: 0,
2803            staff_index: 0,
2804            measure_index: 0,
2805            voice: 0,
2806            position: 0,
2807            pitch: Some(Pitch::new(Step::C, 4)),
2808            duration: Duration::Quarter,
2809            dot_count: 0,
2810            is_rest: false,
2811            tuplet: None,
2812        });
2813        apply_command(&cmd, &mut score).unwrap();
2814        let first = &score.parts[0].staves[0].measures[0].voices[0][0];
2815        assert!(!first.is_rest);
2816        assert_eq!(first.pitches[0].step, Step::C);
2817    }
2818
2819    #[test]
2820    fn typed_spanners_follow_note_insertions_at_every_relative_position() {
2821        for (position, expected_start, expected_end) in [
2822            (0, 2, 3), // before the span
2823            (1, 2, 3), // at its start endpoint
2824            (2, 1, 3), // inside the span
2825            (4, 1, 2), // at the end of the voice
2826            (3, 1, 2), // after the span
2827        ] {
2828            let mut score = score_with_typed_spanner();
2829            apply_command(&insert_note_at(position), &mut score).unwrap();
2830            let span = score.spanners.first().expect("span remains attached");
2831            assert_eq!(span.start.note, expected_start, "position {position}");
2832            assert_eq!(span.end.note, expected_end, "position {position}");
2833        }
2834    }
2835
2836    #[test]
2837    fn deleting_a_typed_spanner_endpoint_removes_the_whole_span_and_undo_redo_is_atomic() {
2838        let mut score = score_with_typed_spanner();
2839        let endpoint_id = score.parts[0].staves[0].measures[0].voices[0][1].id.clone();
2840        let mut stack = CommandStack::new(8);
2841
2842        stack
2843            .execute(
2844                Command::DeleteNote(DeleteNoteCmd {
2845                    note_id: endpoint_id,
2846                    part_index: 0,
2847                    staff_index: 0,
2848                    measure_index: 0,
2849                    voice: 0,
2850                }),
2851                &mut score,
2852            )
2853            .unwrap();
2854        assert!(score.spanners.is_empty());
2855
2856        stack.undo(&mut score).unwrap();
2857        assert_eq!(score.spanners[0].start.note, 1);
2858        assert_eq!(score.spanners[0].end.note, 2);
2859
2860        stack.redo(&mut score).unwrap();
2861        assert!(score.spanners.is_empty());
2862    }
2863
2864    #[test]
2865    fn batched_structural_edits_remap_typed_spanners_and_restore_them_on_undo() {
2866        let mut score = score_with_typed_spanner();
2867        let mut stack = CommandStack::new(8);
2868        stack
2869            .batch_execute(
2870                vec![
2871                    insert_note_at(0),
2872                    Command::SetTempo(SetTempoCmd { bpm: 144 }),
2873                ],
2874                &mut score,
2875            )
2876            .unwrap();
2877        assert_eq!(score.spanners[0].start.note, 2);
2878        assert_eq!(score.spanners[0].end.note, 3);
2879
2880        stack.undo(&mut score).unwrap();
2881        assert_eq!(score.spanners[0].start.note, 1);
2882        assert_eq!(score.spanners[0].end.note, 2);
2883
2884        stack.redo(&mut score).unwrap();
2885        assert_eq!(score.spanners[0].start.note, 2);
2886        assert_eq!(score.spanners[0].end.note, 3);
2887    }
2888
2889    #[test]
2890    fn typed_spanners_remap_only_the_edited_endpoint_across_staff_and_voice() {
2891        use crate::model::score::{NotationSpanner, NotationSpannerKind, ScoreTemplate};
2892
2893        let mut score = Score::template(ScoreTemplate::Piano);
2894        for staff in &mut score.parts[0].staves {
2895            let notes: Vec<Note> = (0..4)
2896                .map(|offset| Note::new(Pitch::new(Step::C, 4 + offset), Duration::Quarter))
2897                .collect();
2898            staff.measures[0].voices[0] = notes.clone();
2899            staff.measures[0].voices[1] = notes;
2900        }
2901        score.spanners.push(NotationSpanner {
2902            id: "cross-staff".to_string(),
2903            kind: NotationSpannerKind::Slur,
2904            start: NoteAddr {
2905                part: 0,
2906                staff: 0,
2907                measure: 0,
2908                voice: 0,
2909                note: 1,
2910            },
2911            end: NoteAddr {
2912                part: 0,
2913                staff: 1,
2914                measure: 0,
2915                voice: 1,
2916                note: 2,
2917            },
2918            number: Some(2),
2919            line_type: None,
2920            text: None,
2921            placement: None,
2922            ottava_size: None,
2923            ottava_type: None,
2924        });
2925
2926        let mut stack = CommandStack::new(8);
2927        stack.execute(insert_note_at(0), &mut score).unwrap();
2928        let span = &score.spanners[0];
2929        assert_eq!(span.start.note, 2);
2930        assert_eq!(span.end.staff, 1);
2931        assert_eq!(span.end.voice, 1);
2932        assert_eq!(span.end.note, 2);
2933    }
2934
2935    #[test]
2936    fn set_fingerings_keeps_first_legacy_value_in_sync() {
2937        let mut score = default_engine_score();
2938        apply_command(
2939            &Command::AddNote(AddNoteCmd {
2940                part_index: 0,
2941                staff_index: 0,
2942                measure_index: 0,
2943                voice: 0,
2944                position: 0,
2945                pitch: Some(Pitch::new(Step::C, 4)),
2946                duration: Duration::Quarter,
2947                dot_count: 0,
2948                is_rest: false,
2949                tuplet: None,
2950            }),
2951            &mut score,
2952        )
2953        .unwrap();
2954        apply_command(
2955            &Command::SetFingerings(SetFingeringsCmd {
2956                part_index: 0,
2957                staff_index: 0,
2958                measure_index: 0,
2959                voice: 0,
2960                note_index: 0,
2961                fingerings: vec![1, 3, 4],
2962            }),
2963            &mut score,
2964        )
2965        .unwrap();
2966        let note = &score.parts[0].staves[0].measures[0].voices[0][0];
2967        assert_eq!(note.fingerings, vec![1, 3, 4]);
2968        assert_eq!(note.fingering, Some(1));
2969    }
2970
2971    #[test]
2972    fn set_figured_bass_replaces_measure_figures() {
2973        let mut score = default_engine_score();
2974        let figures = vec![FiguredBassFigure {
2975            number: "6".to_string(),
2976            alter: Some("-1".to_string()),
2977            prefix: Some("+".to_string()),
2978            suffix: None,
2979            extender: false,
2980        }];
2981        apply_command(
2982            &Command::SetFiguredBass(SetFiguredBassCmd {
2983                measure_index: 0,
2984                figures: figures.clone(),
2985            }),
2986            &mut score,
2987        )
2988        .unwrap();
2989        assert_eq!(score.parts[0].staves[0].measures[0].figured_bass, figures);
2990    }
2991
2992    #[test]
2993    fn set_measure_text_supports_append_replace_remove_and_undo_redo() {
2994        let mut engine = crate::ScoreEngine::new();
2995        let address = SetMeasureTextCmd {
2996            part_index: 0,
2997            staff_index: 0,
2998            measure_index: 0,
2999            text_index: 0,
3000            text: Some(StyledText {
3001                style: crate::TextStyle::Technique,
3002                text: "dolce".to_string(),
3003                placement: None,
3004                offset_x: None,
3005                offset_y: None,
3006                relative_x: None,
3007                relative_y: None,
3008            }),
3009        };
3010        engine.apply(Command::SetMeasureText(address)).unwrap();
3011        assert_eq!(
3012            engine.score.parts[0].staves[0].measures[0].texts[0],
3013            StyledText {
3014                style: crate::TextStyle::Technique,
3015                text: "dolce".to_string(),
3016                placement: None,
3017                offset_x: None,
3018                offset_y: None,
3019                relative_x: None,
3020                relative_y: None,
3021            }
3022        );
3023
3024        engine
3025            .apply(Command::SetMeasureText(SetMeasureTextCmd {
3026                text_index: 0,
3027                text: Some(StyledText {
3028                    style: crate::TextStyle::RehearsalMark,
3029                    text: "A".to_string(),
3030                    placement: None,
3031                    offset_x: None,
3032                    offset_y: None,
3033                    relative_x: None,
3034                    relative_y: None,
3035                }),
3036                ..SetMeasureTextCmd {
3037                    part_index: 0,
3038                    staff_index: 0,
3039                    measure_index: 0,
3040                    text_index: 0,
3041                    text: None,
3042                }
3043            }))
3044            .unwrap();
3045        assert_eq!(
3046            engine.score.parts[0].staves[0].measures[0].texts[0].style,
3047            crate::TextStyle::RehearsalMark
3048        );
3049
3050        engine
3051            .apply(Command::SetMeasureText(SetMeasureTextCmd {
3052                text_index: 0,
3053                text: None,
3054                ..SetMeasureTextCmd {
3055                    part_index: 0,
3056                    staff_index: 0,
3057                    measure_index: 0,
3058                    text_index: 0,
3059                    text: None,
3060                }
3061            }))
3062            .unwrap();
3063        assert!(engine.score.parts[0].staves[0].measures[0].texts.is_empty());
3064        engine.undo().unwrap();
3065        assert_eq!(engine.score.parts[0].staves[0].measures[0].texts.len(), 1);
3066        engine.redo().unwrap();
3067        assert!(engine.score.parts[0].staves[0].measures[0].texts.is_empty());
3068    }
3069
3070    #[test]
3071    fn set_measure_text_rejects_invalid_index_atomically_and_round_trips_json() {
3072        let mut score = default_engine_score();
3073        let before = score.clone();
3074        let command = Command::SetMeasureText(SetMeasureTextCmd {
3075            part_index: 0,
3076            staff_index: 0,
3077            measure_index: 0,
3078            text_index: 2,
3079            text: Some(StyledText {
3080                style: crate::TextStyle::Expression,
3081                text: "espressivo".to_string(),
3082                placement: None,
3083                offset_x: None,
3084                offset_y: None,
3085                relative_x: None,
3086                relative_y: None,
3087            }),
3088        });
3089        let json = serde_json::to_string(&command).unwrap();
3090        let restored: Command = serde_json::from_str(&json).unwrap();
3091        assert_eq!(
3092            serde_json::to_value(&restored).unwrap(),
3093            serde_json::to_value(&command).unwrap()
3094        );
3095        assert!(apply_command(&restored, &mut score).is_err());
3096        assert_eq!(
3097            serde_json::to_value(&score).unwrap(),
3098            serde_json::to_value(&before).unwrap()
3099        );
3100    }
3101
3102    #[test]
3103    fn set_score_text_supports_append_replace_remove_and_undo_redo() {
3104        let mut engine = crate::ScoreEngine::new();
3105        let text = StyledText {
3106            style: crate::TextStyle::Expression,
3107            text: "Title".to_string(),
3108            placement: None,
3109            offset_x: None,
3110            offset_y: None,
3111            relative_x: None,
3112            relative_y: None,
3113        };
3114        engine
3115            .apply(Command::SetScoreText(SetScoreTextCmd {
3116                text_index: 0,
3117                text: Some(text.clone()),
3118            }))
3119            .unwrap();
3120        assert_eq!(engine.score.texts, vec![text.clone()]);
3121
3122        let mut replacement = text.clone();
3123        replacement.text = "Subtitle".to_string();
3124        engine
3125            .apply(Command::SetScoreText(SetScoreTextCmd {
3126                text_index: 0,
3127                text: Some(replacement.clone()),
3128            }))
3129            .unwrap();
3130        assert_eq!(engine.score.texts, vec![replacement]);
3131
3132        engine
3133            .apply(Command::SetScoreText(SetScoreTextCmd {
3134                text_index: 0,
3135                text: None,
3136            }))
3137            .unwrap();
3138        assert!(engine.score.texts.is_empty());
3139        engine.undo().unwrap();
3140        assert_eq!(engine.score.texts.len(), 1);
3141        engine.redo().unwrap();
3142        assert!(engine.score.texts.is_empty());
3143    }
3144
3145    #[test]
3146    fn set_harmony_range_is_undoable_and_json_compatible() {
3147        let mut score = default_engine_score();
3148        score.parts[0].staves[0].measures[0].voices[0] =
3149            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
3150        score.parts[0].staves[0].measures[0].voices[0][0].chord_symbol = Some(ChordSymbol {
3151            root: "C".to_owned(),
3152            kind: "major".to_owned(),
3153            bass: None,
3154            placement: None,
3155            extender: true,
3156            harmonic_degree: None,
3157            harmony_function: None,
3158            harmony_type: None,
3159            chord_ref: None,
3160            range_end: None,
3161            degrees: Vec::new(),
3162        });
3163        let mut engine = ScoreEngine::new();
3164        engine.replace_score(score);
3165        let command = Command::SetHarmonyRange(SetHarmonyRangeCmd {
3166            part_index: 0,
3167            staff_index: 0,
3168            measure_index: 0,
3169            voice: 0,
3170            note_index: 0,
3171            end: Some(NoteAddr {
3172                part: 0,
3173                staff: 0,
3174                measure: 0,
3175                voice: 0,
3176                note: 0,
3177            }),
3178        });
3179        let json = serde_json::to_string(&command).unwrap();
3180        let restored: Command = serde_json::from_str(&json).unwrap();
3181        engine.apply(restored).unwrap();
3182        assert!(
3183            engine.score.parts[0].staves[0].measures[0].voices[0][0]
3184                .chord_symbol
3185                .as_ref()
3186                .and_then(|chord| chord.range_end.as_ref())
3187                .is_some()
3188        );
3189        engine.undo().unwrap();
3190        assert!(
3191            engine.score.parts[0].staves[0].measures[0].voices[0][0]
3192                .chord_symbol
3193                .as_ref()
3194                .is_some_and(|chord| chord.range_end.is_none())
3195        );
3196    }
3197
3198    #[test]
3199    fn set_note_placement_is_undoable_and_rejects_non_finite_values() {
3200        let mut score = default_engine_score();
3201        score.parts[0].staves[0].measures[0].voices[0] =
3202            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
3203        let mut engine = ScoreEngine::new();
3204        engine.replace_score(score);
3205        let command = Command::SetNotePlacement(SetNotePlacementCmd {
3206            part_index: 0,
3207            staff_index: 0,
3208            measure_index: 0,
3209            voice: 0,
3210            note_index: 0,
3211            offset_x: Some(12.5),
3212            offset_y: Some(-3.0),
3213            relative_x: Some(1.25),
3214            relative_y: Some(-0.5),
3215        });
3216        engine.apply(command).unwrap();
3217        let note = &engine.score.parts[0].staves[0].measures[0].voices[0][0];
3218        assert_eq!(note.offset_x, Some(12.5));
3219        assert_eq!(note.relative_y, Some(-0.5));
3220        engine.undo().unwrap();
3221        assert_eq!(
3222            engine.score.parts[0].staves[0].measures[0].voices[0][0].offset_x,
3223            None
3224        );
3225        let invalid = Command::SetNotePlacement(SetNotePlacementCmd {
3226            part_index: 0,
3227            staff_index: 0,
3228            measure_index: 0,
3229            voice: 0,
3230            note_index: 0,
3231            offset_x: Some(f64::NAN),
3232            offset_y: None,
3233            relative_x: None,
3234            relative_y: None,
3235        });
3236        assert!(engine.apply(invalid).is_err());
3237        assert_eq!(
3238            engine.score.parts[0].staves[0].measures[0].voices[0][0].offset_x,
3239            None
3240        );
3241    }
3242
3243    #[test]
3244    fn set_guitar_bend_alter_updates_note_and_can_clear() {
3245        let mut score = default_engine_score();
3246        apply_command(
3247            &Command::AddNote(AddNoteCmd {
3248                part_index: 0,
3249                staff_index: 0,
3250                measure_index: 0,
3251                voice: 0,
3252                position: 0,
3253                pitch: Some(Pitch::new(Step::G, 4)),
3254                duration: Duration::Quarter,
3255                dot_count: 0,
3256                is_rest: false,
3257                tuplet: None,
3258            }),
3259            &mut score,
3260        )
3261        .unwrap();
3262        let location = SetGuitarBendAlterCmd {
3263            part_index: 0,
3264            staff_index: 0,
3265            measure_index: 0,
3266            voice: 0,
3267            note_index: 0,
3268            alter_cents: Some(200),
3269        };
3270        apply_command(&Command::SetGuitarBendAlter(location.clone()), &mut score).unwrap();
3271        assert_eq!(
3272            score.parts[0].staves[0].measures[0].voices[0][0].guitar_bend_alter_cents,
3273            Some(200)
3274        );
3275        let mut cleared = location;
3276        cleared.alter_cents = None;
3277        apply_command(&Command::SetGuitarBendAlter(cleared), &mut score).unwrap();
3278        assert_eq!(
3279            score.parts[0].staves[0].measures[0].voices[0][0].guitar_bend_alter_cents,
3280            None
3281        );
3282    }
3283
3284    #[test]
3285    fn set_duration_updates_note_and_preserves_measure_capacity() {
3286        let mut score = default_engine_score();
3287        apply_command(
3288            &Command::AddNote(AddNoteCmd {
3289                part_index: 0,
3290                staff_index: 0,
3291                measure_index: 0,
3292                voice: 0,
3293                position: 0,
3294                pitch: Some(Pitch::new(Step::C, 4)),
3295                duration: Duration::Quarter,
3296                dot_count: 0,
3297                is_rest: false,
3298                tuplet: None,
3299            }),
3300            &mut score,
3301        )
3302        .unwrap();
3303        apply_command(
3304            &Command::SetDuration(SetDurationCmd {
3305                part_index: 0,
3306                staff_index: 0,
3307                measure_index: 0,
3308                voice: 0,
3309                note_index: 0,
3310                duration: Duration::Half,
3311                dot_count: 1,
3312            }),
3313            &mut score,
3314        )
3315        .unwrap();
3316        let voice = &score.parts[0].staves[0].measures[0].voices[0];
3317        assert_eq!(voice[0].duration, Duration::Half);
3318        assert_eq!(voice[0].dot_count, 1);
3319        assert!((voice.iter().map(|note| note.beats()).sum::<f64>() - 4.0).abs() < 1e-9);
3320    }
3321
3322    #[test]
3323    fn set_tempo_updates_score() {
3324        let mut score = default_engine_score();
3325        let cmd = Command::SetTempo(SetTempoCmd { bpm: 160 });
3326        apply_command(&cmd, &mut score).unwrap();
3327        assert_eq!(score.settings.tempo_bpm, 160);
3328    }
3329
3330    #[test]
3331    fn add_measure_increases_count() {
3332        let mut score = default_engine_score();
3333        let before = score.measure_count();
3334        apply_command(
3335            &Command::AddMeasure(AddMeasureCmd { after_index: 0 }),
3336            &mut score,
3337        )
3338        .unwrap();
3339        assert_eq!(score.measure_count(), before + 1);
3340    }
3341
3342    #[test]
3343    fn delete_measure_decreases_count() {
3344        let mut score = default_engine_score();
3345        let before = score.measure_count();
3346        apply_command(
3347            &Command::DeleteMeasure(DeleteMeasureCmd { measure_index: 0 }),
3348            &mut score,
3349        )
3350        .unwrap();
3351        assert_eq!(score.measure_count(), before - 1);
3352    }
3353
3354    #[test]
3355    fn undo_restores_score() {
3356        let mut stack = CommandStack::new(50);
3357        let mut score = default_engine_score();
3358        let before = score.settings.tempo_bpm;
3359        stack
3360            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
3361            .unwrap();
3362        assert_eq!(score.settings.tempo_bpm, 200);
3363        stack.undo(&mut score).unwrap();
3364        assert_eq!(score.settings.tempo_bpm, before);
3365    }
3366
3367    #[test]
3368    fn redo_reapplies_command() {
3369        let mut stack = CommandStack::new(50);
3370        let mut score = default_engine_score();
3371        stack
3372            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
3373            .unwrap();
3374        stack.undo(&mut score).unwrap();
3375        stack.redo(&mut score).unwrap();
3376        assert_eq!(score.settings.tempo_bpm, 200);
3377    }
3378
3379    #[test]
3380    fn undo_nothing_returns_error() {
3381        let mut stack = CommandStack::new(50);
3382        let mut score = default_engine_score();
3383        assert!(stack.undo(&mut score).is_err());
3384    }
3385
3386    #[test]
3387    fn add_part_appends_part() {
3388        let mut score = default_engine_score();
3389        let before = score.parts.len();
3390        apply_command(
3391            &Command::AddPart(AddPartCmd {
3392                name: "Violin".into(),
3393                short_name: "Vln.".into(),
3394                clefs: vec!["Treble".into()],
3395                midi_channel: 0,
3396                midi_program: 0,
3397            }),
3398            &mut score,
3399        )
3400        .unwrap();
3401        assert_eq!(score.parts.len(), before + 1);
3402    }
3403
3404    #[test]
3405    fn delete_part_removes_part() {
3406        let mut score = default_engine_score();
3407        apply_command(
3408            &Command::AddPart(AddPartCmd {
3409                name: "Violin".into(),
3410                short_name: "V.".into(),
3411                clefs: vec!["Treble".into()],
3412                midi_channel: 0,
3413                midi_program: 0,
3414            }),
3415            &mut score,
3416        )
3417        .unwrap();
3418        let before = score.parts.len();
3419        apply_command(
3420            &Command::DeletePart(DeletePartCmd { part_index: 0 }),
3421            &mut score,
3422        )
3423        .unwrap();
3424        assert_eq!(score.parts.len(), before - 1);
3425    }
3426
3427    #[test]
3428    fn delete_part_out_of_range_returns_err() {
3429        let mut score = default_engine_score();
3430        assert!(
3431            apply_command(
3432                &Command::DeletePart(DeletePartCmd { part_index: 99 }),
3433                &mut score
3434            )
3435            .is_err()
3436        );
3437    }
3438
3439    #[test]
3440    fn delete_part_undo_restores_part() {
3441        let mut stack = CommandStack::new(50);
3442        let mut score = default_engine_score();
3443        apply_command(
3444            &Command::AddPart(AddPartCmd {
3445                name: "Violin".into(),
3446                short_name: "V.".into(),
3447                clefs: vec!["Treble".into()],
3448                midi_channel: 0,
3449                midi_program: 0,
3450            }),
3451            &mut score,
3452        )
3453        .unwrap();
3454        let before = score.parts.len();
3455        stack
3456            .execute(
3457                Command::DeletePart(DeletePartCmd { part_index: 0 }),
3458                &mut score,
3459            )
3460            .unwrap();
3461        assert_eq!(score.parts.len(), before - 1);
3462        stack.undo(&mut score).unwrap();
3463        assert_eq!(score.parts.len(), before);
3464    }
3465
3466    #[test]
3467    fn set_metadata_updates_title() {
3468        let mut score = default_engine_score();
3469        apply_command(
3470            &Command::SetMetadata(SetMetadataCmd {
3471                title: Some("New Title".into()),
3472                ..Default::default()
3473            }),
3474            &mut score,
3475        )
3476        .unwrap();
3477        assert_eq!(score.metadata.title, "New Title");
3478    }
3479
3480    #[test]
3481    fn set_metadata_none_fields_skipped() {
3482        let mut score = default_engine_score();
3483        let original_composer = score.metadata.composer.clone();
3484        apply_command(
3485            &Command::SetMetadata(SetMetadataCmd {
3486                title: Some("X".into()),
3487                ..Default::default()
3488            }),
3489            &mut score,
3490        )
3491        .unwrap();
3492        assert_eq!(score.metadata.composer, original_composer);
3493    }
3494
3495    #[test]
3496    fn set_volta_sets_bracket() {
3497        use crate::model::score::VoltaBracket;
3498        let mut score = default_engine_score();
3499        let volta = VoltaBracket {
3500            number: 1,
3501            kind: "begin_end".into(),
3502        };
3503        apply_command(
3504            &Command::SetVolta(SetVoltaCmd {
3505                measure_index: 0,
3506                volta: Some(volta.clone()),
3507            }),
3508            &mut score,
3509        )
3510        .unwrap();
3511        assert!(score.parts[0].staves[0].measures[0].volta.is_some());
3512    }
3513
3514    #[test]
3515    fn set_volta_none_clears_bracket() {
3516        use crate::model::score::VoltaBracket;
3517        let mut score = default_engine_score();
3518        score.parts[0].staves[0].measures[0].volta = Some(VoltaBracket {
3519            number: 1,
3520            kind: "begin_end".into(),
3521        });
3522        apply_command(
3523            &Command::SetVolta(SetVoltaCmd {
3524                measure_index: 0,
3525                volta: None,
3526            }),
3527            &mut score,
3528        )
3529        .unwrap();
3530        assert!(score.parts[0].staves[0].measures[0].volta.is_none());
3531    }
3532
3533    #[test]
3534    fn set_volta_undo_restores_old() {
3535        use crate::model::score::VoltaBracket;
3536        let mut stack = CommandStack::new(50);
3537        let mut score = default_engine_score();
3538        stack
3539            .execute(
3540                Command::SetVolta(SetVoltaCmd {
3541                    measure_index: 0,
3542                    volta: Some(VoltaBracket {
3543                        number: 1,
3544                        kind: "begin_end".into(),
3545                    }),
3546                }),
3547                &mut score,
3548            )
3549            .unwrap();
3550        stack.undo(&mut score).unwrap();
3551        assert!(score.parts[0].staves[0].measures[0].volta.is_none());
3552    }
3553
3554    #[test]
3555    fn set_clef_updates_staff_clef() {
3556        use crate::model::notation::Clef;
3557        let mut score = default_engine_score();
3558        apply_command(
3559            &Command::SetClef(SetClefCmd {
3560                part_index: 0,
3561                staff_index: 0,
3562                clef: Clef::Bass,
3563            }),
3564            &mut score,
3565        )
3566        .unwrap();
3567        assert_eq!(score.parts[0].staves[0].clef, Clef::Bass);
3568    }
3569
3570    #[test]
3571    fn set_clef_out_of_range_returns_err() {
3572        use crate::model::notation::Clef;
3573        let mut score = default_engine_score();
3574        assert!(
3575            apply_command(
3576                &Command::SetClef(SetClefCmd {
3577                    part_index: 99,
3578                    staff_index: 0,
3579                    clef: Clef::Bass,
3580                }),
3581                &mut score
3582            )
3583            .is_err()
3584        );
3585    }
3586
3587    #[test]
3588    fn set_part_name_updates_name() {
3589        let mut score = default_engine_score();
3590        apply_command(
3591            &Command::SetPartName(SetPartNameCmd {
3592                part_index: 0,
3593                name: "Violin".into(),
3594                short_name: "Vln.".into(),
3595            }),
3596            &mut score,
3597        )
3598        .unwrap();
3599        assert_eq!(score.parts[0].name, "Violin");
3600        assert_eq!(score.parts[0].short_name, "Vln.");
3601    }
3602
3603    #[test]
3604    fn set_part_name_undo_restores_old() {
3605        let mut stack = CommandStack::new(50);
3606        let mut score = default_engine_score();
3607        let original = score.parts[0].name.clone();
3608        stack
3609            .execute(
3610                Command::SetPartName(SetPartNameCmd {
3611                    part_index: 0,
3612                    name: "Flute".into(),
3613                    short_name: "Fl.".into(),
3614                }),
3615                &mut score,
3616            )
3617            .unwrap();
3618        stack.undo(&mut score).unwrap();
3619        assert_eq!(score.parts[0].name, original);
3620    }
3621
3622    #[test]
3623    fn set_metadata_undo_restores_old_title() {
3624        let mut stack = CommandStack::new(50);
3625        let mut score = default_engine_score();
3626        let original = score.metadata.title.clone();
3627        stack
3628            .execute(
3629                Command::SetMetadata(SetMetadataCmd {
3630                    title: Some("Changed".into()),
3631                    ..Default::default()
3632                }),
3633                &mut score,
3634            )
3635            .unwrap();
3636        assert_ne!(score.metadata.title, original);
3637        stack.undo(&mut score).unwrap();
3638        assert_eq!(score.metadata.title, original);
3639    }
3640
3641    #[test]
3642    fn set_midi_instrument_updates_channel_and_program() {
3643        let mut score = default_engine_score();
3644        apply_command(
3645            &Command::SetMidiInstrument(SetMidiInstrumentCmd {
3646                part_index: 0,
3647                midi_channel: 2,
3648                midi_program: 40,
3649            }),
3650            &mut score,
3651        )
3652        .unwrap();
3653        assert_eq!(score.parts[0].midi_channel, 2);
3654        assert_eq!(score.parts[0].midi_program, 40);
3655    }
3656
3657    #[test]
3658    fn set_midi_instrument_clamps_channel_to_15() {
3659        let mut score = default_engine_score();
3660        apply_command(
3661            &Command::SetMidiInstrument(SetMidiInstrumentCmd {
3662                part_index: 0,
3663                midi_channel: 20,
3664                midi_program: 0,
3665            }),
3666            &mut score,
3667        )
3668        .unwrap();
3669        assert_eq!(score.parts[0].midi_channel, 15);
3670    }
3671
3672    #[test]
3673    fn set_midi_instrument_undo_restores_old() {
3674        let mut stack = CommandStack::new(50);
3675        let mut score = default_engine_score();
3676        score.parts[0].midi_channel = 3;
3677        score.parts[0].midi_program = 10;
3678        stack
3679            .execute(
3680                Command::SetMidiInstrument(SetMidiInstrumentCmd {
3681                    part_index: 0,
3682                    midi_channel: 9,
3683                    midi_program: 114,
3684                }),
3685                &mut score,
3686            )
3687            .unwrap();
3688        stack.undo(&mut score).unwrap();
3689        assert_eq!(score.parts[0].midi_channel, 3);
3690        assert_eq!(score.parts[0].midi_program, 10);
3691    }
3692
3693    #[test]
3694    fn set_transpose_updates_staff() {
3695        let mut score = default_engine_score();
3696        apply_command(
3697            &Command::SetTranspose(SetTransposeCmd {
3698                part_index: 0,
3699                staff_index: 0,
3700                semitones: -2,
3701            }),
3702            &mut score,
3703        )
3704        .unwrap();
3705        assert_eq!(score.parts[0].staves[0].transpose_semitones, -2);
3706    }
3707
3708    #[test]
3709    fn set_transpose_out_of_range_returns_err() {
3710        let mut score = default_engine_score();
3711        assert!(
3712            apply_command(
3713                &Command::SetTranspose(SetTransposeCmd {
3714                    part_index: 99,
3715                    staff_index: 0,
3716                    semitones: -2,
3717                }),
3718                &mut score
3719            )
3720            .is_err()
3721        );
3722    }
3723
3724    #[test]
3725    fn set_tempo_at_measure_sets_tempo() {
3726        let mut score = default_engine_score();
3727        apply_command(
3728            &Command::SetTempoAtMeasure(SetTempoAtMeasureCmd {
3729                measure_index: 0,
3730                bpm: Some(80),
3731            }),
3732            &mut score,
3733        )
3734        .unwrap();
3735        assert_eq!(score.parts[0].staves[0].measures[0].tempo, Some(80));
3736    }
3737
3738    #[test]
3739    fn set_tempo_at_measure_none_clears_tempo() {
3740        let mut score = default_engine_score();
3741        score.parts[0].staves[0].measures[0].tempo = Some(120);
3742        apply_command(
3743            &Command::SetTempoAtMeasure(SetTempoAtMeasureCmd {
3744                measure_index: 0,
3745                bpm: None,
3746            }),
3747            &mut score,
3748        )
3749        .unwrap();
3750        assert!(score.parts[0].staves[0].measures[0].tempo.is_none());
3751    }
3752
3753    // ── batch_execute ─────────────────────────────────────────────────────────
3754
3755    #[test]
3756    fn batch_execute_two_commands_single_undo() {
3757        let mut stack = CommandStack::new(50);
3758        let mut score = default_engine_score();
3759        let original_bpm = score.settings.tempo_bpm;
3760        stack
3761            .batch_execute(
3762                vec![
3763                    Command::SetTempo(SetTempoCmd { bpm: 160 }),
3764                    Command::SetTempo(SetTempoCmd { bpm: 180 }),
3765                ],
3766                &mut score,
3767            )
3768            .unwrap();
3769        assert_eq!(score.settings.tempo_bpm, 180);
3770        stack.undo(&mut score).unwrap();
3771        assert_eq!(score.settings.tempo_bpm, original_bpm);
3772    }
3773
3774    #[test]
3775    fn batch_execute_partial_failure_rollback() {
3776        let mut stack = CommandStack::new(50);
3777        let mut score = default_engine_score();
3778        let original_bpm = score.settings.tempo_bpm;
3779        let result = stack.batch_execute(
3780            vec![
3781                Command::SetTempo(SetTempoCmd { bpm: 160 }),
3782                Command::DeleteNote(DeleteNoteCmd {
3783                    note_id: "nonexistent".into(),
3784                    part_index: 99,
3785                    staff_index: 0,
3786                    measure_index: 0,
3787                    voice: 0,
3788                }),
3789            ],
3790            &mut score,
3791        );
3792        assert!(result.is_err());
3793        assert_eq!(score.settings.tempo_bpm, original_bpm);
3794    }
3795
3796    #[test]
3797    fn execute_rejects_invalid_candidate_without_mutating_or_recording_history() {
3798        let mut stack = CommandStack::new(50);
3799        let mut score = default_engine_score();
3800        score.parts[0].staves[0].tablature = Some(crate::TablatureConfig {
3801            lines: 6,
3802            tuning_midi: vec![40, 45, 50, 55, 59, 64],
3803            capo: 0,
3804        });
3805        stack
3806            .execute(
3807                Command::AddNote(AddNoteCmd {
3808                    part_index: 0,
3809                    staff_index: 0,
3810                    measure_index: 0,
3811                    voice: 0,
3812                    position: 0,
3813                    pitch: Some(Pitch::new(Step::C, 4)),
3814                    duration: Duration::Quarter,
3815                    dot_count: 0,
3816                    is_rest: false,
3817                    tuplet: None,
3818                }),
3819                &mut score,
3820            )
3821            .expect("valid pitched note should be accepted");
3822        let original = score.clone();
3823        let result = stack.execute(
3824            Command::SetTabPosition(SetTabPositionCmd {
3825                part_index: 0,
3826                staff_index: 0,
3827                measure_index: 0,
3828                voice: 0,
3829                note_index: 0,
3830                position: Some(crate::TabPosition { string: 7, fret: 0 }),
3831            }),
3832            &mut score,
3833        );
3834        assert!(matches!(result, Err(Error::InvalidScore)));
3835        let note = &score.parts[0].staves[0].measures[0].voices[0][0];
3836        let original_note = &original.parts[0].staves[0].measures[0].voices[0][0];
3837        assert_eq!(note.pitches, original_note.pitches);
3838        assert_eq!(note.duration, original_note.duration);
3839        assert_eq!(note.tab_position, original_note.tab_position);
3840        assert_eq!(note.tab_positions, original_note.tab_positions);
3841        assert!(stack.can_undo());
3842    }
3843
3844    #[test]
3845    fn set_tablature_config_is_undoable_and_json_compatible() {
3846        let mut stack = CommandStack::new(50);
3847        let mut score = default_engine_score();
3848        let config = crate::TablatureConfig {
3849            lines: 6,
3850            tuning_midi: vec![40, 45, 50, 55, 59, 64],
3851            capo: 2,
3852        };
3853        let command = Command::SetTablatureConfig(SetTablatureConfigCmd {
3854            part_index: 0,
3855            staff_index: 0,
3856            config: Some(config.clone()),
3857        });
3858        let json = serde_json::to_string(&command).expect("command should serialize");
3859        let decoded: Command = serde_json::from_str(&json).expect("command should deserialize");
3860        assert_eq!(command_key(&decoded), "SetTablatureConfig");
3861        stack
3862            .execute(decoded, &mut score)
3863            .expect("config should apply");
3864        assert_eq!(score.parts[0].staves[0].tablature, Some(config));
3865        stack.undo(&mut score).expect("config undo should apply");
3866        assert!(score.parts[0].staves[0].tablature.is_none());
3867        stack.redo(&mut score).expect("config redo should apply");
3868        assert_eq!(
3869            score.parts[0].staves[0]
3870                .tablature
3871                .as_ref()
3872                .map(|tab| tab.capo),
3873            Some(2)
3874        );
3875    }
3876
3877    #[test]
3878    fn batch_execute_empty_is_noop() {
3879        let mut stack = CommandStack::new(50);
3880        let mut score = default_engine_score();
3881        stack.batch_execute(vec![], &mut score).unwrap();
3882        assert!(!stack.can_undo());
3883    }
3884
3885    // ── BatchCmd.label ────────────────────────────────────────────────────────
3886
3887    #[test]
3888    fn batch_label_used_as_command_key() {
3889        let cmd = Command::Batch(BatchCmd {
3890            commands: vec![],
3891            label: Some("ApplyAI".to_string()),
3892        });
3893        assert_eq!(command_key(&cmd), "ApplyAI");
3894    }
3895
3896    #[test]
3897    fn batch_no_label_key_is_batch() {
3898        let cmd = Command::Batch(BatchCmd {
3899            commands: vec![],
3900            label: None,
3901        });
3902        assert_eq!(command_key(&cmd), "Batch");
3903    }
3904
3905    #[test]
3906    fn batch_label_survives_json_roundtrip() {
3907        let cmd = Command::Batch(BatchCmd {
3908            commands: vec![Command::SetTempo(SetTempoCmd { bpm: 120 })],
3909            label: Some("PasteSelection".to_string()),
3910        });
3911        let json = serde_json::to_string(&cmd).unwrap();
3912        let cmd2: Command = serde_json::from_str(&json).unwrap();
3913        assert_eq!(command_key(&cmd2), "PasteSelection");
3914    }
3915
3916    #[test]
3917    fn batch_label_in_undo_key() {
3918        let mut stack = CommandStack::new(50);
3919        let mut score = default_engine_score();
3920        let cmd = Command::Batch(BatchCmd {
3921            commands: vec![Command::SetTempo(SetTempoCmd { bpm: 140 })],
3922            label: Some("ApplyAI".to_string()),
3923        });
3924        stack.execute(cmd, &mut score).unwrap();
3925        assert_eq!(stack.undo_key(), Some("ApplyAI".to_string()));
3926    }
3927
3928    #[test]
3929    fn undo_returns_change_hint() {
3930        use crate::model::change_hint::ChangeScope;
3931        let mut stack = CommandStack::new(50);
3932        let mut score = default_engine_score();
3933        stack
3934            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
3935            .unwrap();
3936        let hint = stack.undo(&mut score).unwrap();
3937        assert_eq!(hint.scope, ChangeScope::Global);
3938        assert!(hint.playback_dirty);
3939    }
3940
3941    #[test]
3942    fn redo_returns_change_hint() {
3943        use crate::model::change_hint::ChangeScope;
3944        let mut stack = CommandStack::new(50);
3945        let mut score = default_engine_score();
3946        stack
3947            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
3948            .unwrap();
3949        stack.undo(&mut score).unwrap();
3950        let hint = stack.redo(&mut score).unwrap();
3951        assert_eq!(hint.scope, ChangeScope::Global);
3952        assert!(hint.playback_dirty);
3953    }
3954
3955    // ── Feature A: ToggleSlur ─────────────────────────────────────────────
3956
3957    #[test]
3958    fn toggle_slur_sets_start_and_end() {
3959        let mut score = default_engine_score();
3960        let cmd = Command::AddNote(AddNoteCmd {
3961            part_index: 0,
3962            staff_index: 0,
3963            measure_index: 0,
3964            voice: 0,
3965            position: 0,
3966            pitch: Some(Pitch::new(Step::C, 4)),
3967            duration: Duration::Quarter,
3968            dot_count: 0,
3969            is_rest: false,
3970            tuplet: None,
3971        });
3972        apply_command(&cmd, &mut score).unwrap();
3973        apply_command(
3974            &Command::AddNote(AddNoteCmd {
3975                part_index: 0,
3976                staff_index: 0,
3977                measure_index: 0,
3978                voice: 0,
3979                position: 1,
3980                pitch: Some(Pitch::new(Step::D, 4)),
3981                duration: Duration::Quarter,
3982                dot_count: 0,
3983                is_rest: false,
3984                tuplet: None,
3985            }),
3986            &mut score,
3987        )
3988        .unwrap();
3989        let start = NoteAddr {
3990            part: 0,
3991            staff: 0,
3992            measure: 0,
3993            voice: 0,
3994            note: 0,
3995        };
3996        let end = NoteAddr {
3997            part: 0,
3998            staff: 0,
3999            measure: 0,
4000            voice: 0,
4001            note: 1,
4002        };
4003        apply_command(
4004            &Command::ToggleSlur(ToggleSlurCmd {
4005                start: start.clone(),
4006                end: end.clone(),
4007            }),
4008            &mut score,
4009        )
4010        .unwrap();
4011        assert!(score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
4012        assert!(score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
4013        // toggle off
4014        apply_command(
4015            &Command::ToggleSlur(ToggleSlurCmd { start, end }),
4016            &mut score,
4017        )
4018        .unwrap();
4019        assert!(!score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
4020        assert!(!score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
4021    }
4022
4023    // ── Feature B: AddStaff / DeleteStaff ────────────────────────────────
4024
4025    #[test]
4026    fn add_staff_appends_staff_with_correct_measure_count() {
4027        let mut score = default_engine_score();
4028        let before = score.parts[0].staves.len();
4029        let measure_count = score.parts[0].staves[0].measures.len();
4030        apply_command(
4031            &Command::AddStaff(AddStaffCmd {
4032                part_index: 0,
4033                clef: Clef::Bass,
4034            }),
4035            &mut score,
4036        )
4037        .unwrap();
4038        assert_eq!(score.parts[0].staves.len(), before + 1);
4039        let new_staff = score.parts[0].staves.last().unwrap();
4040        assert_eq!(new_staff.measures.len(), measure_count);
4041    }
4042
4043    #[test]
4044    fn add_staff_out_of_range_returns_err() {
4045        let mut score = default_engine_score();
4046        let result = apply_command(
4047            &Command::AddStaff(AddStaffCmd {
4048                part_index: 99,
4049                clef: Clef::Treble,
4050            }),
4051            &mut score,
4052        );
4053        assert!(result.is_err());
4054    }
4055
4056    #[test]
4057    fn delete_staff_removes_extra_staff() {
4058        let mut score = default_engine_score();
4059        apply_command(
4060            &Command::AddStaff(AddStaffCmd {
4061                part_index: 0,
4062                clef: Clef::Bass,
4063            }),
4064            &mut score,
4065        )
4066        .unwrap();
4067        assert_eq!(score.parts[0].staves.len(), 2);
4068        apply_command(
4069            &Command::DeleteStaff(DeleteStaffCmd {
4070                part_index: 0,
4071                staff_index: 1,
4072            }),
4073            &mut score,
4074        )
4075        .unwrap();
4076        assert_eq!(score.parts[0].staves.len(), 1);
4077    }
4078
4079    #[test]
4080    fn delete_last_staff_returns_err() {
4081        let mut score = default_engine_score();
4082        assert_eq!(score.parts[0].staves.len(), 1);
4083        let result = apply_command(
4084            &Command::DeleteStaff(DeleteStaffCmd {
4085                part_index: 0,
4086                staff_index: 0,
4087            }),
4088            &mut score,
4089        );
4090        assert!(result.is_err());
4091    }
4092
4093    // ── Feature D: SetTuplet ──────────────────────────────────────────────
4094
4095    #[test]
4096    fn set_tuplet_assigns_and_clears() {
4097        use crate::model::notation::TupletInfo;
4098        let mut score = default_engine_score();
4099        apply_command(
4100            &Command::AddNote(AddNoteCmd {
4101                part_index: 0,
4102                staff_index: 0,
4103                measure_index: 0,
4104                voice: 0,
4105                position: 0,
4106                pitch: Some(Pitch::new(Step::C, 4)),
4107                duration: Duration::Quarter,
4108                dot_count: 0,
4109                is_rest: false,
4110                tuplet: None,
4111            }),
4112            &mut score,
4113        )
4114        .unwrap();
4115        let ti = TupletInfo {
4116            actual_notes: 3,
4117            normal_notes: 2,
4118        };
4119        apply_command(
4120            &Command::SetTuplet(SetTupletCmd {
4121                part_index: 0,
4122                staff_index: 0,
4123                measure_index: 0,
4124                voice_index: 0,
4125                note_index: 0,
4126                tuplet: Some(ti.clone()),
4127            }),
4128            &mut score,
4129        )
4130        .unwrap();
4131        assert_eq!(
4132            score.parts[0].staves[0].measures[0].voices[0][0].tuplet,
4133            Some(ti)
4134        );
4135        apply_command(
4136            &Command::SetTuplet(SetTupletCmd {
4137                part_index: 0,
4138                staff_index: 0,
4139                measure_index: 0,
4140                voice_index: 0,
4141                note_index: 0,
4142                tuplet: None,
4143            }),
4144            &mut score,
4145        )
4146        .unwrap();
4147        assert!(
4148            score.parts[0].staves[0].measures[0].voices[0][0]
4149                .tuplet
4150                .is_none()
4151        );
4152    }
4153
4154    // ── Feature E: RespellScore ───────────────────────────────────────────
4155
4156    #[test]
4157    fn respell_score_cmd_changes_all_pitches() {
4158        use crate::model::pitch::Step;
4159        let mut score = default_engine_score();
4160        apply_command(
4161            &Command::AddNote(AddNoteCmd {
4162                part_index: 0,
4163                staff_index: 0,
4164                measure_index: 0,
4165                voice: 0,
4166                position: 0,
4167                pitch: Some(Pitch::with_alter(Step::C, 4, 1)), // C#4
4168                duration: Duration::Quarter,
4169                dot_count: 0,
4170                is_rest: false,
4171                tuplet: None,
4172            }),
4173            &mut score,
4174        )
4175        .unwrap();
4176        apply_command(
4177            &Command::RespellScore(RespellScoreCmd { prefer_flat: true }),
4178            &mut score,
4179        )
4180        .unwrap();
4181        let pitch = &score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
4182        assert_eq!(pitch.step, Step::D);
4183        assert_eq!(pitch.alter, -1); // Db4
4184    }
4185
4186    #[test]
4187    fn typed_spanner_commands_are_atomic_and_undoable() {
4188        use crate::model::score::{NotationSpanner, NotationSpannerKind};
4189
4190        let mut score = default_engine_score();
4191        let address = NoteAddr {
4192            part: 0,
4193            staff: 0,
4194            measure: 0,
4195            voice: 0,
4196            note: 0,
4197        };
4198        let spanner = NotationSpanner {
4199            id: "slur-1".to_string(),
4200            kind: NotationSpannerKind::Slur,
4201            start: address.clone(),
4202            end: address,
4203            number: Some(1),
4204            line_type: Some("dashed".to_string()),
4205            text: None,
4206            placement: Some("above".to_string()),
4207            ottava_size: None,
4208            ottava_type: None,
4209        };
4210        let mut stack = CommandStack::new(8);
4211        stack
4212            .execute(Command::AddSpanner(AddSpannerCmd { spanner }), &mut score)
4213            .unwrap();
4214        assert_eq!(score.spanners.len(), 1);
4215        assert_eq!(stack.undo_key().as_deref(), Some("AddSpanner"));
4216
4217        let before_duplicate = score.clone();
4218        let duplicate = score.spanners[0].clone();
4219        assert!(
4220            stack
4221                .execute(
4222                    Command::AddSpanner(AddSpannerCmd { spanner: duplicate }),
4223                    &mut score,
4224                )
4225                .is_err()
4226        );
4227        assert_eq!(score.spanners, before_duplicate.spanners);
4228
4229        let mut updated = score.spanners[0].clone();
4230        updated.number = Some(2);
4231        stack
4232            .execute(
4233                Command::UpdateSpanner(UpdateSpannerCmd { spanner: updated }),
4234                &mut score,
4235            )
4236            .unwrap();
4237        assert_eq!(score.spanners[0].number, Some(2));
4238        stack.undo(&mut score).unwrap();
4239        assert_eq!(score.spanners[0].number, Some(1));
4240        stack.redo(&mut score).unwrap();
4241        assert_eq!(score.spanners[0].number, Some(2));
4242
4243        stack
4244            .execute(
4245                Command::RemoveSpanner(RemoveSpannerCmd {
4246                    id: "slur-1".to_string(),
4247                }),
4248                &mut score,
4249            )
4250            .unwrap();
4251        assert!(score.spanners.is_empty());
4252        stack.undo(&mut score).unwrap();
4253        assert_eq!(score.spanners.len(), 1);
4254    }
4255}