Skip to main content

acorde_core/model/
commands.rs

1use super::change_hint::{ChangeHint, ChangeScope};
2use super::duration::Duration;
3use super::fragment::{
4    MIN_SUPPORTED_SCORE_FRAGMENT_CONTRACT_VERSION, SCORE_FRAGMENT_CONTRACT_VERSION, ScoreFragment,
5    ScoreFragmentSelection, extract_score_fragment,
6};
7use super::notation::{
8    Articulation, Barline, ChordSymbol, Clef, CrossStaff, Dynamic, FiguredBassFigure,
9    GuitarTechnique, HairpinKind, KeySignature, Lyric, NoteHead, OttavaKind, StyledText,
10    TablatureConfig, TimeSignature, TupletInfo, VerseLyric,
11};
12use super::pitch::Pitch;
13use super::score::{
14    HarpPedalDiagram, InstrumentDefinition, InstrumentRange, Measure, NotationSpanner,
15    NotationSpannerKind, Note, NoteAddr, ObjectStyleOverride, Part, PartGroup,
16    PercussionInstrument, RegionalTranspositionTarget, RespellPolicy, Score, ScoreTemplate,
17    ScoreView, Staff, StaffKind, StaffPresentation, ViewStyleOverride, respell_score,
18    respell_score_to_key, respell_staff_region, transpose_staff_region_checked,
19};
20use super::validate::validate;
21use crate::Error;
22use serde::{Deserialize, Serialize};
23use std::collections::BTreeSet;
24use uuid::Uuid;
25
26mod range_commands;
27mod spanner_remap;
28mod structural_commands;
29
30use self::range_commands::{apply_paste_range, apply_paste_voice};
31use self::spanner_remap::{
32    clear_legacy_spanner_endpoints, note_at, prune_orphaned_spanners, remap_spanners,
33};
34use self::structural_commands::{apply_join_measures, apply_split_measure};
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(tag = "type", rename_all = "snake_case")]
38pub enum Command {
39    AddNote(AddNoteCmd),
40    AddPitch(AddPitchCmd),
41    SetDuration(SetDurationCmd),
42    DeleteNote(DeleteNoteCmd),
43    AddMeasure(AddMeasureCmd),
44    DeleteMeasure(DeleteMeasureCmd),
45    SetTempo(SetTempoCmd),
46    NewScore(NewScoreCmd),
47    AddHairpin(AddHairpinCmd),
48    ToggleTie(ToggleTieCmd),
49    SetDynamic(SetDynamicCmd),
50    ToggleArticulation(ToggleArticulationCmd),
51    SetKeySignature(SetKeySignatureCmd),
52    SetTimeSignature(SetTimeSignatureCmd),
53    SetBarline(SetBarlineCmd),
54    AddPart(AddPartCmd),
55    DeletePart(DeletePartCmd),
56    ReorderParts(ReorderPartsCmd),
57    SetMetadata(SetMetadataCmd),
58    SetRehearsalMark(SetRehearsalMarkCmd),
59    SetNavigationMark(SetNavigationMarkCmd),
60    SetChordSymbol(SetChordSymbolCmd),
61    SetHarmonyRange(SetHarmonyRangeCmd),
62    SetFiguredBass(SetFiguredBassCmd),
63    SetHarpPedalDiagrams(SetHarpPedalDiagramsCmd),
64    SetGrace(SetGraceCmd),
65    SetOttava(SetOttavaCmd),
66    SetLyric(SetLyricCmd),
67    SetMultiRest(SetMultiRestCmd),
68    AddPedal(AddPedalCmd),
69    SetVolta(SetVoltaCmd),
70    SetClef(SetClefCmd),
71    SetPartName(SetPartNameCmd),
72    SetMidiInstrument(SetMidiInstrumentCmd),
73    SetPercussionKit(SetPercussionKitCmd),
74    SetInstrumentDefinition(SetInstrumentDefinitionCmd),
75    SetMeasureInstrumentChange(SetMeasureInstrumentChangeCmd),
76    SetMeasureTablatureChange(SetMeasureTablatureChangeCmd),
77    UpsertScoreView(UpsertScoreViewCmd),
78    RemoveScoreView(RemoveScoreViewCmd),
79    SetTranspose(SetTransposeCmd),
80    TransposeStaffRegion(TransposeStaffRegionCmd),
81    SetTempoAtMeasure(SetTempoAtMeasureCmd),
82    SetTempoRampAtMeasure(SetTempoRampAtMeasureCmd),
83    PasteVoice(PasteVoiceCmd),
84    PasteRange(PasteRangeCmd),
85    PasteScoreFragment(PasteScoreFragmentCmd),
86    ExchangeVoices(ExchangeVoicesCmd),
87    MoveOrCopyVoiceRange(MoveOrCopyVoiceRangeCmd),
88    SplitMeasure(SplitMeasureCmd),
89    JoinMeasures(JoinMeasuresCmd),
90    ImplodeStaves(ImplodeStavesCmd),
91    ExplodeVoices(ExplodeVoicesCmd),
92    ExplodeChordPitches(ExplodeChordPitchesCmd),
93    ScaleVoiceRange(ScaleVoiceRangeCmd),
94    SetSystemBreak(SetSystemBreakCmd),
95    SetPageBreak(SetPageBreakCmd),
96    SetSectionBreak(SetSectionBreakCmd),
97    ToggleSlur(ToggleSlurCmd),
98    AddStaff(AddStaffCmd),
99    DeleteStaff(DeleteStaffCmd),
100    SetTuplet(SetTupletCmd),
101    RespellScore(RespellScoreCmd),
102    RespellScoreToKey(RespellScoreToKeyCmd),
103    RespellStaffRegion(RespellStaffRegionCmd),
104    CycleEnharmonicSpelling(CycleEnharmonicSpellingCmd),
105    ResequenceRehearsalMarks(ResequenceRehearsalMarksCmd),
106    SetSystemBreakInterval(SetSystemBreakIntervalCmd),
107    RemoveTrailingEmptyMeasures(RemoveTrailingEmptyMeasuresCmd),
108    SetStem(SetStemCmd),
109    SetArpeggio(SetArpeggioCmd),
110    SetTechniqueText(SetTechniqueTextCmd),
111    SetFingering(SetFingeringCmd),
112    SetFingerings(SetFingeringsCmd),
113    SetStringNumber(SetStringNumberCmd),
114    SetTabPosition(SetTabPositionCmd),
115    SetTablatureConfig(SetTablatureConfigCmd),
116    SetStaffPresentation(SetStaffPresentationCmd),
117    SetNoteHead(SetNoteHeadCmd),
118    SetCue(SetCueCmd),
119    SetUnpitched(SetUnpitchedCmd),
120    SetInstrumentId(SetInstrumentIdCmd),
121    SetNotePlacement(SetNotePlacementCmd),
122    SetGuitarTechnique(SetGuitarTechniqueCmd),
123    SetGuitarBendAlter(SetGuitarBendAlterCmd),
124    SetGuitarBendCurve(SetGuitarBendCurveCmd),
125    SetExpressionText(SetExpressionTextCmd),
126    SetMeasureText(SetMeasureTextCmd),
127    SetScoreText(SetScoreTextCmd),
128    SetScoreStyleOverrides(SetScoreStyleOverridesCmd),
129    SetObjectStyleOverrides(SetObjectStyleOverridesCmd),
130    ToggleTrillLine(ToggleTrillLineCmd),
131    SetGlissando(SetGlissandoCmd),
132    SetCrossStaff(SetCrossStaffCmd),
133    SetPartGroup(SetPartGroupCmd),
134    AddSpanner(AddSpannerCmd),
135    UpdateSpanner(UpdateSpannerCmd),
136    RemoveSpanner(RemoveSpannerCmd),
137    Batch(BatchCmd),
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct AddNoteCmd {
142    pub part_index: usize,
143    pub staff_index: usize,
144    pub measure_index: usize,
145    pub voice: usize,
146    pub position: usize,
147    pub pitch: Option<Pitch>,
148    pub duration: Duration,
149    pub dot_count: u8,
150    pub is_rest: bool,
151    #[serde(default)]
152    pub tuplet: Option<TupletInfo>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct AddPitchCmd {
157    pub part_index: usize,
158    pub staff_index: usize,
159    pub measure_index: usize,
160    pub voice: usize,
161    pub note_index: usize,
162    pub pitch: Pitch,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct SetDurationCmd {
167    pub part_index: usize,
168    pub staff_index: usize,
169    pub measure_index: usize,
170    pub voice: usize,
171    pub note_index: usize,
172    pub duration: Duration,
173    #[serde(default)]
174    pub dot_count: u8,
175}
176
177/// Add a typed notation span to the score.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct AddSpannerCmd {
180    pub spanner: NotationSpanner,
181}
182
183/// Replace every mutable property of an existing typed notation span.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct UpdateSpannerCmd {
186    pub spanner: NotationSpanner,
187}
188
189/// Remove a typed notation span by its stable identity.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct RemoveSpannerCmd {
192    pub id: String,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct DeleteNoteCmd {
197    pub note_id: String,
198    pub part_index: usize,
199    pub staff_index: usize,
200    pub measure_index: usize,
201    pub voice: usize,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct AddMeasureCmd {
206    pub after_index: usize,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct DeleteMeasureCmd {
211    pub measure_index: usize,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct SetTempoCmd {
216    pub bpm: u16,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct NewScoreCmd {
221    pub title: String,
222    pub composer: String,
223    pub tempo_bpm: u16,
224    pub time_numerator: u8,
225    pub time_denominator: u8,
226    pub key_fifths: i8,
227    pub measure_count: u32,
228    /// When set, creates the score from an ensemble template instead of a blank single-part score.
229    #[serde(default)]
230    pub template: Option<ScoreTemplate>,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct AddHairpinCmd {
235    pub part_index: usize,
236    pub staff_index: usize,
237    pub measure_index: usize,
238    pub voice: usize,
239    pub start_note_idx: usize,
240    pub end_note_idx: usize,
241    pub kind: HairpinKind,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct ToggleTieCmd {
246    pub part_index: usize,
247    pub staff_index: usize,
248    pub measure_index: usize,
249    pub voice: usize,
250    pub note_index: usize,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct SetDynamicCmd {
255    pub part_index: usize,
256    pub staff_index: usize,
257    pub measure_index: usize,
258    pub voice: usize,
259    pub note_index: usize,
260    pub dynamic: Option<Dynamic>,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct ToggleArticulationCmd {
265    pub part_index: usize,
266    pub staff_index: usize,
267    pub measure_index: usize,
268    pub voice: usize,
269    pub note_index: usize,
270    pub articulation: Articulation,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct SetKeySignatureCmd {
275    pub fifths: i8,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct SetTimeSignatureCmd {
280    pub numerator: u8,
281    pub denominator: u8,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct SetBarlineCmd {
286    pub measure_index: usize,
287    /// "left" or "right"
288    pub side: String,
289    pub barline: Barline,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct AddPartCmd {
294    pub name: String,
295    pub short_name: String,
296    /// Clef variant names for each staff, e.g. ["Treble"] or ["Treble", "Bass"]
297    pub clefs: Vec<String>,
298    /// MIDI channel (0–15). Default 0.
299    #[serde(default)]
300    pub midi_channel: u8,
301    /// General MIDI program (0–127). Default 0 = Acoustic Grand Piano.
302    #[serde(default)]
303    pub midi_program: u8,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct DeletePartCmd {
308    pub part_index: usize,
309}
310
311/// Reorder every part by its current zero-based index.
312///
313/// `order` must be a permutation of every current part. Linked views, typed spanners, and
314/// contiguous part groups are remapped so they continue to identify the same musical parts.
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct ReorderPartsCmd {
317    pub order: Vec<usize>,
318}
319
320#[derive(Debug, Clone, Default, Serialize, Deserialize)]
321pub struct SetMetadataCmd {
322    pub title: Option<String>,
323    pub composer: Option<String>,
324    pub lyricist: Option<String>,
325    pub copyright: Option<String>,
326    pub work_number: Option<String>,
327    pub movement_title: Option<String>,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct SetRehearsalMarkCmd {
332    pub measure_index: usize,
333    pub text: Option<String>,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct SetNavigationMarkCmd {
338    pub measure_index: usize,
339    /// Known values: "Segno", "Coda", "Fine", "DaCapo", "DaCapoAlFine",
340    /// "DaCapoAlCoda", "DalSegno", "DalSegnoAlFine", "DalSegnoAlCoda", "ToCoda".
341    pub mark: Option<String>,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct SetChordSymbolCmd {
346    pub part_index: usize,
347    pub staff_index: usize,
348    pub measure_index: usize,
349    pub voice: usize,
350    pub note_index: usize,
351    pub chord: Option<ChordSymbol>,
352}
353
354/// Set or clear the end note of a chord-symbol continuation range.
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct SetHarmonyRangeCmd {
357    pub part_index: usize,
358    pub staff_index: usize,
359    pub measure_index: usize,
360    pub voice: usize,
361    pub note_index: usize,
362    pub end: Option<NoteAddr>,
363}
364
365/// Replace the structured figured-bass figures attached to a measure.
366#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct SetFiguredBassCmd {
368    pub measure_index: usize,
369    pub figures: Vec<FiguredBassFigure>,
370}
371
372/// Replace the MusicXML-compatible harp pedal diagrams on one staff-local measure.
373#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct SetHarpPedalDiagramsCmd {
375    pub part_index: usize,
376    pub staff_index: usize,
377    pub measure_index: usize,
378    pub diagrams: Vec<HarpPedalDiagram>,
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct SetGraceCmd {
383    pub part_index: usize,
384    pub staff_index: usize,
385    pub measure_index: usize,
386    pub voice: usize,
387    pub note_index: usize,
388    pub is_grace: bool,
389    /// true = acciaccatura (slash), false = appoggiatura (no slash).
390    pub slash: bool,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize)]
394pub struct SetOttavaCmd {
395    pub part_index: usize,
396    pub staff_index: usize,
397    pub measure_index: usize,
398    pub voice: usize,
399    pub note_index: usize,
400    pub ottava_start: Option<OttavaKind>,
401    pub ottava_end: bool,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct SetLyricCmd {
406    pub part_index: usize,
407    pub staff_index: usize,
408    pub measure_index: usize,
409    pub voice: usize,
410    pub note_index: usize,
411    pub lyric: Option<Lyric>,
412    /// Verse to set or clear; `None` or 1 addresses verse 1 (`Note.lyric`).
413    #[serde(default)]
414    pub verse: Option<u8>,
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct SetMultiRestCmd {
419    pub measure_index: usize,
420    pub count: Option<u8>,
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct SetVoltaCmd {
425    pub measure_index: usize,
426    pub volta: Option<super::score::VoltaBracket>,
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct SetClefCmd {
431    pub part_index: usize,
432    pub staff_index: usize,
433    pub clef: Clef,
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct SetPartNameCmd {
438    pub part_index: usize,
439    pub name: String,
440    pub short_name: String,
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct SetMidiInstrumentCmd {
445    pub part_index: usize,
446    /// MIDI channel (0–15).
447    pub midi_channel: u8,
448    /// General MIDI program number (0–127).
449    pub midi_program: u8,
450}
451
452/// Replace a part's editable percussion-kit map.
453#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct SetPercussionKitCmd {
455    pub part_index: usize,
456    pub instruments: Vec<PercussionInstrument>,
457}
458
459/// Set or clear a part's stable instrument semantics.
460#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct SetInstrumentDefinitionCmd {
462    pub part_index: usize,
463    pub definition: Option<InstrumentDefinition>,
464}
465
466/// Set or clear an instrument change beginning at a staff-local measure boundary.
467#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct SetMeasureInstrumentChangeCmd {
469    pub part_index: usize,
470    pub staff_index: usize,
471    pub measure_index: usize,
472    pub definition: Option<InstrumentDefinition>,
473}
474
475/// Set or clear a tuning/capo change beginning at a staff-local measure boundary.
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct SetMeasureTablatureChangeCmd {
478    pub part_index: usize,
479    pub staff_index: usize,
480    pub measure_index: usize,
481    pub config: Option<TablatureConfig>,
482}
483
484/// Create or replace a named linked-part view by stable ID.
485#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct UpsertScoreViewCmd {
487    pub view: ScoreView,
488}
489
490/// Remove a named linked-part view by stable ID.
491#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct RemoveScoreViewCmd {
493    pub id: String,
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct SetTransposeCmd {
498    pub part_index: usize,
499    pub staff_index: usize,
500    /// Semitones to transpose (negative = down). E.g. -2 for Bb clarinet.
501    pub semitones: i8,
502}
503
504/// Transpose one contiguous staff range through the validated written/concert boundary.
505#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct TransposeStaffRegionCmd {
507    pub part_index: usize,
508    pub staff_index: usize,
509    /// Inclusive physical measure index.
510    pub start_measure: usize,
511    /// Exclusive physical measure index.
512    pub end_measure: usize,
513    pub semitones: i8,
514    pub target: RegionalTranspositionTarget,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize)]
518pub struct SetTempoAtMeasureCmd {
519    pub measure_index: usize,
520    /// New BPM at this measure. `None` clears any measure-level override.
521    pub bpm: Option<u16>,
522}
523
524/// Set or clear the target BPM reached at the end of one physical measure.
525#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct SetTempoRampAtMeasureCmd {
527    pub measure_index: usize,
528    pub target_bpm: Option<u16>,
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct PasteVoiceCmd {
533    pub part_index: usize,
534    pub staff_index: usize,
535    pub measure_index: usize,
536    pub voice_index: usize,
537    /// Snapshot of the clipboard at paste time — embedded in the command for undo/redo.
538    pub notes: Vec<Note>,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize)]
542pub struct SetSystemBreakCmd {
543    pub measure_index: usize,
544    pub value: bool,
545}
546
547#[derive(Debug, Clone, Serialize, Deserialize)]
548pub struct SetPageBreakCmd {
549    pub measure_index: usize,
550    pub value: bool,
551}
552
553/// Set or clear a semantic section boundary at a physical measure.
554#[derive(Debug, Clone, Serialize, Deserialize)]
555pub struct SetSectionBreakCmd {
556    pub measure_index: usize,
557    pub value: bool,
558}
559
560/// A group of commands applied and undone as a single unit.
561#[derive(Debug, Clone, Serialize, Deserialize)]
562pub struct BatchCmd {
563    pub commands: Vec<Command>,
564    /// Optional i18n key / display label override shown in the undo menu.
565    /// E.g. `"ApplyAI"`, `"PasteSelection"`. `None` falls back to `"Batch"`.
566    #[serde(default)]
567    pub label: Option<String>,
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct AddPedalCmd {
572    pub part_index: usize,
573    pub staff_index: usize,
574    pub measure_index: usize,
575    pub voice: usize,
576    pub start_note_idx: usize,
577    pub end_note_idx: usize,
578}
579
580/// Replace a contiguous range of voice measures with stored notes (undo-able).
581///
582/// `measures` contains one `Vec<Note>` per measure to paste, starting at `target_measure`.
583/// The target voice of each measure is replaced entirely.
584#[derive(Debug, Clone, Serialize, Deserialize)]
585pub struct PasteRangeCmd {
586    pub part_index: usize,
587    pub staff_index: usize,
588    pub voice_index: usize,
589    pub target_measure: usize,
590    /// One note list per measure, in order.
591    pub measures: Vec<Vec<Note>>,
592}
593
594/// Paste a versioned, multi-lane score fragment at a canonical destination.
595///
596/// The destination is the origin for every relative fragment address. The
597/// policy determines whether mapped voice measures are replaced, or only
598/// pasted into empty/rest-only lanes. CommandStack retains a full undo snapshot.
599#[derive(Debug, Clone, Serialize, Deserialize)]
600pub struct PasteScoreFragmentCmd {
601    pub fragment: ScoreFragment,
602    pub target: NoteAddr,
603    #[serde(default)]
604    pub policy: ScoreFragmentPastePolicy,
605}
606
607/// Collision policy for a score-fragment paste.
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
609#[serde(rename_all = "snake_case")]
610pub enum ScoreFragmentPastePolicy {
611    /// Replace every mapped destination lane, matching the v1/v2 behavior.
612    #[default]
613    Replace,
614    /// Preserve any sounding destination lane; paste only into empty or
615    /// rest-only lanes, otherwise reject the complete command atomically.
616    Merge,
617}
618
619/// Exchange two editable voices across an inclusive measure range.
620///
621/// Both note vectors and their imported MusicXML source voice numbers move
622/// together, preserving sparse-voice cursor semantics for later serialization.
623#[derive(Debug, Clone, Serialize, Deserialize)]
624pub struct ExchangeVoicesCmd {
625    pub part_index: usize,
626    pub staff_index: usize,
627    pub start_measure: usize,
628    pub end_measure: usize,
629    pub first_voice: usize,
630    pub second_voice: usize,
631}
632
633/// Copy or move an inclusive, whole-measure single-voice range.
634///
635/// The operation reuses the versioned ScoreFragment mapping contract. `move_source`
636/// clears the source lane only after the destination has been validated and populated.
637#[derive(Debug, Clone, Serialize, Deserialize)]
638pub struct MoveOrCopyVoiceRangeCmd {
639    pub source_start: NoteAddr,
640    pub source_end: NoteAddr,
641    pub target: NoteAddr,
642    #[serde(default)]
643    pub move_source: bool,
644}
645
646/// Split one physical measure across every part and staff at an exact beat boundary.
647#[derive(Debug, Clone, Serialize, Deserialize)]
648pub struct SplitMeasureCmd {
649    pub measure_index: usize,
650    pub split_at_beats: f64,
651}
652
653/// Join one physical measure with its following measure across every part and staff.
654#[derive(Debug, Clone, Serialize, Deserialize)]
655pub struct JoinMeasuresCmd {
656    pub measure_index: usize,
657}
658
659/// Move the primary voice from compatible staves into voices of one staff.
660///
661/// `source_staves` is ordered: its position becomes the destination voice index.
662/// It must contain `target_staff` exactly once and contain two to four staves.
663/// Every source secondary voice must be empty; this makes the operation lossless
664/// and gives a deterministic conflict policy.
665#[derive(Debug, Clone, Serialize, Deserialize)]
666pub struct ImplodeStavesCmd {
667    pub part_index: usize,
668    pub source_staves: Vec<usize>,
669    pub target_staff: usize,
670    pub start_measure: usize,
671    pub end_measure: usize,
672}
673
674/// Move voices from one staff into the primary voices of compatible staves.
675///
676/// `target_staves[voice]` receives the corresponding source voice. The first
677/// target must equal `source_staff`, so the command does not discard voice zero.
678/// Non-source targets must contain only rests in their primary voice and no
679/// secondary voices; otherwise the command fails before changing the score.
680#[derive(Debug, Clone, Serialize, Deserialize)]
681pub struct ExplodeVoicesCmd {
682    pub part_index: usize,
683    pub source_staff: usize,
684    pub target_staves: Vec<usize>,
685    pub start_measure: usize,
686    pub end_measure: usize,
687}
688
689/// Distribute pitches from primary-voice chords to the primary voices of
690/// compatible staves.
691///
692/// `target_staves[0]` must be `source_staff`. The original note identity and
693/// all note-attached notation remain with that first pitch; derived pitches get
694/// fresh note identities and only pitch-local tablature placement. This avoids
695/// duplicating directions, lyrics, or typed span endpoints during export.
696#[derive(Debug, Clone, Serialize, Deserialize)]
697pub struct ExplodeChordPitchesCmd {
698    pub part_index: usize,
699    pub source_staff: usize,
700    pub target_staves: Vec<usize>,
701    pub start_measure: usize,
702    pub end_measure: usize,
703}
704
705/// Exact power-of-two duration scaling supported by the portable score model.
706#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
707#[serde(rename_all = "snake_case")]
708pub enum DurationScale {
709    Half,
710    Double,
711}
712
713/// How duration scaling treats the notation ratio of existing tuplets.
714#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
715#[serde(rename_all = "snake_case")]
716pub enum TupletScalePolicy {
717    /// Keep each `actual-notes:normal-notes` ratio while scaling written duration.
718    #[default]
719    PreserveRatio,
720}
721
722/// Scale every note in one voice across an inclusive measure range.
723///
724/// Tuplets preserve their `actual-notes:normal-notes` ratio, so their written
725/// and performed durations change by the same factor. Underfilled scaled
726/// measures receive explicit trailing rests; overflow rejects the complete
727/// command before any score or history mutation.
728#[derive(Debug, Clone, Serialize, Deserialize)]
729pub struct ScaleVoiceRangeCmd {
730    pub part_index: usize,
731    pub staff_index: usize,
732    pub voice: usize,
733    pub start_measure: usize,
734    pub end_measure: usize,
735    pub scale: DurationScale,
736    #[serde(default)]
737    pub tuplet_policy: TupletScalePolicy,
738}
739
740/// Toggle slur_start on `start` note and slur_end on `end` note (cross-measure aware).
741#[derive(Debug, Clone, Serialize, Deserialize)]
742pub struct ToggleSlurCmd {
743    pub start: NoteAddr,
744    pub end: NoteAddr,
745}
746
747/// Add or replace a part group. `None` removes all groups that overlap the range.
748#[derive(Debug, Clone, Serialize, Deserialize)]
749pub struct SetPartGroupCmd {
750    /// `Some(group)` to add/replace; `None` removes any group whose `[first_part, last_part]` matches.
751    pub group: Option<PartGroup>,
752}
753
754/// Toggle a trill line span between two notes (start note gets `trill_line_start`, end gets `trill_line_end`).
755#[derive(Debug, Clone, Serialize, Deserialize)]
756pub struct ToggleTrillLineCmd {
757    pub start: NoteAddr,
758    pub end: NoteAddr,
759}
760
761/// Add a new staff to an existing part with the given clef.
762///
763/// The new staff is appended with empty measures matching the current measure count.
764/// Clef values: `"Treble"` | `"Bass"` | `"Alto"` | `"Tenor"` | `"Percussion"`.
765#[derive(Debug, Clone, Serialize, Deserialize)]
766pub struct AddStaffCmd {
767    pub part_index: usize,
768    pub clef: Clef,
769}
770
771/// Remove a staff from a part. Fails if it is the last remaining staff.
772#[derive(Debug, Clone, Serialize, Deserialize)]
773pub struct DeleteStaffCmd {
774    pub part_index: usize,
775    pub staff_index: usize,
776}
777
778/// Set (or clear) the tuplet info on an existing note.
779#[derive(Debug, Clone, Serialize, Deserialize)]
780pub struct SetTupletCmd {
781    pub part_index: usize,
782    pub staff_index: usize,
783    pub measure_index: usize,
784    pub voice_index: usize,
785    pub note_index: usize,
786    /// `None` removes the tuplet; `Some(TupletInfo)` sets it.
787    pub tuplet: Option<TupletInfo>,
788}
789
790/// Set or clear the stem direction on a note (override). `None` means auto.
791#[derive(Debug, Clone, Serialize, Deserialize)]
792pub struct SetStemCmd {
793    pub part_index: usize,
794    pub staff_index: usize,
795    pub measure_index: usize,
796    pub voice_index: usize,
797    pub note_index: usize,
798    /// `None` = auto, `Some(true)` = stem up, `Some(false)` = stem down.
799    pub stem_up: Option<bool>,
800}
801
802#[derive(Debug, Clone, Serialize, Deserialize)]
803pub struct SetArpeggioCmd {
804    pub part_index: usize,
805    pub staff_index: usize,
806    pub measure_index: usize,
807    pub voice_index: usize,
808    pub note_index: usize,
809    /// `Some(true)` = up, `Some(false)` = down, `None` = clear.
810    pub direction: Option<bool>,
811}
812
813/// Set (or clear) the technique-text annotation on a note ("pizz.", "arco", "con sord.", etc.).
814#[derive(Debug, Clone, Serialize, Deserialize)]
815pub struct SetTechniqueTextCmd {
816    pub part_index: usize,
817    pub staff_index: usize,
818    pub measure_index: usize,
819    pub voice: usize,
820    pub note_index: usize,
821    /// `None` clears the annotation.
822    pub text: Option<String>,
823}
824
825#[derive(Debug, Clone, Serialize, Deserialize)]
826pub struct SetGlissandoCmd {
827    pub part_index: usize,
828    pub staff_index: usize,
829    pub measure_index: usize,
830    pub voice: usize,
831    pub note_index: usize,
832    pub start: bool,
833    pub end: bool,
834}
835
836#[derive(Debug, Clone, Serialize, Deserialize)]
837pub struct SetCrossStaffCmd {
838    pub part_index: usize,
839    pub staff_index: usize,
840    pub measure_index: usize,
841    pub voice: usize,
842    pub note_index: usize,
843    pub placement: Option<CrossStaff>,
844}
845
846/// Set (or clear) the fingering number on a note (0 = open/thumb, 1–5 = fingers).
847#[derive(Debug, Clone, Serialize, Deserialize)]
848pub struct SetFingeringCmd {
849    pub part_index: usize,
850    pub staff_index: usize,
851    pub measure_index: usize,
852    pub voice: usize,
853    pub note_index: usize,
854    /// `None` clears the fingering.
855    pub fingering: Option<u8>,
856}
857
858/// Set all ordered fingering candidates on a note. The first value mirrors
859/// the legacy singular `fingering` field; an empty list clears both fields.
860#[derive(Debug, Clone, Serialize, Deserialize)]
861pub struct SetFingeringsCmd {
862    pub part_index: usize,
863    pub staff_index: usize,
864    pub measure_index: usize,
865    pub voice: usize,
866    pub note_index: usize,
867    pub fingerings: Vec<u8>,
868}
869
870/// Set (or clear) the string number on a note (1 = highest string).
871#[derive(Debug, Clone, Serialize, Deserialize)]
872pub struct SetStringNumberCmd {
873    pub part_index: usize,
874    pub staff_index: usize,
875    pub measure_index: usize,
876    pub voice: usize,
877    pub note_index: usize,
878    /// `None` clears the string number.
879    pub string_number: Option<u8>,
880}
881
882/// Set (or clear) the tablature string/fret position on a note.
883#[derive(Debug, Clone, Serialize, Deserialize)]
884pub struct SetTabPositionCmd {
885    pub part_index: usize,
886    pub staff_index: usize,
887    pub measure_index: usize,
888    pub voice: usize,
889    pub note_index: usize,
890    /// `None` clears the explicit tablature position.
891    pub position: Option<super::notation::TabPosition>,
892}
893
894/// Set (or clear) the tablature tuning and capo configuration on a staff.
895#[derive(Debug, Clone, Serialize, Deserialize)]
896pub struct SetTablatureConfigCmd {
897    pub part_index: usize,
898    pub staff_index: usize,
899    /// `None` clears tablature mode for the staff.
900    pub config: Option<TablatureConfig>,
901}
902
903/// Replace the renderer-independent presentation settings for one staff.
904#[derive(Debug, Clone, Serialize, Deserialize)]
905pub struct SetStaffPresentationCmd {
906    pub part_index: usize,
907    pub staff_index: usize,
908    pub presentation: StaffPresentation,
909}
910
911/// Set (or clear) the guitar playing technique on a note (bend, slide, hammer-on, pull-off).
912#[derive(Debug, Clone, Serialize, Deserialize)]
913pub struct SetGuitarTechniqueCmd {
914    pub part_index: usize,
915    pub staff_index: usize,
916    pub measure_index: usize,
917    pub voice: usize,
918    pub note_index: usize,
919    /// `None` clears the technique.
920    pub technique: Option<GuitarTechnique>,
921}
922
923/// Set (or clear) the MusicXML guitar bend amount in cents on a note.
924#[derive(Debug, Clone, Serialize, Deserialize)]
925pub struct SetGuitarBendAlterCmd {
926    pub part_index: usize,
927    pub staff_index: usize,
928    pub measure_index: usize,
929    pub voice: usize,
930    pub note_index: usize,
931    /// `None` clears the bend amount.
932    pub alter_cents: Option<i16>,
933}
934
935/// Replace the authored bend/hold/release curve on a note.
936#[derive(Debug, Clone, Serialize, Deserialize)]
937pub struct SetGuitarBendCurveCmd {
938    pub part_index: usize,
939    pub staff_index: usize,
940    pub measure_index: usize,
941    pub voice: usize,
942    pub note_index: usize,
943    #[serde(default)]
944    pub points: Vec<crate::GuitarBendPoint>,
945}
946
947/// Set (or clear) the expression/performance text on a measure ("dolce", "espressivo", etc.).
948#[derive(Debug, Clone, Serialize, Deserialize)]
949pub struct SetExpressionTextCmd {
950    pub measure_index: usize,
951    /// `None` clears the expression text.
952    pub text: Option<String>,
953}
954
955/// Insert, replace, or remove one measure-level styled text entry.
956///
957/// `text_index == texts.len()` with `Some(text)` appends an entry. An existing
958/// index with `Some(text)` replaces it; an existing index with `None` removes it.
959/// `None` at the end, or an index beyond the end, is rejected.
960#[derive(Debug, Clone, Serialize, Deserialize)]
961pub struct SetMeasureTextCmd {
962    pub part_index: usize,
963    pub staff_index: usize,
964    pub measure_index: usize,
965    pub text_index: usize,
966    pub text: Option<StyledText>,
967}
968
969/// Insert, replace, or remove one score-level styled text entry.
970///
971/// `text_index == texts.len()` with `Some(text)` appends an entry. An existing
972/// index with `Some(text)` replaces it; an existing index with `None` removes it.
973/// `None` at the end, or an index beyond the end, is rejected.
974#[derive(Debug, Clone, Serialize, Deserialize)]
975pub struct SetScoreTextCmd {
976    pub text_index: usize,
977    pub text: Option<StyledText>,
978}
979
980/// Replace the ordered score-wide typed presentation defaults.
981///
982/// Later entries for the same property take precedence when a view is resolved.
983#[derive(Debug, Clone, Serialize, Deserialize)]
984pub struct SetScoreStyleOverridesCmd {
985    pub overrides: Vec<ViewStyleOverride>,
986}
987
988/// Replace every typed object-attached presentation override.
989#[derive(Debug, Clone, Serialize, Deserialize)]
990pub struct SetObjectStyleOverridesCmd {
991    pub overrides: Vec<ObjectStyleOverride>,
992}
993
994/// Mark or unmark a note as a cue note (cue notes have zero beats).
995#[derive(Debug, Clone, Serialize, Deserialize)]
996pub struct SetCueCmd {
997    pub part_index: usize,
998    pub staff_index: usize,
999    pub measure_index: usize,
1000    pub voice: usize,
1001    pub note_index: usize,
1002    pub is_cue: bool,
1003}
1004
1005/// Mark or unmark a note as unpitched while retaining its display placement pitch.
1006#[derive(Debug, Clone, Serialize, Deserialize)]
1007pub struct SetUnpitchedCmd {
1008    pub part_index: usize,
1009    pub staff_index: usize,
1010    pub measure_index: usize,
1011    pub voice: usize,
1012    pub note_index: usize,
1013    pub is_unpitched: bool,
1014}
1015
1016/// Set or clear a source instrument identifier attached to a note.
1017#[derive(Debug, Clone, Serialize, Deserialize)]
1018pub struct SetInstrumentIdCmd {
1019    pub part_index: usize,
1020    pub staff_index: usize,
1021    pub measure_index: usize,
1022    pub voice: usize,
1023    pub note_index: usize,
1024    pub instrument_id: Option<String>,
1025}
1026
1027/// Set or clear MusicXML-compatible note placement offsets in tenths.
1028#[derive(Debug, Clone, Serialize, Deserialize)]
1029pub struct SetNotePlacementCmd {
1030    pub part_index: usize,
1031    pub staff_index: usize,
1032    pub measure_index: usize,
1033    pub voice: usize,
1034    pub note_index: usize,
1035    #[serde(default)]
1036    pub offset_x: Option<f64>,
1037    #[serde(default)]
1038    pub offset_y: Option<f64>,
1039    #[serde(default)]
1040    pub relative_x: Option<f64>,
1041    #[serde(default)]
1042    pub relative_y: Option<f64>,
1043}
1044
1045/// Set the note head shape on a note.
1046#[derive(Debug, Clone, Serialize, Deserialize)]
1047pub struct SetNoteHeadCmd {
1048    pub part_index: usize,
1049    pub staff_index: usize,
1050    pub measure_index: usize,
1051    pub voice: usize,
1052    pub note_index: usize,
1053    pub note_head: NoteHead,
1054}
1055
1056/// Respell all pitches in the score to prefer flats or sharps.
1057#[derive(Debug, Clone, Serialize, Deserialize)]
1058pub struct RespellScoreCmd {
1059    pub prefer_flat: bool,
1060}
1061
1062/// Respell all pitches to match the score's key signature (auto-selects flat vs sharp).
1063#[derive(Debug, Clone, Serialize, Deserialize)]
1064pub struct RespellScoreToKeyCmd {}
1065
1066/// Respell the pitched notes of one staff range, like MuseScore's "Respell pitches" applied to a
1067/// selection. Tie chains crossing the range boundary keep one spelling; see
1068/// [`respell_staff_region`].
1069#[derive(Debug, Clone, Serialize, Deserialize)]
1070pub struct RespellStaffRegionCmd {
1071    pub part_index: usize,
1072    pub staff_index: usize,
1073    /// Inclusive physical measure index.
1074    pub start_measure: usize,
1075    /// Exclusive physical measure index.
1076    pub end_measure: usize,
1077    pub policy: RespellPolicy,
1078}
1079
1080/// Move a note to its next enharmonic spelling (MuseScore's "Change enharmonic spelling", J);
1081/// see [`Pitch::next_enharmonic`]. Sounding pitch, microtones, and tablature positions are kept.
1082#[derive(Debug, Clone, Serialize, Deserialize)]
1083pub struct CycleEnharmonicSpellingCmd {
1084    pub part_index: usize,
1085    pub staff_index: usize,
1086    pub measure_index: usize,
1087    pub voice: usize,
1088    pub note_index: usize,
1089    /// Chord member to respell; `None` respells every member of the chord.
1090    #[serde(default)]
1091    pub pitch_index: Option<usize>,
1092}
1093
1094/// Renumber rehearsal marks in score order, continuing the sequence started by the first mark
1095/// (MuseScore's "Resequence rehearsal marks"). Supported sequences are upper- or lower-case
1096/// letters (`A`…`Z`, `AA`, `AB`, …), numbers, and measure numbers (when the first mark equals its
1097/// measure number). Without a range the whole score is resequenced.
1098#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1099pub struct ResequenceRehearsalMarksCmd {
1100    /// Inclusive physical measure index; defaults to the first measure.
1101    #[serde(default)]
1102    pub start_measure: Option<usize>,
1103    /// Exclusive physical measure index; defaults to the measure count.
1104    #[serde(default)]
1105    pub end_measure: Option<usize>,
1106}
1107
1108/// Replace system (line) breaks with one after every `interval` measures, or remove them when
1109/// `interval` is 0 (MuseScore's "Add/remove line breaks"). Page and section breaks are kept, and
1110/// no break is added after the final measure of the score.
1111#[derive(Debug, Clone, Serialize, Deserialize)]
1112pub struct SetSystemBreakIntervalCmd {
1113    pub interval: u32,
1114    /// Inclusive physical measure index; defaults to the first measure.
1115    #[serde(default)]
1116    pub start_measure: Option<usize>,
1117    /// Exclusive physical measure index; defaults to the measure count.
1118    #[serde(default)]
1119    pub end_measure: Option<usize>,
1120}
1121
1122/// Delete trailing measures that contain nothing but plain rests in every part and staff
1123/// (MuseScore's "Remove empty trailing measures"). At least one measure is always kept. A final
1124/// barline on a removed measure moves to the new last measure.
1125#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1126pub struct RemoveTrailingEmptyMeasuresCmd {}
1127
1128struct UndoEntry {
1129    command: Command,
1130    snapshot: Score,
1131}
1132
1133pub struct CommandStack {
1134    history: Vec<UndoEntry>,
1135    future: Vec<(Command, Score)>,
1136    max_depth: usize,
1137}
1138
1139impl CommandStack {
1140    pub fn new(max_depth: usize) -> Self {
1141        Self {
1142            history: Vec::new(),
1143            future: Vec::new(),
1144            max_depth,
1145        }
1146    }
1147
1148    pub fn can_undo(&self) -> bool {
1149        !self.history.is_empty()
1150    }
1151
1152    pub fn can_redo(&self) -> bool {
1153        !self.future.is_empty()
1154    }
1155
1156    pub fn execute(&mut self, cmd: Command, score: &mut Score) -> Result<(), Error> {
1157        let snapshot = score.clone();
1158        let mut candidate = snapshot.clone();
1159        apply_command(&cmd, &mut candidate)?;
1160        if !validate(&candidate).is_valid() {
1161            return Err(Error::InvalidScore);
1162        }
1163        *score = candidate;
1164        self.history.push(UndoEntry {
1165            command: cmd,
1166            snapshot,
1167        });
1168        self.future.clear();
1169        if self.history.len() > self.max_depth {
1170            self.history.remove(0);
1171        }
1172        Ok(())
1173    }
1174
1175    pub fn undo(&mut self, score: &mut Score) -> Result<ChangeHint, Error> {
1176        let entry = self.history.last().ok_or(Error::NothingToUndo)?;
1177        if !validate(&entry.snapshot).is_valid() {
1178            return Err(Error::InvalidScore);
1179        }
1180        let entry = self.history.pop().ok_or(Error::NothingToUndo)?;
1181        let hint = command_hint(&entry.command);
1182        let post_snapshot = score.clone();
1183        *score = entry.snapshot;
1184        self.future.push((entry.command, post_snapshot));
1185        if self.future.len() > self.max_depth {
1186            self.future.remove(0);
1187        }
1188        Ok(hint)
1189    }
1190
1191    pub fn redo(&mut self, score: &mut Score) -> Result<ChangeHint, Error> {
1192        let (_, post) = self.future.last().ok_or(Error::NothingToRedo)?;
1193        if !validate(post).is_valid() {
1194            return Err(Error::InvalidScore);
1195        }
1196        let (cmd, post) = self.future.pop().ok_or(Error::NothingToRedo)?;
1197        let hint = command_hint(&cmd);
1198        let snapshot = score.clone();
1199        *score = post;
1200        self.history.push(UndoEntry {
1201            command: cmd,
1202            snapshot,
1203        });
1204        Ok(hint)
1205    }
1206
1207    /// Return the commands applied so far, in execution order.
1208    /// Suitable for use with [`ScoreEngine::export_history`].
1209    pub fn history_commands(&self) -> Vec<Command> {
1210        self.history.iter().map(|e| e.command.clone()).collect()
1211    }
1212
1213    /// Label of the command that would be undone next, for UI display (e.g. "Undo: Add Note").
1214    pub fn undo_label(&self) -> Option<String> {
1215        self.history.last().map(|e| command_label(&e.command))
1216    }
1217
1218    /// Label of the command that would be redone next, for UI display (e.g. "Redo: Add Note").
1219    pub fn redo_label(&self) -> Option<String> {
1220        self.future.last().map(|(cmd, _)| command_label(cmd))
1221    }
1222
1223    /// i18n key of the command that would be undone next.
1224    pub fn undo_key(&self) -> Option<String> {
1225        self.history.last().map(|e| command_key(&e.command))
1226    }
1227
1228    /// i18n key of the command that would be redone next.
1229    pub fn redo_key(&self) -> Option<String> {
1230        self.future.last().map(|(cmd, _)| command_key(cmd))
1231    }
1232
1233    /// Apply a batch of commands as a single undo entry (rollback-safe).
1234    pub fn batch_execute(&mut self, cmds: Vec<Command>, score: &mut Score) -> Result<(), Error> {
1235        if cmds.is_empty() {
1236            return Ok(());
1237        }
1238        let snapshot = score.clone();
1239        let mut candidate = snapshot.clone();
1240        for cmd in &cmds {
1241            apply_command(cmd, &mut candidate)?;
1242        }
1243        if !validate(&candidate).is_valid() {
1244            return Err(Error::InvalidScore);
1245        }
1246        *score = candidate;
1247        self.history.push(UndoEntry {
1248            command: Command::Batch(BatchCmd {
1249                commands: cmds,
1250                label: None,
1251            }),
1252            snapshot,
1253        });
1254        self.future.clear();
1255        if self.history.len() > self.max_depth {
1256            self.history.remove(0);
1257        }
1258        Ok(())
1259    }
1260
1261    /// Apply a batch with an explicit undo-label, as a single rollback-safe entry.
1262    ///
1263    /// The `label` appears as the [`command_key`] for undo/redo UI (e.g. `"ApplyAI"`).
1264    pub fn batch_execute_labeled(
1265        &mut self,
1266        cmds: Vec<Command>,
1267        label: String,
1268        score: &mut Score,
1269    ) -> Result<(), Error> {
1270        if cmds.is_empty() {
1271            return Ok(());
1272        }
1273        let snapshot = score.clone();
1274        let mut candidate = snapshot.clone();
1275        for cmd in &cmds {
1276            apply_command(cmd, &mut candidate)?;
1277        }
1278        if !validate(&candidate).is_valid() {
1279            return Err(Error::InvalidScore);
1280        }
1281        *score = candidate;
1282        self.history.push(UndoEntry {
1283            command: Command::Batch(BatchCmd {
1284                commands: cmds,
1285                label: Some(label),
1286            }),
1287            snapshot,
1288        });
1289        self.future.clear();
1290        if self.history.len() > self.max_depth {
1291            self.history.remove(0);
1292        }
1293        Ok(())
1294    }
1295}
1296
1297/// Return a [`ChangeHint`] describing the scope and dirty flags for a command,
1298/// without executing it.
1299pub fn command_hint(cmd: &Command) -> ChangeHint {
1300    use ChangeScope::*;
1301    macro_rules! hint {
1302        ($scope:expr, $layout:expr, $playback:expr) => {
1303            ChangeHint {
1304                scope: $scope,
1305                layout_dirty: $layout,
1306                playback_dirty: $playback,
1307            }
1308        };
1309    }
1310    macro_rules! meas {
1311        ($c:expr) => {
1312            Measures {
1313                part: $c.part_index,
1314                staff: $c.staff_index,
1315                start: $c.measure_index,
1316                end: $c.measure_index + 1,
1317            }
1318        };
1319    }
1320    match cmd {
1321        // Global — full score affected
1322        Command::NewScore(_)
1323        | Command::AddPart(_)
1324        | Command::DeletePart(_)
1325        | Command::ReorderParts(_)
1326        | Command::AddMeasure(_)
1327        | Command::DeleteMeasure(_) => hint!(Global, true, true),
1328
1329        Command::SetTempo(_) => hint!(Global, false, true),
1330
1331        Command::SetMetadata(_) => hint!(Global, false, false),
1332
1333        Command::SetKeySignature(_) => hint!(Global, true, false),
1334
1335        Command::SetTimeSignature(_) => hint!(Global, true, true),
1336
1337        Command::SetBarline(_)
1338        | Command::SetVolta(_)
1339        | Command::SetRehearsalMark(_)
1340        | Command::SetNavigationMark(_)
1341        | Command::SetExpressionText(_) => hint!(Global, false, false),
1342        Command::SetMeasureText(c) => hint!(
1343            Measures {
1344                part: c.part_index,
1345                staff: c.staff_index,
1346                start: c.measure_index,
1347                end: c.measure_index + 1
1348            },
1349            false,
1350            false
1351        ),
1352        Command::SetScoreText(_)
1353        | Command::SetScoreStyleOverrides(_)
1354        | Command::SetObjectStyleOverrides(_) => hint!(Global, true, false),
1355
1356        Command::SetMultiRest(_) => hint!(Global, true, false),
1357
1358        Command::SetTempoAtMeasure(_) => hint!(Global, false, true),
1359        Command::SetTempoRampAtMeasure(_) => hint!(Global, false, true),
1360
1361        // Part scope
1362        Command::SetPartName(c) => hint!(Part(c.part_index), false, false),
1363
1364        Command::SetMidiInstrument(c) => hint!(Part(c.part_index), false, true),
1365        Command::SetPercussionKit(c) => hint!(Part(c.part_index), true, true),
1366
1367        Command::SetInstrumentDefinition(c) => hint!(Part(c.part_index), true, false),
1368        Command::SetMeasureInstrumentChange(c) => hint!(meas!(c), true, true),
1369        Command::SetMeasureTablatureChange(c) => hint!(meas!(c), true, true),
1370
1371        Command::UpsertScoreView(_) | Command::RemoveScoreView(_) => hint!(Global, true, false),
1372
1373        Command::SetTranspose(c) => hint!(Part(c.part_index), false, true),
1374        Command::TransposeStaffRegion(c) => hint!(
1375            Measures {
1376                part: c.part_index,
1377                staff: c.staff_index,
1378                start: c.start_measure,
1379                end: c.end_measure
1380            },
1381            true,
1382            true
1383        ),
1384
1385        Command::SetClef(c) => hint!(Part(c.part_index), true, false),
1386
1387        // Measure scope
1388        Command::AddNote(c) => hint!(meas!(c), false, true),
1389        Command::AddPitch(c) => hint!(meas!(c), false, true),
1390        Command::SetDuration(c) => hint!(meas!(c), false, true),
1391        Command::DeleteNote(c) => hint!(meas!(c), false, true),
1392        Command::PasteVoice(c) => hint!(meas!(c), false, true),
1393        Command::PasteRange(c) => hint!(
1394            Measures {
1395                part: c.part_index,
1396                staff: c.staff_index,
1397                start: c.target_measure,
1398                end: c.target_measure + c.measures.len()
1399            },
1400            false,
1401            true
1402        ),
1403        Command::PasteScoreFragment(_) => hint!(Global, true, true),
1404        Command::ExchangeVoices(c) => hint!(
1405            Measures {
1406                part: c.part_index,
1407                staff: c.staff_index,
1408                start: c.start_measure.min(c.end_measure),
1409                end: c.start_measure.max(c.end_measure) + 1,
1410            },
1411            true,
1412            true
1413        ),
1414        Command::MoveOrCopyVoiceRange(_) => hint!(Global, true, true),
1415        Command::SplitMeasure(_) => hint!(Global, true, true),
1416        Command::JoinMeasures(_) => hint!(Global, true, true),
1417        Command::ImplodeStaves(_) | Command::ExplodeVoices(_) => hint!(Global, true, true),
1418        Command::ExplodeChordPitches(_) => hint!(Global, true, true),
1419        Command::ScaleVoiceRange(c) => hint!(
1420            Measures {
1421                part: c.part_index,
1422                staff: c.staff_index,
1423                start: c.start_measure.min(c.end_measure),
1424                end: c.start_measure.max(c.end_measure) + 1,
1425            },
1426            true,
1427            true
1428        ),
1429        Command::AddHairpin(c) => hint!(meas!(c), false, true),
1430        Command::ToggleTie(c) => hint!(meas!(c), false, true),
1431        Command::SetDynamic(c) => hint!(meas!(c), false, true),
1432        Command::ToggleArticulation(c) => hint!(meas!(c), false, true),
1433        Command::SetGrace(c) => hint!(meas!(c), false, true),
1434        Command::SetOttava(c) => hint!(meas!(c), false, true),
1435        Command::SetLyric(c) => hint!(meas!(c), false, true),
1436        Command::AddPedal(c) => hint!(meas!(c), false, true),
1437        Command::SetChordSymbol(c) => hint!(meas!(c), false, true),
1438        Command::SetHarmonyRange(c) => hint!(meas!(c), false, true),
1439        Command::SetFiguredBass(_) => hint!(Global, false, true),
1440        Command::SetHarpPedalDiagrams(c) => hint!(meas!(c), false, false),
1441
1442        Command::SetSystemBreak(_) | Command::SetPageBreak(_) | Command::SetSectionBreak(_) => {
1443            hint!(Global, true, false)
1444        }
1445
1446        Command::ToggleSlur(_) | Command::ToggleTrillLine(_) => hint!(Global, true, false),
1447        Command::SetGlissando(c) => hint!(meas!(c), true, true),
1448        Command::SetCrossStaff(c) => hint!(meas!(c), true, true),
1449        Command::AddSpanner(_) | Command::UpdateSpanner(_) | Command::RemoveSpanner(_) => {
1450            hint!(Global, true, true)
1451        }
1452
1453        Command::SetPartGroup(_) => hint!(Global, false, false),
1454
1455        Command::AddStaff(_) | Command::DeleteStaff(_) => hint!(Global, true, true),
1456
1457        Command::SetTuplet(c) => hint!(meas!(c), false, true),
1458
1459        Command::RespellScore(_) | Command::RespellScoreToKey(_) => hint!(Global, true, true),
1460        // A tie crossing the range boundary may respell neighbouring measures of the same staff.
1461        Command::RespellStaffRegion(c) => hint!(Part(c.part_index), true, true),
1462        Command::CycleEnharmonicSpelling(c) => hint!(meas!(c), true, true),
1463        Command::ResequenceRehearsalMarks(_) | Command::SetSystemBreakInterval(_) => {
1464            hint!(Global, true, false)
1465        }
1466        Command::RemoveTrailingEmptyMeasures(_) => hint!(Global, true, true),
1467
1468        Command::SetStem(c) => hint!(meas!(c), false, false),
1469
1470        Command::SetArpeggio(c) => hint!(meas!(c), false, false),
1471
1472        Command::SetTechniqueText(c) => hint!(meas!(c), false, false),
1473        Command::SetFingering(c) => hint!(meas!(c), false, false),
1474        Command::SetFingerings(c) => hint!(meas!(c), false, false),
1475        Command::SetStringNumber(c) => hint!(meas!(c), false, false),
1476        Command::SetTabPosition(c) => hint!(meas!(c), false, false),
1477        Command::SetTablatureConfig(c) => hint!(Part(c.part_index), true, true),
1478        Command::SetStaffPresentation(c) => hint!(Part(c.part_index), true, false),
1479        Command::SetGuitarTechnique(c) => hint!(meas!(c), false, false),
1480        Command::SetGuitarBendAlter(c) => hint!(meas!(c), false, false),
1481        Command::SetGuitarBendCurve(c) => hint!(meas!(c), false, false),
1482        Command::SetNoteHead(c) => hint!(meas!(c), false, false),
1483        Command::SetCue(c) => hint!(meas!(c), false, true),
1484        Command::SetUnpitched(c) => hint!(meas!(c), false, true),
1485        Command::SetInstrumentId(c) => hint!(meas!(c), false, true),
1486        Command::SetNotePlacement(c) => hint!(meas!(c), true, false),
1487
1488        Command::Batch(c) => {
1489            let Some(first) = c.commands.first() else {
1490                return hint!(Global, false, false);
1491            };
1492            let mut merged = command_hint(first);
1493            for cmd in c.commands.iter().skip(1) {
1494                merged = merged.merge(command_hint(cmd));
1495            }
1496            merged
1497        }
1498    }
1499}
1500
1501/// Human-readable label for an undoable command (for menu display).
1502pub fn command_label(cmd: &Command) -> String {
1503    match cmd {
1504        Command::AddNote(_) => "Add Note".to_string(),
1505        Command::AddPitch(_) => "Add Pitch".to_string(),
1506        Command::SetDuration(_) => "Set Duration".to_string(),
1507        Command::DeleteNote(_) => "Delete Note".to_string(),
1508        Command::AddMeasure(_) => "Add Measure".to_string(),
1509        Command::DeleteMeasure(_) => "Delete Measure".to_string(),
1510        Command::SetTempo(_) => "Set Tempo".to_string(),
1511        Command::NewScore(_) => "New Score".to_string(),
1512        Command::AddHairpin(_) => "Add Hairpin".to_string(),
1513        Command::ToggleTie(_) => "Toggle Tie".to_string(),
1514        Command::SetDynamic(_) => "Set Dynamic".to_string(),
1515        Command::ToggleArticulation(_) => "Toggle Articulation".to_string(),
1516        Command::SetKeySignature(_) => "Set Key Signature".to_string(),
1517        Command::SetTimeSignature(_) => "Set Time Signature".to_string(),
1518        Command::SetBarline(_) => "Set Barline".to_string(),
1519        Command::AddPart(_) => "Add Part".to_string(),
1520        Command::DeletePart(_) => "Delete Part".to_string(),
1521        Command::ReorderParts(_) => "Reorder Parts".to_string(),
1522        Command::SetMetadata(_) => "Set Metadata".to_string(),
1523        Command::SetRehearsalMark(_) => "Set Rehearsal Mark".to_string(),
1524        Command::SetNavigationMark(_) => "Set Navigation Mark".to_string(),
1525        Command::SetChordSymbol(_) => "Set Chord Symbol".to_string(),
1526        Command::SetHarmonyRange(_) => "Set Harmony Range".to_string(),
1527        Command::SetHarpPedalDiagrams(_) => "Set Harp Pedal Diagrams".to_string(),
1528        Command::SetFiguredBass(_) => "Set Figured Bass".to_string(),
1529        Command::SetGrace(_) => "Set Grace Note".to_string(),
1530        Command::SetOttava(_) => "Set Ottava".to_string(),
1531        Command::SetLyric(_) => "Set Lyric".to_string(),
1532        Command::SetMultiRest(_) => "Set Multi-Rest".to_string(),
1533        Command::AddPedal(_) => "Add Pedal".to_string(),
1534        Command::SetVolta(_) => "Set Volta".to_string(),
1535        Command::SetClef(_) => "Set Clef".to_string(),
1536        Command::SetPartName(_) => "Set Part Name".to_string(),
1537        Command::SetMidiInstrument(_) => "Set MIDI Instrument".to_string(),
1538        Command::SetPercussionKit(_) => "Set Percussion Kit".to_string(),
1539        Command::SetInstrumentDefinition(_) => "Set Instrument Definition".to_string(),
1540        Command::SetMeasureInstrumentChange(_) => "Set Measure Instrument Change".to_string(),
1541        Command::SetMeasureTablatureChange(_) => "Set Measure Tablature Change".to_string(),
1542        Command::UpsertScoreView(_) => "Update Score View".to_string(),
1543        Command::RemoveScoreView(_) => "Remove Score View".to_string(),
1544        Command::SetTranspose(_) => "Set Transpose".to_string(),
1545        Command::TransposeStaffRegion(_) => "Transpose Staff Region".to_string(),
1546        Command::SetTempoAtMeasure(_) => "Set Tempo".to_string(),
1547        Command::SetTempoRampAtMeasure(_) => "Set Tempo Ramp".to_string(),
1548        Command::PasteVoice(_) => "Paste Voice".to_string(),
1549        Command::PasteRange(_) => "Paste Range".to_string(),
1550        Command::PasteScoreFragment(_) => "Paste Score Fragment".to_string(),
1551        Command::ExchangeVoices(_) => "Exchange Voices".to_string(),
1552        Command::MoveOrCopyVoiceRange(c) => if c.move_source {
1553            "Move Voice Range"
1554        } else {
1555            "Copy Voice Range"
1556        }
1557        .to_string(),
1558        Command::SplitMeasure(_) => "Split Measure".to_string(),
1559        Command::JoinMeasures(_) => "Join Measures".to_string(),
1560        Command::ImplodeStaves(_) => "Implode Staves".to_string(),
1561        Command::ExplodeVoices(_) => "Explode Voices".to_string(),
1562        Command::ExplodeChordPitches(_) => "Explode Chord Pitches".to_string(),
1563        Command::ScaleVoiceRange(c) => match c.scale {
1564            DurationScale::Half => "Halve Voice Durations",
1565            DurationScale::Double => "Double Voice Durations",
1566        }
1567        .to_string(),
1568        Command::SetSystemBreak(_) => "Set System Break".to_string(),
1569        Command::SetPageBreak(_) => "Set Page Break".to_string(),
1570        Command::SetSectionBreak(_) => "Set Section Break".to_string(),
1571        Command::ToggleSlur(_) => "Toggle Slur".to_string(),
1572        Command::AddStaff(_) => "Add Staff".to_string(),
1573        Command::DeleteStaff(_) => "Delete Staff".to_string(),
1574        Command::SetTuplet(c) => if c.tuplet.is_some() {
1575            "Set Tuplet"
1576        } else {
1577            "Clear Tuplet"
1578        }
1579        .to_string(),
1580        Command::SetInstrumentId(_) => "Set Note Instrument".to_string(),
1581        Command::SetNotePlacement(_) => "Set Note Placement".to_string(),
1582        Command::SetUnpitched(c) => if c.is_unpitched {
1583            "Set Unpitched Note"
1584        } else {
1585            "Clear Unpitched Note"
1586        }
1587        .to_string(),
1588        Command::RespellScore(c) => if c.prefer_flat {
1589            "Respell Score (flat)"
1590        } else {
1591            "Respell Score (sharp)"
1592        }
1593        .to_string(),
1594        Command::RespellScoreToKey(_) => "Respell Score to Key".to_string(),
1595        Command::RespellStaffRegion(c) => match c.policy {
1596            RespellPolicy::Flat => "Respell Pitches (flat)",
1597            RespellPolicy::Sharp => "Respell Pitches (sharp)",
1598            RespellPolicy::Key => "Respell Pitches to Key",
1599        }
1600        .to_string(),
1601        Command::CycleEnharmonicSpelling(_) => "Change Enharmonic Spelling".to_string(),
1602        Command::ResequenceRehearsalMarks(_) => "Resequence Rehearsal Marks".to_string(),
1603        Command::SetSystemBreakInterval(c) => {
1604            if c.interval == 0 {
1605                "Remove System Breaks".to_string()
1606            } else {
1607                format!("System Break Every {} Measures", c.interval)
1608            }
1609        }
1610        Command::RemoveTrailingEmptyMeasures(_) => "Remove Empty Trailing Measures".to_string(),
1611        Command::SetStem(_) => "Set Stem".to_string(),
1612        Command::SetArpeggio(_) => "Set Arpeggio".to_string(),
1613        Command::SetTechniqueText(_) => "Set Technique Text".to_string(),
1614        Command::SetFingering(_) => "Set Fingering".to_string(),
1615        Command::SetFingerings(_) => "Set Fingering Candidates".to_string(),
1616        Command::SetStringNumber(_) => "Set String Number".to_string(),
1617        Command::SetTabPosition(_) => "Set Tablature Position".to_string(),
1618        Command::SetTablatureConfig(_) => "Set Tablature Configuration".to_string(),
1619        Command::SetStaffPresentation(_) => "Set Staff Presentation".to_string(),
1620        Command::SetGuitarTechnique(_) => "Set Guitar Technique".to_string(),
1621        Command::SetGuitarBendAlter(_) => "Set Guitar Bend Alter".to_string(),
1622        Command::SetGuitarBendCurve(_) => "Set Guitar Bend Curve".to_string(),
1623        Command::SetNoteHead(_) => "Set Note Head".to_string(),
1624        Command::SetCue(c) => if c.is_cue {
1625            "Set Cue Note"
1626        } else {
1627            "Clear Cue Note"
1628        }
1629        .to_string(),
1630        Command::SetExpressionText(_) => "Set Expression Text".to_string(),
1631        Command::SetMeasureText(c) => match c.text {
1632            Some(_) => "Set Measure Text",
1633            None => "Remove Measure Text",
1634        }
1635        .to_string(),
1636        Command::SetScoreText(c) => match c.text {
1637            Some(_) => "Set Score Text",
1638            None => "Remove Score Text",
1639        }
1640        .to_string(),
1641        Command::SetScoreStyleOverrides(_) => "Set Score Style Defaults".to_string(),
1642        Command::SetObjectStyleOverrides(_) => "Set Object Style Overrides".to_string(),
1643        Command::ToggleTrillLine(_) => "Toggle Trill Line".to_string(),
1644        Command::SetGlissando(_) => "Set Glissando".to_string(),
1645        Command::SetCrossStaff(_) => "Set Cross-Staff Placement".to_string(),
1646        Command::SetPartGroup(_) => "Set Part Group".to_string(),
1647        Command::AddSpanner(_) => "Add Notation Spanner".to_string(),
1648        Command::UpdateSpanner(_) => "Update Notation Spanner".to_string(),
1649        Command::RemoveSpanner(_) => "Remove Notation Spanner".to_string(),
1650        Command::Batch(c) => c.label.clone().unwrap_or_else(|| {
1651            c.commands
1652                .first()
1653                .map(command_label)
1654                .unwrap_or_else(|| "Batch".to_string())
1655        }),
1656    }
1657}
1658
1659/// Stable i18n key for an undoable command — camelCase variant name.
1660///
1661/// Use this instead of [`command_label`] when the UI translates labels itself.
1662pub fn command_key(cmd: &Command) -> String {
1663    match cmd {
1664        Command::AddNote(_) => "AddNote".to_string(),
1665        Command::AddPitch(_) => "AddPitch".to_string(),
1666        Command::SetDuration(_) => "SetDuration".to_string(),
1667        Command::DeleteNote(_) => "DeleteNote".to_string(),
1668        Command::AddMeasure(_) => "AddMeasure".to_string(),
1669        Command::DeleteMeasure(_) => "DeleteMeasure".to_string(),
1670        Command::SetTempo(_) => "SetTempo".to_string(),
1671        Command::NewScore(_) => "NewScore".to_string(),
1672        Command::AddHairpin(_) => "AddHairpin".to_string(),
1673        Command::ToggleTie(_) => "ToggleTie".to_string(),
1674        Command::SetDynamic(_) => "SetDynamic".to_string(),
1675        Command::ToggleArticulation(_) => "ToggleArticulation".to_string(),
1676        Command::SetKeySignature(_) => "SetKeySignature".to_string(),
1677        Command::SetTimeSignature(_) => "SetTimeSignature".to_string(),
1678        Command::SetBarline(_) => "SetBarline".to_string(),
1679        Command::AddPart(_) => "AddPart".to_string(),
1680        Command::DeletePart(_) => "DeletePart".to_string(),
1681        Command::ReorderParts(_) => "ReorderParts".to_string(),
1682        Command::SetMetadata(_) => "SetMetadata".to_string(),
1683        Command::SetRehearsalMark(_) => "SetRehearsalMark".to_string(),
1684        Command::SetNavigationMark(_) => "SetNavigationMark".to_string(),
1685        Command::SetChordSymbol(_) => "SetChordSymbol".to_string(),
1686        Command::SetHarmonyRange(_) => "SetHarmonyRange".to_string(),
1687        Command::SetFiguredBass(_) => "SetFiguredBass".to_string(),
1688        Command::SetHarpPedalDiagrams(_) => "SetHarpPedalDiagrams".to_string(),
1689        Command::SetGrace(_) => "SetGrace".to_string(),
1690        Command::SetOttava(_) => "SetOttava".to_string(),
1691        Command::SetLyric(_) => "SetLyric".to_string(),
1692        Command::SetMultiRest(_) => "SetMultiRest".to_string(),
1693        Command::AddPedal(_) => "AddPedal".to_string(),
1694        Command::SetVolta(_) => "SetVolta".to_string(),
1695        Command::SetClef(_) => "SetClef".to_string(),
1696        Command::SetPartName(_) => "SetPartName".to_string(),
1697        Command::SetMidiInstrument(_) => "SetMidiInstrument".to_string(),
1698        Command::SetPercussionKit(_) => "SetPercussionKit".to_string(),
1699        Command::SetInstrumentDefinition(_) => "SetInstrumentDefinition".to_string(),
1700        Command::SetMeasureInstrumentChange(_) => "SetMeasureInstrumentChange".to_string(),
1701        Command::SetMeasureTablatureChange(_) => "SetMeasureTablatureChange".to_string(),
1702        Command::UpsertScoreView(_) => "UpsertScoreView".to_string(),
1703        Command::RemoveScoreView(_) => "RemoveScoreView".to_string(),
1704        Command::SetTranspose(_) => "SetTranspose".to_string(),
1705        Command::TransposeStaffRegion(_) => "TransposeStaffRegion".to_string(),
1706        Command::SetTempoAtMeasure(_) => "SetTempoAtMeasure".to_string(),
1707        Command::SetTempoRampAtMeasure(_) => "SetTempoRampAtMeasure".to_string(),
1708        Command::PasteVoice(_) => "PasteVoice".to_string(),
1709        Command::PasteRange(_) => "PasteRange".to_string(),
1710        Command::PasteScoreFragment(_) => "PasteScoreFragment".to_string(),
1711        Command::ExchangeVoices(_) => "ExchangeVoices".to_string(),
1712        Command::MoveOrCopyVoiceRange(_) => "MoveOrCopyVoiceRange".to_string(),
1713        Command::SplitMeasure(_) => "SplitMeasure".to_string(),
1714        Command::JoinMeasures(_) => "JoinMeasures".to_string(),
1715        Command::ImplodeStaves(_) => "ImplodeStaves".to_string(),
1716        Command::ExplodeVoices(_) => "ExplodeVoices".to_string(),
1717        Command::ExplodeChordPitches(_) => "ExplodeChordPitches".to_string(),
1718        Command::ScaleVoiceRange(_) => "ScaleVoiceRange".to_string(),
1719        Command::SetSystemBreak(_) => "SetSystemBreak".to_string(),
1720        Command::SetPageBreak(_) => "SetPageBreak".to_string(),
1721        Command::SetSectionBreak(_) => "SetSectionBreak".to_string(),
1722        Command::ToggleSlur(_) => "ToggleSlur".to_string(),
1723        Command::AddStaff(_) => "AddStaff".to_string(),
1724        Command::DeleteStaff(_) => "DeleteStaff".to_string(),
1725        Command::SetTuplet(_) => "SetTuplet".to_string(),
1726        Command::RespellScore(_) => "RespellScore".to_string(),
1727        Command::RespellScoreToKey(_) => "RespellScoreToKey".to_string(),
1728        Command::RespellStaffRegion(_) => "RespellStaffRegion".to_string(),
1729        Command::CycleEnharmonicSpelling(_) => "CycleEnharmonicSpelling".to_string(),
1730        Command::ResequenceRehearsalMarks(_) => "ResequenceRehearsalMarks".to_string(),
1731        Command::SetSystemBreakInterval(_) => "SetSystemBreakInterval".to_string(),
1732        Command::RemoveTrailingEmptyMeasures(_) => "RemoveTrailingEmptyMeasures".to_string(),
1733        Command::SetStem(_) => "SetStem".to_string(),
1734        Command::SetArpeggio(_) => "SetArpeggio".to_string(),
1735        Command::SetTechniqueText(_) => "SetTechniqueText".to_string(),
1736        Command::SetFingering(_) => "SetFingering".to_string(),
1737        Command::SetFingerings(_) => "SetFingerings".to_string(),
1738        Command::SetStringNumber(_) => "SetStringNumber".to_string(),
1739        Command::SetTabPosition(_) => "SetTabPosition".to_string(),
1740        Command::SetTablatureConfig(_) => "SetTablatureConfig".to_string(),
1741        Command::SetStaffPresentation(_) => "SetStaffPresentation".to_string(),
1742        Command::SetGuitarTechnique(_) => "SetGuitarTechnique".to_string(),
1743        Command::SetGuitarBendAlter(_) => "SetGuitarBendAlter".to_string(),
1744        Command::SetGuitarBendCurve(_) => "SetGuitarBendCurve".to_string(),
1745        Command::SetNoteHead(_) => "SetNoteHead".to_string(),
1746        Command::SetCue(_) => "SetCue".to_string(),
1747        Command::SetUnpitched(_) => "SetUnpitched".to_string(),
1748        Command::SetInstrumentId(_) => "SetInstrumentId".to_string(),
1749        Command::SetNotePlacement(_) => "SetNotePlacement".to_string(),
1750        Command::SetExpressionText(_) => "SetExpressionText".to_string(),
1751        Command::SetMeasureText(_) => "SetMeasureText".to_string(),
1752        Command::SetScoreText(_) => "SetScoreText".to_string(),
1753        Command::SetScoreStyleOverrides(_) => "SetScoreStyleOverrides".to_string(),
1754        Command::SetObjectStyleOverrides(_) => "SetObjectStyleOverrides".to_string(),
1755        Command::ToggleTrillLine(_) => "ToggleTrillLine".to_string(),
1756        Command::SetGlissando(_) => "SetGlissando".to_string(),
1757        Command::SetCrossStaff(_) => "SetCrossStaff".to_string(),
1758        Command::SetPartGroup(_) => "SetPartGroup".to_string(),
1759        Command::AddSpanner(_) => "AddSpanner".to_string(),
1760        Command::UpdateSpanner(_) => "UpdateSpanner".to_string(),
1761        Command::RemoveSpanner(_) => "RemoveSpanner".to_string(),
1762        Command::Batch(c) => c.label.clone().unwrap_or_else(|| "Batch".to_string()),
1763    }
1764}
1765
1766pub fn apply_command(cmd: &Command, score: &mut Score) -> Result<(), Error> {
1767    match cmd {
1768        Command::AddNote(c) => apply_add_note(c, score),
1769        Command::AddPitch(c) => apply_add_pitch(c, score),
1770        Command::SetDuration(c) => apply_set_duration(c, score),
1771        Command::DeleteNote(c) => apply_delete_note(c, score),
1772        Command::AddMeasure(c) => apply_add_measure(c, score),
1773        Command::DeleteMeasure(c) => apply_delete_measure(c, score),
1774        Command::SetTempo(c) => {
1775            score.settings.tempo_bpm = c.bpm;
1776            Ok(())
1777        }
1778        Command::NewScore(c) => {
1779            let mut s = match c.template {
1780                Some(kind) => Score::template(kind),
1781                None => Score::new(
1782                    &c.title,
1783                    c.tempo_bpm,
1784                    c.time_numerator,
1785                    c.time_denominator,
1786                    c.key_fifths,
1787                    c.measure_count,
1788                ),
1789            };
1790            if c.template.is_some() {
1791                s.metadata.title = c.title.clone();
1792                s.metadata.composer = c.composer.clone();
1793                s.settings.tempo_bpm = c.tempo_bpm;
1794                s.settings.time_signature = TimeSignature {
1795                    numerator: c.time_numerator,
1796                    denominator: c.time_denominator,
1797                };
1798                s.settings.key_signature = KeySignature {
1799                    fifths: c.key_fifths,
1800                    mode: "major".to_string(),
1801                };
1802                for part in &mut s.parts {
1803                    for staff in &mut part.staves {
1804                        staff.measures.clear();
1805                        for i in 0..c.measure_count {
1806                            let mut m = Measure::empty(c.time_numerator, c.time_denominator);
1807                            m.number = i + 1;
1808                            staff.measures.push(m);
1809                        }
1810                    }
1811                }
1812            }
1813            *score = s;
1814            Ok(())
1815        }
1816        Command::AddHairpin(c) => apply_add_hairpin(c, score),
1817        Command::ToggleTie(c) => apply_toggle_tie(c, score),
1818        Command::SetDynamic(c) => apply_set_dynamic(c, score),
1819        Command::ToggleArticulation(c) => apply_toggle_articulation(c, score),
1820        Command::SetKeySignature(c) => {
1821            score.settings.key_signature = KeySignature {
1822                fifths: c.fifths,
1823                mode: "major".to_string(),
1824            };
1825            Ok(())
1826        }
1827        Command::SetTimeSignature(c) => apply_set_time_signature(c, score),
1828        Command::SetBarline(c) => apply_set_barline(c, score),
1829        Command::AddPart(c) => apply_add_part(c, score),
1830        Command::DeletePart(c) => apply_delete_part(c, score),
1831        Command::ReorderParts(c) => apply_reorder_parts(c, score),
1832        Command::SetMetadata(c) => apply_set_metadata(c, score),
1833        Command::SetRehearsalMark(c) => {
1834            for_each_measure_at(score, c.measure_index, |m| {
1835                m.rehearsal = c.text.clone();
1836            });
1837            Ok(())
1838        }
1839        Command::SetNavigationMark(c) => {
1840            for_each_measure_at(score, c.measure_index, |m| {
1841                m.navigation = c.mark.clone();
1842            });
1843            Ok(())
1844        }
1845        Command::SetChordSymbol(c) => {
1846            get_note_mut(
1847                score,
1848                c.part_index,
1849                c.staff_index,
1850                c.measure_index,
1851                c.voice,
1852                c.note_index,
1853            )?
1854            .chord_symbol = c.chord.clone();
1855            Ok(())
1856        }
1857        Command::SetHarmonyRange(c) => {
1858            if let Some(end) = &c.end
1859                && !score
1860                    .parts
1861                    .get(end.part)
1862                    .and_then(|part| part.staves.get(end.staff))
1863                    .and_then(|staff| staff.measures.get(end.measure))
1864                    .and_then(|measure| measure.voices.get(end.voice))
1865                    .and_then(|voice| voice.get(end.note))
1866                    .is_some()
1867            {
1868                return Err(Error::InvalidCommand(
1869                    "harmony range end does not point to an existing note".into(),
1870                ));
1871            }
1872            let note = get_note_mut(
1873                score,
1874                c.part_index,
1875                c.staff_index,
1876                c.measure_index,
1877                c.voice,
1878                c.note_index,
1879            )?;
1880            let chord = note.chord_symbol.as_mut().ok_or_else(|| {
1881                Error::InvalidCommand("cannot set a harmony range without a chord symbol".into())
1882            })?;
1883            chord.range_end = c.end.clone();
1884            Ok(())
1885        }
1886        Command::SetFiguredBass(c) => {
1887            for part in &mut score.parts {
1888                for staff in &mut part.staves {
1889                    if let Some(measure) = staff.measures.get_mut(c.measure_index) {
1890                        measure.figured_bass = c.figures.clone();
1891                    }
1892                }
1893            }
1894            Ok(())
1895        }
1896        Command::SetHarpPedalDiagrams(c) => {
1897            let measure = score
1898                .parts
1899                .get_mut(c.part_index)
1900                .ok_or(Error::PartNotFound(c.part_index))?
1901                .staves
1902                .get_mut(c.staff_index)
1903                .ok_or(Error::StaffNotFound(c.staff_index))?
1904                .measures
1905                .get_mut(c.measure_index)
1906                .ok_or(Error::MeasureNotFound(c.measure_index))?;
1907            measure.harp_pedal_diagrams = c.diagrams.clone();
1908            Ok(())
1909        }
1910        Command::SetGrace(c) => {
1911            let note = get_note_mut(
1912                score,
1913                c.part_index,
1914                c.staff_index,
1915                c.measure_index,
1916                c.voice,
1917                c.note_index,
1918            )?;
1919            if note.is_rest {
1920                return Err(Error::InvalidCommand(
1921                    "cannot make a rest into a grace note".into(),
1922                ));
1923            }
1924            note.is_grace = c.is_grace;
1925            note.grace_slash = c.slash;
1926            Ok(())
1927        }
1928        Command::SetOttava(c) => {
1929            let note = get_note_mut(
1930                score,
1931                c.part_index,
1932                c.staff_index,
1933                c.measure_index,
1934                c.voice,
1935                c.note_index,
1936            )?;
1937            note.ottava_start = c.ottava_start;
1938            note.ottava_end = c.ottava_end;
1939            Ok(())
1940        }
1941        Command::SetLyric(c) => {
1942            let note = get_note_mut(
1943                score,
1944                c.part_index,
1945                c.staff_index,
1946                c.measure_index,
1947                c.voice,
1948                c.note_index,
1949            )?;
1950            match c.verse.unwrap_or(1) {
1951                0 => Err(Error::InvalidCommand(
1952                    "lyric verse numbers start at 1".into(),
1953                )),
1954                1 => {
1955                    note.lyric = c.lyric.clone();
1956                    Ok(())
1957                }
1958                verse if verse > VerseLyric::MAX_VERSE => Err(Error::InvalidCommand(format!(
1959                    "lyric verse {verse} exceeds {}",
1960                    VerseLyric::MAX_VERSE
1961                ))),
1962                verse => {
1963                    note.additional_lyrics.retain(|entry| entry.verse != verse);
1964                    if let Some(lyric) = &c.lyric {
1965                        note.additional_lyrics.push(VerseLyric {
1966                            verse,
1967                            lyric: lyric.clone(),
1968                        });
1969                        note.additional_lyrics.sort_by_key(|entry| entry.verse);
1970                    }
1971                    Ok(())
1972                }
1973            }
1974        }
1975        Command::SetMultiRest(c) => {
1976            for_each_measure_at(score, c.measure_index, |m| {
1977                m.multi_rest_count = c.count;
1978            });
1979            Ok(())
1980        }
1981        Command::AddPedal(c) => apply_add_pedal(c, score),
1982        Command::SetVolta(c) => {
1983            for_each_measure_at(score, c.measure_index, |m| {
1984                m.volta = c.volta.clone();
1985            });
1986            Ok(())
1987        }
1988        Command::SetClef(c) => apply_set_clef(c, score),
1989        Command::SetPartName(c) => apply_set_part_name(c, score),
1990        Command::SetMidiInstrument(c) => apply_set_midi_instrument(c, score),
1991        Command::SetPercussionKit(c) => apply_set_percussion_kit(c, score),
1992        Command::SetInstrumentDefinition(c) => apply_set_instrument_definition(c, score),
1993        Command::SetMeasureInstrumentChange(c) => apply_set_measure_instrument_change(c, score),
1994        Command::SetMeasureTablatureChange(c) => apply_set_measure_tablature_change(c, score),
1995        Command::UpsertScoreView(c) => apply_upsert_score_view(c, score),
1996        Command::RemoveScoreView(c) => apply_remove_score_view(c, score),
1997        Command::SetTranspose(c) => apply_set_transpose(c, score),
1998        Command::TransposeStaffRegion(c) => apply_transpose_staff_region(c, score),
1999        Command::SetTempoAtMeasure(c) => {
2000            for_each_measure_at(score, c.measure_index, |m| {
2001                m.tempo = c.bpm;
2002            });
2003            Ok(())
2004        }
2005        Command::SetTempoRampAtMeasure(c) => {
2006            for_each_measure_at(score, c.measure_index, |m| {
2007                m.tempo_ramp_to = c.target_bpm;
2008            });
2009            Ok(())
2010        }
2011        Command::PasteVoice(c) => apply_paste_voice(c, score),
2012        Command::PasteRange(c) => apply_paste_range(c, score),
2013        Command::PasteScoreFragment(c) => apply_paste_score_fragment(c, score),
2014        Command::ExchangeVoices(c) => apply_exchange_voices(c, score),
2015        Command::MoveOrCopyVoiceRange(c) => apply_move_or_copy_voice_range(c, score),
2016        Command::SplitMeasure(c) => apply_split_measure(c, score),
2017        Command::JoinMeasures(c) => apply_join_measures(c, score),
2018        Command::ImplodeStaves(c) => apply_implode_staves(c, score),
2019        Command::ExplodeVoices(c) => apply_explode_voices(c, score),
2020        Command::ExplodeChordPitches(c) => apply_explode_chord_pitches(c, score),
2021        Command::ScaleVoiceRange(c) => apply_scale_voice_range(c, score),
2022        Command::SetSystemBreak(c) => {
2023            for_each_measure_at(score, c.measure_index, |m| {
2024                m.system_break = c.value;
2025            });
2026            Ok(())
2027        }
2028        Command::SetPageBreak(c) => {
2029            for_each_measure_at(score, c.measure_index, |m| {
2030                m.page_break = c.value;
2031            });
2032            Ok(())
2033        }
2034        Command::SetSectionBreak(c) => {
2035            if c.measure_index >= score.measure_count() {
2036                return Err(Error::MeasureNotFound(c.measure_index));
2037            }
2038            for_each_measure_at(score, c.measure_index, |m| {
2039                m.section_break = c.value;
2040            });
2041            Ok(())
2042        }
2043        Command::ToggleSlur(c) => apply_toggle_slur(c, score),
2044        Command::AddStaff(c) => apply_add_staff(c, score),
2045        Command::DeleteStaff(c) => apply_delete_staff(c, score),
2046        Command::SetTuplet(c) => {
2047            get_note_mut(
2048                score,
2049                c.part_index,
2050                c.staff_index,
2051                c.measure_index,
2052                c.voice_index,
2053                c.note_index,
2054            )?
2055            .tuplet = c.tuplet.clone();
2056            Ok(())
2057        }
2058        Command::RespellScore(c) => {
2059            respell_score(score, c.prefer_flat);
2060            Ok(())
2061        }
2062        Command::RespellScoreToKey(_) => {
2063            respell_score_to_key(score);
2064            Ok(())
2065        }
2066        Command::CycleEnharmonicSpelling(c) => {
2067            let note = get_note_mut(
2068                score,
2069                c.part_index,
2070                c.staff_index,
2071                c.measure_index,
2072                c.voice,
2073                c.note_index,
2074            )?;
2075            if note.is_rest || note.is_unpitched || note.pitches.is_empty() {
2076                return Err(Error::InvalidCommand(
2077                    "enharmonic spelling requires a pitched note".into(),
2078                ));
2079            }
2080            match c.pitch_index {
2081                Some(index) => {
2082                    let pitch = note.pitches.get_mut(index).ok_or_else(|| {
2083                        Error::InvalidCommand(format!("pitch index {index} out of range"))
2084                    })?;
2085                    *pitch = pitch.next_enharmonic();
2086                }
2087                None => {
2088                    for pitch in &mut note.pitches {
2089                        *pitch = pitch.next_enharmonic();
2090                    }
2091                }
2092            }
2093            Ok(())
2094        }
2095        Command::ResequenceRehearsalMarks(c) => apply_resequence_rehearsal_marks(c, score),
2096        Command::SetSystemBreakInterval(c) => apply_system_break_interval(c, score),
2097        Command::RemoveTrailingEmptyMeasures(_) => apply_remove_trailing_empty_measures(score),
2098        Command::RespellStaffRegion(c) => {
2099            respell_staff_region(
2100                score,
2101                c.part_index,
2102                c.staff_index,
2103                c.start_measure,
2104                c.end_measure,
2105                c.policy,
2106            )?;
2107            Ok(())
2108        }
2109        Command::SetStem(c) => {
2110            get_note_mut(
2111                score,
2112                c.part_index,
2113                c.staff_index,
2114                c.measure_index,
2115                c.voice_index,
2116                c.note_index,
2117            )?
2118            .stem_up = c.stem_up;
2119            Ok(())
2120        }
2121        Command::SetArpeggio(c) => {
2122            get_note_mut(
2123                score,
2124                c.part_index,
2125                c.staff_index,
2126                c.measure_index,
2127                c.voice_index,
2128                c.note_index,
2129            )?
2130            .arpeggiate = c.direction;
2131            Ok(())
2132        }
2133        Command::SetTechniqueText(c) => {
2134            get_note_mut(
2135                score,
2136                c.part_index,
2137                c.staff_index,
2138                c.measure_index,
2139                c.voice,
2140                c.note_index,
2141            )?
2142            .technique_text = c.text.clone();
2143            Ok(())
2144        }
2145        Command::SetGlissando(c) => {
2146            let note = get_note_mut(
2147                score,
2148                c.part_index,
2149                c.staff_index,
2150                c.measure_index,
2151                c.voice,
2152                c.note_index,
2153            )?;
2154            note.glissando_start = c.start;
2155            note.glissando_end = c.end;
2156            Ok(())
2157        }
2158        Command::SetCrossStaff(c) => {
2159            let staff_count = score
2160                .parts
2161                .get(c.part_index)
2162                .ok_or(Error::PartNotFound(c.part_index))?
2163                .staves
2164                .len();
2165            let note = get_note_mut(
2166                score,
2167                c.part_index,
2168                c.staff_index,
2169                c.measure_index,
2170                c.voice,
2171                c.note_index,
2172            )?;
2173            if let Some(ref placement) = c.placement
2174                && placement.target_staff == c.staff_index
2175            {
2176                return Err(Error::InvalidCommand(
2177                    "cross-staff target must differ from source staff".into(),
2178                ));
2179            }
2180            if let Some(ref placement) = c.placement
2181                && placement.target_staff >= staff_count
2182            {
2183                return Err(Error::StaffNotFound(placement.target_staff));
2184            }
2185            note.cross_staff = c.placement.clone();
2186            Ok(())
2187        }
2188        Command::SetFingering(c) => {
2189            let note = get_note_mut(
2190                score,
2191                c.part_index,
2192                c.staff_index,
2193                c.measure_index,
2194                c.voice,
2195                c.note_index,
2196            )?;
2197            note.fingering = c.fingering;
2198            note.fingerings = c.fingering.into_iter().collect();
2199            Ok(())
2200        }
2201        Command::SetFingerings(c) => {
2202            let note = get_note_mut(
2203                score,
2204                c.part_index,
2205                c.staff_index,
2206                c.measure_index,
2207                c.voice,
2208                c.note_index,
2209            )?;
2210            note.fingerings = c.fingerings.clone();
2211            note.fingering = note.fingerings.first().copied();
2212            Ok(())
2213        }
2214        Command::SetStringNumber(c) => {
2215            get_note_mut(
2216                score,
2217                c.part_index,
2218                c.staff_index,
2219                c.measure_index,
2220                c.voice,
2221                c.note_index,
2222            )?
2223            .string_number = c.string_number;
2224            Ok(())
2225        }
2226        Command::SetTabPosition(c) => {
2227            let note = get_note_mut(
2228                score,
2229                c.part_index,
2230                c.staff_index,
2231                c.measure_index,
2232                c.voice,
2233                c.note_index,
2234            )?;
2235            note.string_number = c.position.as_ref().map(|position| position.string);
2236            note.tab_position = c.position.clone();
2237            note.tab_positions = c.position.iter().cloned().collect();
2238            Ok(())
2239        }
2240        Command::SetGuitarTechnique(c) => {
2241            get_note_mut(
2242                score,
2243                c.part_index,
2244                c.staff_index,
2245                c.measure_index,
2246                c.voice,
2247                c.note_index,
2248            )?
2249            .guitar_technique = c.technique.clone();
2250            Ok(())
2251        }
2252        Command::SetGuitarBendAlter(c) => {
2253            get_note_mut(
2254                score,
2255                c.part_index,
2256                c.staff_index,
2257                c.measure_index,
2258                c.voice,
2259                c.note_index,
2260            )?
2261            .guitar_bend_alter_cents = c.alter_cents;
2262            Ok(())
2263        }
2264        Command::SetGuitarBendCurve(c) => {
2265            get_note_mut(
2266                score,
2267                c.part_index,
2268                c.staff_index,
2269                c.measure_index,
2270                c.voice,
2271                c.note_index,
2272            )?
2273            .guitar_bend_curve = c.points.clone();
2274            Ok(())
2275        }
2276        Command::SetNoteHead(c) => {
2277            get_note_mut(
2278                score,
2279                c.part_index,
2280                c.staff_index,
2281                c.measure_index,
2282                c.voice,
2283                c.note_index,
2284            )?
2285            .note_head = c.note_head.clone();
2286            Ok(())
2287        }
2288        Command::SetCue(c) => {
2289            get_note_mut(
2290                score,
2291                c.part_index,
2292                c.staff_index,
2293                c.measure_index,
2294                c.voice,
2295                c.note_index,
2296            )?
2297            .is_cue = c.is_cue;
2298            Ok(())
2299        }
2300        Command::SetUnpitched(c) => {
2301            get_note_mut(
2302                score,
2303                c.part_index,
2304                c.staff_index,
2305                c.measure_index,
2306                c.voice,
2307                c.note_index,
2308            )?
2309            .is_unpitched = c.is_unpitched;
2310            Ok(())
2311        }
2312        Command::SetInstrumentId(c) => {
2313            get_note_mut(
2314                score,
2315                c.part_index,
2316                c.staff_index,
2317                c.measure_index,
2318                c.voice,
2319                c.note_index,
2320            )?
2321            .instrument_id = c.instrument_id.clone();
2322            Ok(())
2323        }
2324        Command::SetNotePlacement(c) => {
2325            let note = get_note_mut(
2326                score,
2327                c.part_index,
2328                c.staff_index,
2329                c.measure_index,
2330                c.voice,
2331                c.note_index,
2332            )?;
2333            for value in [c.offset_x, c.offset_y, c.relative_x, c.relative_y] {
2334                if value.is_some_and(|value| !value.is_finite()) {
2335                    return Err(Error::InvalidCommand(
2336                        "note placement offsets must be finite".into(),
2337                    ));
2338                }
2339            }
2340            note.offset_x = c.offset_x;
2341            note.offset_y = c.offset_y;
2342            note.relative_x = c.relative_x;
2343            note.relative_y = c.relative_y;
2344            Ok(())
2345        }
2346        Command::SetExpressionText(c) => {
2347            for_each_measure_at(score, c.measure_index, |m| {
2348                m.expression_text = c.text.clone();
2349            });
2350            Ok(())
2351        }
2352        Command::SetMeasureText(c) => apply_set_measure_text(c, score),
2353        Command::SetScoreText(c) => apply_set_score_text(c, score),
2354        Command::SetScoreStyleOverrides(c) => apply_set_score_style_overrides(c, score),
2355        Command::SetObjectStyleOverrides(c) => apply_set_object_style_overrides(c, score),
2356        Command::SetTablatureConfig(c) => apply_set_tablature_config(c, score),
2357        Command::SetStaffPresentation(c) => apply_set_staff_presentation(c, score),
2358        Command::ToggleTrillLine(c) => apply_toggle_trill_line(c, score),
2359        Command::SetPartGroup(c) => {
2360            if let Some(group) = &c.group {
2361                score
2362                    .part_groups
2363                    .retain(|g| g.first_part != group.first_part || g.last_part != group.last_part);
2364                score.part_groups.push(group.clone());
2365            } else {
2366                // When None, the command carries no range info so we clear all groups.
2367                score.part_groups.clear();
2368            }
2369            Ok(())
2370        }
2371        Command::AddSpanner(c) => {
2372            if score
2373                .spanners
2374                .iter()
2375                .any(|spanner| spanner.id == c.spanner.id)
2376            {
2377                return Err(Error::InvalidCommand(format!(
2378                    "notation spanner id already exists: {}",
2379                    c.spanner.id
2380                )));
2381            }
2382            score.spanners.push(c.spanner.clone());
2383            Ok(())
2384        }
2385        Command::UpdateSpanner(c) => {
2386            let index = score
2387                .spanners
2388                .iter()
2389                .position(|spanner| spanner.id == c.spanner.id)
2390                .ok_or_else(|| {
2391                    Error::InvalidCommand(format!(
2392                        "notation spanner id does not exist: {}",
2393                        c.spanner.id
2394                    ))
2395                })?;
2396            let previous = score.spanners[index].clone();
2397            score.spanners[index] = c.spanner.clone();
2398            clear_legacy_spanner_endpoints(score, &previous);
2399            clear_legacy_spanner_endpoints(score, &c.spanner);
2400            Ok(())
2401        }
2402        Command::RemoveSpanner(c) => {
2403            let index = score
2404                .spanners
2405                .iter()
2406                .position(|spanner| spanner.id == c.id)
2407                .ok_or_else(|| {
2408                    Error::InvalidCommand(format!("notation spanner id does not exist: {}", c.id))
2409                })?;
2410            let removed = score.spanners.remove(index);
2411            clear_legacy_spanner_endpoints(score, &removed);
2412            Ok(())
2413        }
2414        Command::Batch(c) => {
2415            for cmd in &c.commands {
2416                apply_command(cmd, score)?;
2417            }
2418            Ok(())
2419        }
2420    }
2421}
2422
2423// ── helpers ──────────────────────────────────────────────────────────────────
2424
2425fn apply_set_tablature_config(cmd: &SetTablatureConfigCmd, score: &mut Score) -> Result<(), Error> {
2426    let staff = score
2427        .parts
2428        .get_mut(cmd.part_index)
2429        .ok_or(Error::PartNotFound(cmd.part_index))?
2430        .staves
2431        .get_mut(cmd.staff_index)
2432        .ok_or_else(|| Error::InvalidCommand(format!("staff {} out of range", cmd.staff_index)))?;
2433    staff.tablature = cmd.config.clone();
2434    if staff.tablature.is_some() {
2435        staff.presentation.kind = StaffKind::Tablature;
2436    } else if staff.presentation.kind == StaffKind::Tablature {
2437        staff.presentation.kind = StaffKind::Standard;
2438    }
2439    if staff.tablature.is_none() {
2440        for measure in &mut staff.measures {
2441            measure.tablature_change = None;
2442        }
2443    }
2444    Ok(())
2445}
2446
2447fn apply_set_staff_presentation(
2448    cmd: &SetStaffPresentationCmd,
2449    score: &mut Score,
2450) -> Result<(), Error> {
2451    if !(1..=64).contains(&cmd.presentation.lines) {
2452        return Err(Error::InvalidCommand(format!(
2453            "staff line count {} is outside 1..=64",
2454            cmd.presentation.lines
2455        )));
2456    }
2457    if !cmd.presentation.line_distance.is_finite()
2458        || !(0.1..=16.0).contains(&cmd.presentation.line_distance)
2459    {
2460        return Err(Error::InvalidCommand(
2461            "staff line distance must be finite and within 0.1..=16.0".into(),
2462        ));
2463    }
2464    let staff = score
2465        .parts
2466        .get_mut(cmd.part_index)
2467        .ok_or(Error::PartNotFound(cmd.part_index))?
2468        .staves
2469        .get_mut(cmd.staff_index)
2470        .ok_or_else(|| Error::InvalidCommand(format!("staff {} out of range", cmd.staff_index)))?;
2471    if cmd.presentation.kind == StaffKind::Tablature && staff.tablature.is_none() {
2472        return Err(Error::InvalidCommand(
2473            "tablature staff presentation requires a tablature configuration".into(),
2474        ));
2475    }
2476    staff.presentation = cmd.presentation.clone();
2477    Ok(())
2478}
2479
2480fn get_note_mut(
2481    score: &mut Score,
2482    part_index: usize,
2483    staff_index: usize,
2484    measure_index: usize,
2485    voice: usize,
2486    note_index: usize,
2487) -> Result<&mut Note, Error> {
2488    score
2489        .parts
2490        .get_mut(part_index)
2491        .ok_or(Error::PartNotFound(part_index))?
2492        .staves
2493        .get_mut(staff_index)
2494        .ok_or(Error::StaffNotFound(staff_index))?
2495        .measures
2496        .get_mut(measure_index)
2497        .ok_or(Error::MeasureNotFound(measure_index))?
2498        .voices
2499        .get_mut(voice)
2500        .ok_or(Error::VoiceOutOfRange(voice))?
2501        .get_mut(note_index)
2502        .ok_or(Error::NoteNotFound(note_index))
2503}
2504
2505fn for_each_measure_at(score: &mut Score, index: usize, mut f: impl FnMut(&mut Measure)) {
2506    for part in &mut score.parts {
2507        for staff in &mut part.staves {
2508            if let Some(m) = staff.measures.get_mut(index) {
2509                f(m);
2510            }
2511        }
2512    }
2513}
2514
2515fn apply_set_measure_text(cmd: &SetMeasureTextCmd, score: &mut Score) -> Result<(), Error> {
2516    let measure = score
2517        .parts
2518        .get_mut(cmd.part_index)
2519        .ok_or(Error::PartNotFound(cmd.part_index))?
2520        .staves
2521        .get_mut(cmd.staff_index)
2522        .ok_or(Error::StaffNotFound(cmd.staff_index))?
2523        .measures
2524        .get_mut(cmd.measure_index)
2525        .ok_or(Error::MeasureNotFound(cmd.measure_index))?;
2526    if cmd.text_index > measure.texts.len()
2527        || (cmd.text.is_none() && cmd.text_index == measure.texts.len())
2528    {
2529        return Err(Error::InvalidCommand(format!(
2530            "styled text index {} out of range for {} entries",
2531            cmd.text_index,
2532            measure.texts.len()
2533        )));
2534    }
2535    if let Some(text) = &cmd.text {
2536        if cmd.text_index == measure.texts.len() {
2537            measure.texts.push(text.clone());
2538        } else {
2539            measure.texts[cmd.text_index] = text.clone();
2540        }
2541    } else {
2542        measure.texts.remove(cmd.text_index);
2543    }
2544    Ok(())
2545}
2546
2547fn apply_set_score_text(cmd: &SetScoreTextCmd, score: &mut Score) -> Result<(), Error> {
2548    if cmd.text_index > score.texts.len()
2549        || (cmd.text.is_none() && cmd.text_index == score.texts.len())
2550    {
2551        return Err(Error::InvalidCommand(format!(
2552            "styled score text index {} out of range for {} entries",
2553            cmd.text_index,
2554            score.texts.len()
2555        )));
2556    }
2557    if let Some(text) = &cmd.text {
2558        if cmd.text_index == score.texts.len() {
2559            score.texts.push(text.clone());
2560        } else {
2561            score.texts[cmd.text_index] = text.clone();
2562        }
2563    } else {
2564        score.texts.remove(cmd.text_index);
2565    }
2566    Ok(())
2567}
2568
2569fn apply_set_score_style_overrides(
2570    cmd: &SetScoreStyleOverridesCmd,
2571    score: &mut Score,
2572) -> Result<(), Error> {
2573    if cmd
2574        .overrides
2575        .iter()
2576        .any(|override_| !override_.value.is_finite() || !(0.05..=64.0).contains(&override_.value))
2577    {
2578        return Err(Error::InvalidCommand(
2579            "score typed style override values must be finite and within 0.05..=64".into(),
2580        ));
2581    }
2582    score.style_overrides = cmd.overrides.clone();
2583    Ok(())
2584}
2585
2586fn apply_set_object_style_overrides(
2587    cmd: &SetObjectStyleOverridesCmd,
2588    score: &mut Score,
2589) -> Result<(), Error> {
2590    let mut candidate = score.clone();
2591    candidate.object_style_overrides = cmd.overrides.clone();
2592    if validate(&candidate).errors.iter().any(|error| {
2593        matches!(
2594            error,
2595            super::validate::ValidationError::InvalidObjectStyleOverride { .. }
2596        )
2597    }) {
2598        return Err(Error::InvalidCommand(
2599            "object style overrides require existing targets, finite values within 0.05..=64, and bounded provenance".into(),
2600        ));
2601    }
2602    score.object_style_overrides = cmd.overrides.clone();
2603    Ok(())
2604}
2605
2606/// Capacity used by note-editing commands: a measure's authored actual length (pickup or
2607/// irregular bar) when present, otherwise the score time signature.
2608fn editing_measure_beats(score: &Score, part: usize, staff: usize, measure: usize) -> f64 {
2609    score
2610        .parts
2611        .get(part)
2612        .and_then(|part| part.staves.get(staff))
2613        .and_then(|staff| staff.measures.get(measure))
2614        .and_then(|measure| measure.actual_length)
2615        .and_then(|length| length.beats())
2616        .unwrap_or_else(|| score.settings.time_signature.total_beats())
2617}
2618
2619fn apply_add_note(cmd: &AddNoteCmd, score: &mut Score) -> Result<(), Error> {
2620    let ts_beats = editing_measure_beats(score, cmd.part_index, cmd.staff_index, cmd.measure_index);
2621    let note = if cmd.is_rest {
2622        let mut n = Note::rest(cmd.duration.clone());
2623        n.dot_count = cmd.dot_count;
2624        n.tuplet = cmd.tuplet.clone();
2625        n
2626    } else {
2627        let pitch = cmd
2628            .pitch
2629            .clone()
2630            .ok_or_else(|| Error::InvalidCommand("pitch required for non-rest note".into()))?;
2631        let mut n = Note::new(pitch, cmd.duration.clone());
2632        n.dot_count = cmd.dot_count;
2633        n.tuplet = cmd.tuplet.clone();
2634        n
2635    };
2636
2637    let pos = {
2638        let voice = score
2639            .parts
2640            .get_mut(cmd.part_index)
2641            .ok_or(Error::PartNotFound(cmd.part_index))?
2642            .staves
2643            .get_mut(cmd.staff_index)
2644            .ok_or(Error::StaffNotFound(cmd.staff_index))?
2645            .measures
2646            .get_mut(cmd.measure_index)
2647            .ok_or(Error::MeasureNotFound(cmd.measure_index))?
2648            .voices
2649            .get_mut(cmd.voice)
2650            .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
2651        let pos = cmd.position.min(voice.len());
2652        voice.insert(pos, note);
2653        trim_voice_to_measure(voice, ts_beats);
2654        pos
2655    };
2656    remap_spanners(score, |address| {
2657        if same_voice(
2658            address,
2659            cmd.part_index,
2660            cmd.staff_index,
2661            cmd.measure_index,
2662            cmd.voice,
2663        ) && address.note >= pos
2664        {
2665            let mut shifted = address.clone();
2666            shifted.note += 1;
2667            Some(shifted)
2668        } else {
2669            Some(address.clone())
2670        }
2671    });
2672    Ok(())
2673}
2674
2675fn apply_add_pitch(cmd: &AddPitchCmd, score: &mut Score) -> Result<(), Error> {
2676    let voice = score
2677        .parts
2678        .get_mut(cmd.part_index)
2679        .ok_or(Error::PartNotFound(cmd.part_index))?
2680        .staves
2681        .get_mut(cmd.staff_index)
2682        .ok_or(Error::StaffNotFound(cmd.staff_index))?
2683        .measures
2684        .get_mut(cmd.measure_index)
2685        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
2686        .voices
2687        .get_mut(cmd.voice)
2688        .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
2689    let note = voice
2690        .get_mut(cmd.note_index)
2691        .ok_or(Error::NoteNotFound(cmd.note_index))?;
2692    if note.is_rest {
2693        return Err(Error::InvalidCommand("cannot add pitch to a rest".into()));
2694    }
2695    if !note
2696        .pitches
2697        .iter()
2698        .any(|p| p.step == cmd.pitch.step && p.octave == cmd.pitch.octave)
2699    {
2700        note.pitches.push(cmd.pitch.clone());
2701    }
2702    Ok(())
2703}
2704
2705fn apply_set_duration(cmd: &SetDurationCmd, score: &mut Score) -> Result<(), Error> {
2706    let ts_beats = editing_measure_beats(score, cmd.part_index, cmd.staff_index, cmd.measure_index);
2707    {
2708        let voice = score
2709            .parts
2710            .get_mut(cmd.part_index)
2711            .ok_or(Error::PartNotFound(cmd.part_index))?
2712            .staves
2713            .get_mut(cmd.staff_index)
2714            .ok_or(Error::StaffNotFound(cmd.staff_index))?
2715            .measures
2716            .get_mut(cmd.measure_index)
2717            .ok_or(Error::MeasureNotFound(cmd.measure_index))?
2718            .voices
2719            .get_mut(cmd.voice)
2720            .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
2721        let note = voice
2722            .get_mut(cmd.note_index)
2723            .ok_or(Error::NoteNotFound(cmd.note_index))?;
2724        note.duration = cmd.duration.clone();
2725        note.dot_count = cmd.dot_count;
2726        trim_voice_to_measure(voice, ts_beats);
2727    }
2728    prune_orphaned_spanners(score);
2729    Ok(())
2730}
2731
2732fn apply_delete_note(cmd: &DeleteNoteCmd, score: &mut Score) -> Result<(), Error> {
2733    let ts_beats = editing_measure_beats(score, cmd.part_index, cmd.staff_index, cmd.measure_index);
2734    let deleted_position = {
2735        let voice = score
2736            .parts
2737            .get_mut(cmd.part_index)
2738            .ok_or(Error::PartNotFound(cmd.part_index))?
2739            .staves
2740            .get_mut(cmd.staff_index)
2741            .ok_or(Error::StaffNotFound(cmd.staff_index))?
2742            .measures
2743            .get_mut(cmd.measure_index)
2744            .ok_or(Error::MeasureNotFound(cmd.measure_index))?
2745            .voices
2746            .get_mut(cmd.voice)
2747            .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
2748        let position = voice.iter().position(|note| note.id == cmd.note_id);
2749        voice.retain(|note| note.id != cmd.note_id);
2750        pad_voice_to_measure(voice, ts_beats);
2751        position
2752    };
2753    if let Some(position) = deleted_position {
2754        remap_spanners(score, |address| {
2755            if !same_voice(
2756                address,
2757                cmd.part_index,
2758                cmd.staff_index,
2759                cmd.measure_index,
2760                cmd.voice,
2761            ) {
2762                return Some(address.clone());
2763            }
2764            if address.note == position {
2765                None
2766            } else if address.note > position {
2767                let mut shifted = address.clone();
2768                shifted.note -= 1;
2769                Some(shifted)
2770            } else {
2771                Some(address.clone())
2772            }
2773        });
2774    } else {
2775        prune_orphaned_spanners(score);
2776    }
2777    Ok(())
2778}
2779
2780fn apply_add_measure(cmd: &AddMeasureCmd, score: &mut Score) -> Result<(), Error> {
2781    let ts = score.settings.time_signature.clone();
2782    let insert_at = cmd.after_index.saturating_add(1);
2783    for part in &mut score.parts {
2784        for staff in &mut part.staves {
2785            let insert_at = insert_at.min(staff.measures.len());
2786            let mut m = Measure::empty(ts.numerator, ts.denominator);
2787            m.number = insert_at as u32 + 1;
2788            staff.measures.insert(insert_at, m);
2789            for (i, measure) in staff.measures.iter_mut().enumerate() {
2790                measure.number = i as u32 + 1;
2791            }
2792        }
2793    }
2794    remap_spanners(score, |address| {
2795        if address.measure >= insert_at {
2796            let mut shifted = address.clone();
2797            shifted.measure += 1;
2798            Some(shifted)
2799        } else {
2800            Some(address.clone())
2801        }
2802    });
2803    Ok(())
2804}
2805
2806/// Resolve an optional measure range against the first staff of the first part.
2807fn score_measure_range(
2808    score: &Score,
2809    start: Option<usize>,
2810    end: Option<usize>,
2811) -> Result<(usize, usize), Error> {
2812    let count = score
2813        .parts
2814        .first()
2815        .and_then(|part| part.staves.first())
2816        .map_or(0, |staff| staff.measures.len());
2817    let start_measure = start.unwrap_or(0);
2818    let end_measure = end.unwrap_or(count);
2819    if start_measure >= end_measure || end_measure > count {
2820        return Err(Error::InvalidCommand(format!(
2821            "invalid measure range {start_measure}..{end_measure}"
2822        )));
2823    }
2824    Ok((start_measure, end_measure))
2825}
2826
2827/// A rehearsal-mark sequence recognised from its first mark.
2828enum RehearsalSequence {
2829    MeasureNumber,
2830    Number(u64),
2831    Letters { first: u64, lower: bool },
2832}
2833
2834impl RehearsalSequence {
2835    fn detect(text: &str, measure_number: u32) -> Option<Self> {
2836        if !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit()) {
2837            let value = text.parse::<u64>().ok()?;
2838            return Some(if value == u64::from(measure_number) {
2839                Self::MeasureNumber
2840            } else {
2841                Self::Number(value)
2842            });
2843        }
2844        let lower = text.bytes().all(|byte| byte.is_ascii_lowercase());
2845        let upper = text.bytes().all(|byte| byte.is_ascii_uppercase());
2846        if text.is_empty() || text.len() > 6 || !(lower || upper) {
2847            return None;
2848        }
2849        // Bijective base 26: A = 0, Z = 25, AA = 26, AB = 27, …
2850        let first = text.bytes().fold(0u64, |value, byte| {
2851            value * 26 + u64::from(byte.to_ascii_uppercase() - b'A') + 1
2852        }) - 1;
2853        Some(Self::Letters { first, lower })
2854    }
2855
2856    fn nth(&self, offset: u64, measure_number: u32) -> String {
2857        match self {
2858            Self::MeasureNumber => measure_number.to_string(),
2859            Self::Number(first) => (first + offset).to_string(),
2860            Self::Letters { first, lower } => {
2861                let mut value = first + offset + 1;
2862                let mut letters = Vec::new();
2863                while value > 0 {
2864                    value -= 1;
2865                    letters.push(b'A' + (value % 26) as u8);
2866                    value /= 26;
2867                }
2868                letters.reverse();
2869                let text = String::from_utf8(letters).unwrap_or_default();
2870                if *lower {
2871                    text.to_ascii_lowercase()
2872                } else {
2873                    text
2874                }
2875            }
2876        }
2877    }
2878}
2879
2880fn apply_resequence_rehearsal_marks(
2881    cmd: &ResequenceRehearsalMarksCmd,
2882    score: &mut Score,
2883) -> Result<(), Error> {
2884    let (start, end) = score_measure_range(score, cmd.start_measure, cmd.end_measure)?;
2885    let reference = &score.parts[0].staves[0].measures;
2886    let marks: Vec<(usize, u32)> = (start..end)
2887        .filter(|&index| {
2888            reference[index]
2889                .rehearsal
2890                .as_deref()
2891                .is_some_and(|text| !text.trim().is_empty())
2892        })
2893        .map(|index| (index, reference[index].number))
2894        .collect();
2895    let Some(&(first_index, first_number)) = marks.first() else {
2896        return Ok(());
2897    };
2898    let first_text = reference[first_index]
2899        .rehearsal
2900        .as_deref()
2901        .unwrap_or_default()
2902        .trim()
2903        .to_string();
2904    let sequence = RehearsalSequence::detect(&first_text, first_number).ok_or_else(|| {
2905        Error::InvalidCommand(format!(
2906            "rehearsal mark '{first_text}' does not start a letter or number sequence"
2907        ))
2908    })?;
2909    for (offset, (index, number)) in marks.into_iter().enumerate() {
2910        let text = sequence.nth(offset as u64, number);
2911        for_each_measure_at(score, index, |measure| {
2912            measure.rehearsal = Some(text.clone());
2913        });
2914    }
2915    Ok(())
2916}
2917
2918fn apply_system_break_interval(
2919    cmd: &SetSystemBreakIntervalCmd,
2920    score: &mut Score,
2921) -> Result<(), Error> {
2922    let (start, end) = score_measure_range(score, cmd.start_measure, cmd.end_measure)?;
2923    let last_measure = score.parts[0].staves[0].measures.len() - 1;
2924    let interval = usize::try_from(cmd.interval).unwrap_or(usize::MAX);
2925    for index in start..end {
2926        let value = interval > 0 && index < last_measure && (index - start + 1) % interval == 0;
2927        for_each_measure_at(score, index, |measure| measure.system_break = value);
2928    }
2929    Ok(())
2930}
2931
2932/// A trailing measure is empty when it only carries plain rests and no measure-level content.
2933/// Only the right barline may differ, so a final barline does not keep an empty measure alive.
2934fn is_empty_trailing_measure(measure: &Measure) -> bool {
2935    let strip = |value: serde_json::Value| -> serde_json::Value {
2936        let mut value = value;
2937        if let Some(object) = value.as_object_mut() {
2938            for field in ["number", "voices", "source_voice_numbers", "barline_right"] {
2939                object.remove(field);
2940            }
2941        }
2942        value
2943    };
2944    let (Ok(actual), Ok(blank)) = (
2945        serde_json::to_value(measure),
2946        serde_json::to_value(Measure::empty(4, 4)),
2947    ) else {
2948        return false;
2949    };
2950    if strip(actual) != strip(blank) {
2951        return false;
2952    }
2953    measure.voices.iter().flatten().all(|note| {
2954        if !note.is_rest {
2955            return false;
2956        }
2957        let mut plain = Note::rest(note.duration.clone());
2958        plain.dot_count = note.dot_count;
2959        plain.tuplet = note.tuplet.clone();
2960        plain.id = note.id.clone();
2961        matches!(
2962            (serde_json::to_value(note), serde_json::to_value(&plain)),
2963            (Ok(actual), Ok(plain)) if actual == plain
2964        )
2965    })
2966}
2967
2968fn apply_remove_trailing_empty_measures(score: &mut Score) -> Result<(), Error> {
2969    let count = score
2970        .parts
2971        .first()
2972        .and_then(|part| part.staves.first())
2973        .map_or(0, |staff| staff.measures.len());
2974    let mut keep = count;
2975    while keep > 1
2976        && score.parts.iter().all(|part| {
2977            part.staves.iter().all(|staff| {
2978                staff
2979                    .measures
2980                    .get(keep - 1)
2981                    .is_some_and(is_empty_trailing_measure)
2982            })
2983        })
2984    {
2985        keep -= 1;
2986    }
2987    if keep == count {
2988        return Ok(());
2989    }
2990    let final_barline = score.parts.iter().any(|part| {
2991        part.staves.iter().any(|staff| {
2992            staff
2993                .measures
2994                .last()
2995                .is_some_and(|measure| matches!(measure.barline_right, Barline::Final))
2996        })
2997    });
2998    for index in (keep..count).rev() {
2999        apply_delete_measure(
3000            &DeleteMeasureCmd {
3001                measure_index: index,
3002            },
3003            score,
3004        )?;
3005    }
3006    if final_barline {
3007        for_each_measure_at(score, keep - 1, |measure| {
3008            if matches!(measure.barline_right, Barline::Normal) {
3009                measure.barline_right = Barline::Final;
3010            }
3011        });
3012    }
3013    Ok(())
3014}
3015
3016fn apply_delete_measure(cmd: &DeleteMeasureCmd, score: &mut Score) -> Result<(), Error> {
3017    for part in &mut score.parts {
3018        for staff in &mut part.staves {
3019            if cmd.measure_index < staff.measures.len() {
3020                staff.measures.remove(cmd.measure_index);
3021                for (i, m) in staff.measures.iter_mut().enumerate() {
3022                    m.number = i as u32 + 1;
3023                }
3024            }
3025        }
3026    }
3027    remap_spanners(score, |address| {
3028        if address.measure == cmd.measure_index {
3029            None
3030        } else if address.measure > cmd.measure_index {
3031            let mut shifted = address.clone();
3032            shifted.measure -= 1;
3033            Some(shifted)
3034        } else {
3035            Some(address.clone())
3036        }
3037    });
3038    Ok(())
3039}
3040
3041fn apply_add_hairpin(cmd: &AddHairpinCmd, score: &mut Score) -> Result<(), Error> {
3042    let voice = score
3043        .parts
3044        .get_mut(cmd.part_index)
3045        .ok_or(Error::PartNotFound(cmd.part_index))?
3046        .staves
3047        .get_mut(cmd.staff_index)
3048        .ok_or(Error::StaffNotFound(cmd.staff_index))?
3049        .measures
3050        .get_mut(cmd.measure_index)
3051        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
3052        .voices
3053        .get_mut(cmd.voice)
3054        .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
3055    if cmd.start_note_idx >= voice.len() {
3056        return Err(Error::NoteNotFound(cmd.start_note_idx));
3057    }
3058    if cmd.end_note_idx >= voice.len() {
3059        return Err(Error::NoteNotFound(cmd.end_note_idx));
3060    }
3061    if cmd.start_note_idx >= cmd.end_note_idx {
3062        return Err(Error::InvalidCommand(
3063            "start_note_idx must be less than end_note_idx".into(),
3064        ));
3065    }
3066    for note in voice
3067        .iter_mut()
3068        .take(cmd.end_note_idx + 1)
3069        .skip(cmd.start_note_idx)
3070    {
3071        note.hairpin_start = None;
3072        note.hairpin_end = false;
3073    }
3074    voice[cmd.start_note_idx].hairpin_start = Some(cmd.kind);
3075    voice[cmd.end_note_idx].hairpin_end = true;
3076    Ok(())
3077}
3078
3079fn apply_add_pedal(cmd: &AddPedalCmd, score: &mut Score) -> Result<(), Error> {
3080    let voice = score
3081        .parts
3082        .get_mut(cmd.part_index)
3083        .ok_or(Error::PartNotFound(cmd.part_index))?
3084        .staves
3085        .get_mut(cmd.staff_index)
3086        .ok_or(Error::StaffNotFound(cmd.staff_index))?
3087        .measures
3088        .get_mut(cmd.measure_index)
3089        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
3090        .voices
3091        .get_mut(cmd.voice)
3092        .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
3093    if cmd.start_note_idx >= voice.len() {
3094        return Err(Error::NoteNotFound(cmd.start_note_idx));
3095    }
3096    if cmd.end_note_idx >= voice.len() {
3097        return Err(Error::NoteNotFound(cmd.end_note_idx));
3098    }
3099    if cmd.start_note_idx >= cmd.end_note_idx {
3100        return Err(Error::InvalidCommand(
3101            "start_note_idx must be less than end_note_idx".into(),
3102        ));
3103    }
3104    for note in voice
3105        .iter_mut()
3106        .take(cmd.end_note_idx + 1)
3107        .skip(cmd.start_note_idx)
3108    {
3109        note.pedal_start = false;
3110        note.pedal_end = false;
3111    }
3112    voice[cmd.start_note_idx].pedal_start = true;
3113    voice[cmd.end_note_idx].pedal_end = true;
3114    Ok(())
3115}
3116
3117fn apply_toggle_tie(cmd: &ToggleTieCmd, score: &mut Score) -> Result<(), Error> {
3118    let current_tie_start = {
3119        let v = score
3120            .parts
3121            .get(cmd.part_index)
3122            .ok_or(Error::PartNotFound(cmd.part_index))?
3123            .staves
3124            .get(cmd.staff_index)
3125            .ok_or(Error::StaffNotFound(cmd.staff_index))?
3126            .measures
3127            .get(cmd.measure_index)
3128            .ok_or(Error::MeasureNotFound(cmd.measure_index))?
3129            .voices
3130            .get(cmd.voice)
3131            .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
3132        v.get(cmd.note_index)
3133            .ok_or(Error::NoteNotFound(cmd.note_index))?
3134            .tie_start
3135    };
3136    let voice_len = score.parts[cmd.part_index].staves[cmd.staff_index].measures[cmd.measure_index]
3137        .voices[cmd.voice]
3138        .len();
3139    let total_measures = score.parts[cmd.part_index].staves[cmd.staff_index]
3140        .measures
3141        .len();
3142
3143    let new_tie = !current_tie_start;
3144    score.parts[cmd.part_index].staves[cmd.staff_index].measures[cmd.measure_index].voices
3145        [cmd.voice][cmd.note_index]
3146        .tie_start = new_tie;
3147
3148    if cmd.note_index + 1 < voice_len {
3149        score.parts[cmd.part_index].staves[cmd.staff_index].measures[cmd.measure_index].voices
3150            [cmd.voice][cmd.note_index + 1]
3151            .tie_end = new_tie;
3152    } else {
3153        let next_mi = cmd.measure_index + 1;
3154        if next_mi < total_measures {
3155            let next_voice = &mut score.parts[cmd.part_index].staves[cmd.staff_index].measures
3156                [next_mi]
3157                .voices[cmd.voice];
3158            if let Some(n) = next_voice.get_mut(0) {
3159                n.tie_end = new_tie;
3160            }
3161        }
3162    }
3163    Ok(())
3164}
3165
3166fn apply_set_dynamic(cmd: &SetDynamicCmd, score: &mut Score) -> Result<(), Error> {
3167    get_note_mut(
3168        score,
3169        cmd.part_index,
3170        cmd.staff_index,
3171        cmd.measure_index,
3172        cmd.voice,
3173        cmd.note_index,
3174    )?
3175    .dynamic = cmd.dynamic.clone();
3176    Ok(())
3177}
3178
3179fn apply_toggle_articulation(cmd: &ToggleArticulationCmd, score: &mut Score) -> Result<(), Error> {
3180    let note = get_note_mut(
3181        score,
3182        cmd.part_index,
3183        cmd.staff_index,
3184        cmd.measure_index,
3185        cmd.voice,
3186        cmd.note_index,
3187    )?;
3188    if let Some(pos) = note
3189        .articulations
3190        .iter()
3191        .position(|a| a == &cmd.articulation)
3192    {
3193        note.articulations.remove(pos);
3194    } else {
3195        note.articulations.push(cmd.articulation.clone());
3196    }
3197    Ok(())
3198}
3199
3200fn apply_set_time_signature(cmd: &SetTimeSignatureCmd, score: &mut Score) -> Result<(), Error> {
3201    if cmd.numerator == 0 || cmd.denominator == 0 {
3202        return Err(Error::InvalidCommand(
3203            "time signature numerator and denominator must be > 0".into(),
3204        ));
3205    }
3206    if ![1u8, 2, 4, 8, 16, 32].contains(&cmd.denominator) {
3207        return Err(Error::InvalidCommand(format!(
3208            "invalid time signature denominator: {}",
3209            cmd.denominator
3210        )));
3211    }
3212    score.settings.time_signature = TimeSignature {
3213        numerator: cmd.numerator,
3214        denominator: cmd.denominator,
3215    };
3216    let max_beats = score.settings.time_signature.total_beats();
3217    for part in &mut score.parts {
3218        for staff in &mut part.staves {
3219            for measure in &mut staff.measures {
3220                // A pickup or irregular bar keeps its authored length.
3221                let beats = measure
3222                    .actual_length
3223                    .and_then(|length| length.beats())
3224                    .unwrap_or(max_beats);
3225                for voice in &mut measure.voices {
3226                    trim_voice_to_measure(voice, beats);
3227                    pad_voice_to_measure(voice, beats);
3228                }
3229            }
3230        }
3231    }
3232    Ok(())
3233}
3234
3235fn apply_set_barline(cmd: &SetBarlineCmd, score: &mut Score) -> Result<(), Error> {
3236    for part in &mut score.parts {
3237        for staff in &mut part.staves {
3238            let measure = staff
3239                .measures
3240                .get_mut(cmd.measure_index)
3241                .ok_or(Error::MeasureNotFound(cmd.measure_index))?;
3242            match cmd.side.as_str() {
3243                "left" => measure.barline_left = cmd.barline.clone(),
3244                "right" => measure.barline_right = cmd.barline.clone(),
3245                _ => {
3246                    return Err(Error::InvalidCommand(format!(
3247                        "invalid barline side: '{}'",
3248                        cmd.side
3249                    )));
3250                }
3251            }
3252        }
3253    }
3254    Ok(())
3255}
3256
3257fn apply_add_part(cmd: &AddPartCmd, score: &mut Score) -> Result<(), Error> {
3258    if cmd.clefs.is_empty() {
3259        return Err(Error::InvalidCommand(
3260            "AddPart requires at least one clef".into(),
3261        ));
3262    }
3263    let measure_count = score.measure_count();
3264    let ts = score.settings.time_signature.clone();
3265    let mut part = Part::new(&cmd.name, &cmd.short_name);
3266    part.midi_channel = cmd.midi_channel.min(15);
3267    part.midi_program = cmd.midi_program;
3268    for clef_str in &cmd.clefs {
3269        let clef = match clef_str.as_str() {
3270            "Bass" => Clef::Bass,
3271            "Alto" => Clef::Alto,
3272            "Tenor" => Clef::Tenor,
3273            "Percussion" => Clef::Percussion,
3274            _ => Clef::Treble,
3275        };
3276        let mut staff = Staff::new(clef);
3277        for i in 0..measure_count {
3278            let mut m = Measure::empty(ts.numerator, ts.denominator);
3279            m.number = i as u32 + 1;
3280            staff.measures.push(m);
3281        }
3282        part.staves.push(staff);
3283    }
3284    score.parts.push(part);
3285    Ok(())
3286}
3287
3288fn apply_set_clef(cmd: &SetClefCmd, score: &mut Score) -> Result<(), Error> {
3289    score
3290        .parts
3291        .get_mut(cmd.part_index)
3292        .ok_or(Error::PartNotFound(cmd.part_index))?
3293        .staves
3294        .get_mut(cmd.staff_index)
3295        .ok_or(Error::StaffNotFound(cmd.staff_index))?
3296        .clef = cmd.clef.clone();
3297    Ok(())
3298}
3299
3300fn apply_set_part_name(cmd: &SetPartNameCmd, score: &mut Score) -> Result<(), Error> {
3301    let part = score
3302        .parts
3303        .get_mut(cmd.part_index)
3304        .ok_or(Error::PartNotFound(cmd.part_index))?;
3305    part.name = cmd.name.clone();
3306    part.short_name = cmd.short_name.clone();
3307    Ok(())
3308}
3309
3310fn apply_delete_part(cmd: &DeletePartCmd, score: &mut Score) -> Result<(), Error> {
3311    if cmd.part_index >= score.parts.len() {
3312        return Err(Error::PartNotFound(cmd.part_index));
3313    }
3314    score.parts.remove(cmd.part_index);
3315    remap_spanners(score, |address| {
3316        if address.part == cmd.part_index {
3317            None
3318        } else if address.part > cmd.part_index {
3319            let mut shifted = address.clone();
3320            shifted.part -= 1;
3321            Some(shifted)
3322        } else {
3323            Some(address.clone())
3324        }
3325    });
3326    Ok(())
3327}
3328
3329fn apply_reorder_parts(cmd: &ReorderPartsCmd, score: &mut Score) -> Result<(), Error> {
3330    let count = score.parts.len();
3331    if cmd.order.len() != count {
3332        return Err(Error::InvalidCommand(format!(
3333            "part order must contain exactly {count} entries"
3334        )));
3335    }
3336    let mut old_to_new = vec![usize::MAX; count];
3337    for (new_index, &old_index) in cmd.order.iter().enumerate() {
3338        if old_index >= count {
3339            return Err(Error::PartNotFound(old_index));
3340        }
3341        if std::mem::replace(&mut old_to_new[old_index], new_index) != usize::MAX {
3342            return Err(Error::InvalidCommand(format!(
3343                "part order contains duplicate index {old_index}"
3344            )));
3345        }
3346    }
3347    for view in &score.views {
3348        for &part in &view.parts {
3349            if part >= count {
3350                return Err(Error::PartNotFound(part));
3351            }
3352        }
3353        for override_ in &view.staff_kind_overrides {
3354            if override_.staff.part >= count {
3355                return Err(Error::PartNotFound(override_.staff.part));
3356            }
3357        }
3358    }
3359    for spanner in &score.spanners {
3360        for address in [&spanner.start, &spanner.end] {
3361            if address.part >= count {
3362                return Err(Error::PartNotFound(address.part));
3363            }
3364        }
3365    }
3366    for group in &score.part_groups {
3367        if group.first_part > group.last_part || group.last_part >= count {
3368            return Err(Error::InvalidCommand(
3369                "part group references an invalid part range".into(),
3370            ));
3371        }
3372        let mut remapped: Vec<_> = (group.first_part..=group.last_part)
3373            .map(|part| old_to_new[part])
3374            .collect();
3375        remapped.sort_unstable();
3376        if remapped
3377            .windows(2)
3378            .any(|pair| pair[1] != pair[0].saturating_add(1))
3379        {
3380            return Err(Error::InvalidCommand(
3381                "part order would split an existing part group".into(),
3382            ));
3383        }
3384    }
3385
3386    let existing = std::mem::take(&mut score.parts);
3387    score.parts = cmd
3388        .order
3389        .iter()
3390        .map(|&old_index| existing[old_index].clone())
3391        .collect();
3392    for view in &mut score.views {
3393        for part in &mut view.parts {
3394            *part = old_to_new[*part];
3395        }
3396        for override_ in &mut view.staff_kind_overrides {
3397            override_.staff.part = old_to_new[override_.staff.part];
3398        }
3399    }
3400    for group in &mut score.part_groups {
3401        group.first_part = old_to_new[group.first_part];
3402        group.last_part = old_to_new[group.last_part];
3403        if group.first_part > group.last_part {
3404            std::mem::swap(&mut group.first_part, &mut group.last_part);
3405        }
3406    }
3407    remap_spanners(score, |address| {
3408        let mut remapped = address.clone();
3409        remapped.part = old_to_new[address.part];
3410        Some(remapped)
3411    });
3412    Ok(())
3413}
3414
3415fn apply_set_metadata(cmd: &SetMetadataCmd, score: &mut Score) -> Result<(), Error> {
3416    if let Some(v) = &cmd.title {
3417        score.metadata.title = v.clone();
3418    }
3419    if let Some(v) = &cmd.composer {
3420        score.metadata.composer = v.clone();
3421    }
3422    if let Some(v) = &cmd.lyricist {
3423        score.metadata.lyricist = v.clone();
3424    }
3425    if let Some(v) = &cmd.copyright {
3426        score.metadata.copyright = v.clone();
3427    }
3428    if let Some(v) = &cmd.work_number {
3429        score.metadata.work_number = v.clone();
3430    }
3431    if let Some(v) = &cmd.movement_title {
3432        score.metadata.movement_title = v.clone();
3433    }
3434    Ok(())
3435}
3436
3437fn apply_set_midi_instrument(cmd: &SetMidiInstrumentCmd, score: &mut Score) -> Result<(), Error> {
3438    let part = score
3439        .parts
3440        .get_mut(cmd.part_index)
3441        .ok_or(Error::PartNotFound(cmd.part_index))?;
3442    part.midi_channel = cmd.midi_channel.min(15);
3443    part.midi_program = cmd.midi_program;
3444    Ok(())
3445}
3446
3447fn apply_set_percussion_kit(cmd: &SetPercussionKitCmd, score: &mut Score) -> Result<(), Error> {
3448    validate_percussion_kit(&cmd.instruments)?;
3449    let part = score
3450        .parts
3451        .get_mut(cmd.part_index)
3452        .ok_or(Error::PartNotFound(cmd.part_index))?;
3453    part.percussion_instruments = cmd.instruments.clone();
3454    Ok(())
3455}
3456
3457fn apply_set_instrument_definition(
3458    cmd: &SetInstrumentDefinitionCmd,
3459    score: &mut Score,
3460) -> Result<(), Error> {
3461    if let Some(definition) = &cmd.definition {
3462        validate_instrument_definition(definition)?;
3463    }
3464    let part = score
3465        .parts
3466        .get_mut(cmd.part_index)
3467        .ok_or(Error::PartNotFound(cmd.part_index))?;
3468    part.instrument = cmd.definition.clone();
3469    Ok(())
3470}
3471
3472fn apply_set_measure_instrument_change(
3473    cmd: &SetMeasureInstrumentChangeCmd,
3474    score: &mut Score,
3475) -> Result<(), Error> {
3476    if let Some(definition) = &cmd.definition {
3477        validate_instrument_definition(definition)?;
3478    }
3479    score
3480        .parts
3481        .get_mut(cmd.part_index)
3482        .ok_or(Error::PartNotFound(cmd.part_index))?
3483        .staves
3484        .get_mut(cmd.staff_index)
3485        .ok_or(Error::StaffNotFound(cmd.staff_index))?
3486        .measures
3487        .get_mut(cmd.measure_index)
3488        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
3489        .instrument_change = cmd.definition.clone();
3490    Ok(())
3491}
3492
3493fn apply_set_measure_tablature_change(
3494    cmd: &SetMeasureTablatureChangeCmd,
3495    score: &mut Score,
3496) -> Result<(), Error> {
3497    let staff = score
3498        .parts
3499        .get_mut(cmd.part_index)
3500        .ok_or(Error::PartNotFound(cmd.part_index))?
3501        .staves
3502        .get_mut(cmd.staff_index)
3503        .ok_or(Error::StaffNotFound(cmd.staff_index))?;
3504    let base = staff.tablature.as_ref().ok_or_else(|| {
3505        Error::InvalidCommand("tablature change requires a staff tablature configuration".into())
3506    })?;
3507    if let Some(config) = &cmd.config {
3508        validate_tablature_config(config)?;
3509        if config.lines != base.lines {
3510            return Err(Error::InvalidCommand(format!(
3511                "tablature change line count {} must match staff line count {}",
3512                config.lines, base.lines
3513            )));
3514        }
3515    }
3516    staff
3517        .measures
3518        .get_mut(cmd.measure_index)
3519        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
3520        .tablature_change = cmd.config.clone();
3521    Ok(())
3522}
3523
3524fn validate_tablature_config(config: &TablatureConfig) -> Result<(), Error> {
3525    if !(1..=64).contains(&config.lines) {
3526        return Err(Error::InvalidCommand(format!(
3527            "tablature line count {} is outside 1..=64",
3528            config.lines
3529        )));
3530    }
3531    if config.tuning_midi.len() > usize::from(config.lines) {
3532        return Err(Error::InvalidCommand(format!(
3533            "tablature has {} tunings for {} lines",
3534            config.tuning_midi.len(),
3535            config.lines
3536        )));
3537    }
3538    if let Some(midi) = config
3539        .tuning_midi
3540        .iter()
3541        .copied()
3542        .find(|midi| !(0..=127).contains(midi))
3543    {
3544        return Err(Error::InvalidCommand(format!(
3545            "tablature tuning MIDI {midi} is outside 0..=127"
3546        )));
3547    }
3548    Ok(())
3549}
3550
3551fn validate_percussion_kit(instruments: &[PercussionInstrument]) -> Result<(), Error> {
3552    let mut ids = std::collections::HashSet::new();
3553    for instrument in instruments {
3554        if instrument.id.trim().is_empty() {
3555            return Err(Error::InvalidCommand(
3556                "percussion instrument id must not be empty".into(),
3557            ));
3558        }
3559        if !ids.insert(instrument.id.as_str()) {
3560            return Err(Error::InvalidCommand(format!(
3561                "percussion instrument id '{}' is duplicated",
3562                instrument.id
3563            )));
3564        }
3565        if let Some(position) = instrument.staff_position
3566            && !(-32..=32).contains(&position)
3567        {
3568            return Err(Error::InvalidCommand(format!(
3569                "percussion staff position {position} is outside -32..=32"
3570            )));
3571        }
3572        if let Some(voice) = instrument.preferred_voice
3573            && !(1..=4).contains(&voice)
3574        {
3575            return Err(Error::InvalidCommand(format!(
3576                "percussion preferred voice {voice} is outside 1..=4"
3577            )));
3578        }
3579        if instrument
3580            .techniques
3581            .iter()
3582            .any(|technique| technique.trim().is_empty() || technique.len() > 128)
3583        {
3584            return Err(Error::InvalidCommand(
3585                "percussion techniques must be non-empty and at most 128 bytes".into(),
3586            ));
3587        }
3588    }
3589    Ok(())
3590}
3591
3592fn validate_instrument_definition(definition: &InstrumentDefinition) -> Result<(), Error> {
3593    if definition.id.trim().is_empty() {
3594        return Err(Error::InvalidCommand(
3595            "instrument definition id must not be empty".into(),
3596        ));
3597    }
3598    if !(1..=64).contains(&definition.staff_count) {
3599        return Err(Error::InvalidCommand(format!(
3600            "instrument staff count {} is outside 1..=64",
3601            definition.staff_count
3602        )));
3603    }
3604    if definition.midi_channel > 15 {
3605        return Err(Error::InvalidCommand(format!(
3606            "instrument MIDI channel {} is outside 0..=15",
3607            definition.midi_channel
3608        )));
3609    }
3610    for (name, range) in [
3611        ("written", definition.written_range),
3612        ("sounding", definition.sounding_range),
3613    ] {
3614        if let Some(InstrumentRange { lowest, highest }) = range
3615            && lowest > highest
3616        {
3617            return Err(Error::InvalidCommand(format!(
3618                "instrument {name} range has lowest MIDI note {lowest} above highest {highest}"
3619            )));
3620        }
3621    }
3622    Ok(())
3623}
3624
3625fn apply_upsert_score_view(cmd: &UpsertScoreViewCmd, score: &mut Score) -> Result<(), Error> {
3626    validate_score_view(&cmd.view, score)?;
3627    if let Some(index) = score.views.iter().position(|view| view.id == cmd.view.id) {
3628        score.views[index] = cmd.view.clone();
3629    } else {
3630        score.views.push(cmd.view.clone());
3631    }
3632    Ok(())
3633}
3634
3635fn apply_remove_score_view(cmd: &RemoveScoreViewCmd, score: &mut Score) -> Result<(), Error> {
3636    let index = score
3637        .views
3638        .iter()
3639        .position(|view| view.id == cmd.id)
3640        .ok_or_else(|| Error::InvalidCommand(format!("score view '{}' does not exist", cmd.id)))?;
3641    score.views.remove(index);
3642    Ok(())
3643}
3644
3645fn validate_score_view(view: &ScoreView, score: &Score) -> Result<(), Error> {
3646    if view.id.trim().is_empty() || view.name.trim().is_empty() {
3647        return Err(Error::InvalidCommand(
3648            "score view id and name must not be empty".into(),
3649        ));
3650    }
3651    if view.parts.is_empty() {
3652        return Err(Error::InvalidCommand(
3653            "score view must select at least one part".into(),
3654        ));
3655    }
3656    let mut selected = vec![false; score.parts.len()];
3657    for &part in &view.parts {
3658        if part >= score.parts.len() {
3659            return Err(Error::PartNotFound(part));
3660        }
3661        if std::mem::replace(&mut selected[part], true) {
3662            return Err(Error::InvalidCommand(format!(
3663                "score view '{}' selects part {part} more than once",
3664                view.id
3665            )));
3666        }
3667    }
3668    if view.layout.measures_per_row.is_some_and(|value| value == 0) {
3669        return Err(Error::InvalidCommand(
3670            "score view measures per row must be greater than zero".into(),
3671        ));
3672    }
3673    if view
3674        .layout
3675        .typed_style_overrides
3676        .iter()
3677        .any(|override_| !override_.value.is_finite() || !(0.05..=64.0).contains(&override_.value))
3678    {
3679        return Err(Error::InvalidCommand(
3680            "score view typed style override values must be finite and within 0.05..=64".into(),
3681        ));
3682    }
3683    for reference in &view.layout.hidden_staves {
3684        let Some(part) = score.parts.get(reference.part) else {
3685            return Err(Error::PartNotFound(reference.part));
3686        };
3687        if reference.staff >= part.staves.len() {
3688            return Err(Error::StaffNotFound(reference.staff));
3689        }
3690        if !selected[reference.part] {
3691            return Err(Error::InvalidCommand(
3692                "score view cannot hide a staff outside its selected parts".into(),
3693            ));
3694        }
3695    }
3696    let mut overridden = std::collections::HashSet::new();
3697    for override_ in &view.staff_kind_overrides {
3698        let reference = override_.staff;
3699        let Some(part) = score.parts.get(reference.part) else {
3700            return Err(Error::PartNotFound(reference.part));
3701        };
3702        if reference.staff >= part.staves.len() {
3703            return Err(Error::StaffNotFound(reference.staff));
3704        }
3705        if !selected[reference.part] {
3706            return Err(Error::InvalidCommand(
3707                "score view cannot override a staff outside its selected parts".into(),
3708            ));
3709        }
3710        if !overridden.insert((reference.part, reference.staff)) {
3711            return Err(Error::InvalidCommand(
3712                "score view may override each staff kind at most once".into(),
3713            ));
3714        }
3715        if override_.kind == StaffKind::Tablature
3716            && part.staves[reference.staff].tablature.is_none()
3717        {
3718            return Err(Error::InvalidCommand(
3719                "tablature score view requires a tablature configuration".into(),
3720            ));
3721        }
3722    }
3723    let measure_count = score.measure_count();
3724    for &break_index in view
3725        .layout
3726        .system_breaks
3727        .iter()
3728        .chain(view.layout.page_breaks.iter())
3729    {
3730        if break_index >= measure_count {
3731            return Err(Error::InvalidCommand(format!(
3732                "score view break measure {break_index} is out of range"
3733            )));
3734        }
3735    }
3736    for (key, value) in &view.layout.style_overrides {
3737        if key.trim().is_empty() || value.len() > 4096 {
3738            return Err(Error::InvalidCommand(
3739                "score view style overrides need a non-empty key and a value of at most 4096 bytes"
3740                    .into(),
3741            ));
3742        }
3743    }
3744    Ok(())
3745}
3746
3747fn apply_set_transpose(cmd: &SetTransposeCmd, score: &mut Score) -> Result<(), Error> {
3748    score
3749        .parts
3750        .get_mut(cmd.part_index)
3751        .ok_or(Error::PartNotFound(cmd.part_index))?
3752        .staves
3753        .get_mut(cmd.staff_index)
3754        .ok_or(Error::StaffNotFound(cmd.staff_index))?
3755        .transpose_semitones = cmd.semitones;
3756    Ok(())
3757}
3758
3759fn apply_transpose_staff_region(
3760    cmd: &TransposeStaffRegionCmd,
3761    score: &mut Score,
3762) -> Result<(), Error> {
3763    *score = transpose_staff_region_checked(
3764        score,
3765        cmd.part_index,
3766        cmd.staff_index,
3767        cmd.start_measure,
3768        cmd.end_measure,
3769        cmd.semitones,
3770        cmd.target,
3771    )?;
3772    Ok(())
3773}
3774
3775fn apply_exchange_voices(cmd: &ExchangeVoicesCmd, score: &mut Score) -> Result<(), Error> {
3776    if cmd.first_voice >= 4 {
3777        return Err(Error::VoiceOutOfRange(cmd.first_voice));
3778    }
3779    if cmd.second_voice >= 4 {
3780        return Err(Error::VoiceOutOfRange(cmd.second_voice));
3781    }
3782    if cmd.first_voice == cmd.second_voice {
3783        return Err(Error::InvalidCommand(
3784            "exchange voices requires two distinct voices".into(),
3785        ));
3786    }
3787    let start = cmd.start_measure.min(cmd.end_measure);
3788    let end = cmd.start_measure.max(cmd.end_measure);
3789    let staff = score
3790        .parts
3791        .get_mut(cmd.part_index)
3792        .ok_or(Error::PartNotFound(cmd.part_index))?
3793        .staves
3794        .get_mut(cmd.staff_index)
3795        .ok_or(Error::StaffNotFound(cmd.staff_index))?;
3796    if end >= staff.measures.len() {
3797        return Err(Error::MeasureNotFound(end));
3798    }
3799    for measure in &mut staff.measures[start..=end] {
3800        measure.voices.swap(cmd.first_voice, cmd.second_voice);
3801        measure
3802            .source_voice_numbers
3803            .swap(cmd.first_voice, cmd.second_voice);
3804    }
3805    remap_spanners(score, |address| {
3806        if address.part != cmd.part_index
3807            || address.staff != cmd.staff_index
3808            || address.measure < start
3809            || address.measure > end
3810        {
3811            return Some(address.clone());
3812        }
3813        let mut remapped = address.clone();
3814        if address.voice == cmd.first_voice {
3815            remapped.voice = cmd.second_voice;
3816        } else if address.voice == cmd.second_voice {
3817            remapped.voice = cmd.first_voice;
3818        }
3819        Some(remapped)
3820    });
3821    Ok(())
3822}
3823
3824fn apply_move_or_copy_voice_range(
3825    cmd: &MoveOrCopyVoiceRangeCmd,
3826    score: &mut Score,
3827) -> Result<(), Error> {
3828    if cmd.source_start.part != cmd.source_end.part
3829        || cmd.source_start.staff != cmd.source_end.staff
3830        || cmd.source_start.voice != cmd.source_end.voice
3831    {
3832        return Err(Error::InvalidCommand(
3833            "move or copy source endpoints must share part, staff, and voice".into(),
3834        ));
3835    }
3836    if cmd.source_start.voice >= 4 {
3837        return Err(Error::VoiceOutOfRange(cmd.source_start.voice));
3838    }
3839    let source_from = cmd.source_start.measure.min(cmd.source_end.measure);
3840    let source_to = cmd.source_start.measure.max(cmd.source_end.measure);
3841    let source_count = source_to - source_from + 1;
3842    let target_end = cmd
3843        .target
3844        .measure
3845        .checked_add(source_count - 1)
3846        .ok_or_else(|| Error::InvalidCommand("voice range target overflows".into()))?;
3847    if cmd.move_source
3848        && cmd.target.part == cmd.source_start.part
3849        && cmd.target.staff == cmd.source_start.staff
3850        && cmd.target.voice == cmd.source_start.voice
3851        && cmd.target.measure <= source_to
3852        && target_end >= source_from
3853    {
3854        return Err(Error::InvalidCommand(
3855            "moving a voice range onto itself is not supported".into(),
3856        ));
3857    }
3858
3859    let fragment = extract_score_fragment(
3860        score,
3861        &[ScoreFragmentSelection {
3862            start: cmd.source_start.clone(),
3863            end: cmd.source_end.clone(),
3864        }],
3865    )?;
3866    apply_paste_score_fragment(
3867        &PasteScoreFragmentCmd {
3868            fragment,
3869            target: cmd.target.clone(),
3870            policy: ScoreFragmentPastePolicy::Replace,
3871        },
3872        score,
3873    )?;
3874    if !cmd.move_source {
3875        return Ok(());
3876    }
3877
3878    let settings_time_signature = score.settings.time_signature.clone();
3879    let staff = score
3880        .parts
3881        .get_mut(cmd.source_start.part)
3882        .ok_or(Error::PartNotFound(cmd.source_start.part))?
3883        .staves
3884        .get_mut(cmd.source_start.staff)
3885        .ok_or(Error::StaffNotFound(cmd.source_start.staff))?;
3886    if source_to >= staff.measures.len() {
3887        return Err(Error::MeasureNotFound(source_to));
3888    }
3889    for measure in &mut staff.measures[source_from..=source_to] {
3890        let signature = measure
3891            .time_sig
3892            .as_ref()
3893            .unwrap_or(&settings_time_signature);
3894        let mut replacement = Vec::new();
3895        pad_voice_to_measure(&mut replacement, signature.total_beats());
3896        measure.voices[cmd.source_start.voice] = replacement;
3897        measure.source_voice_numbers[cmd.source_start.voice] = None;
3898    }
3899    prune_orphaned_spanners(score);
3900    Ok(())
3901}
3902
3903fn effective_staff_measure_beats(
3904    staff: &Staff,
3905    default_time_signature: &TimeSignature,
3906    measure_index: usize,
3907) -> Result<f64, Error> {
3908    let mut time_signature = default_time_signature.clone();
3909    for measure in staff.measures.iter().take(measure_index.saturating_add(1)) {
3910        if let Some(signature) = &measure.time_sig {
3911            time_signature = signature.clone();
3912        }
3913    }
3914    if staff.measures.get(measure_index).is_none() {
3915        return Err(Error::MeasureNotFound(measure_index));
3916    }
3917    Ok(time_signature.total_beats())
3918}
3919
3920fn normalized_structural_measure_range(start: usize, end: usize) -> (usize, usize) {
3921    (start.min(end), start.max(end))
3922}
3923
3924fn apply_implode_staves(cmd: &ImplodeStavesCmd, score: &mut Score) -> Result<(), Error> {
3925    if !(2..=4).contains(&cmd.source_staves.len()) {
3926        return Err(Error::InvalidCommand(
3927            "implode requires two to four source staves".into(),
3928        ));
3929    }
3930    let mut unique_staves = BTreeSet::new();
3931    for &staff_index in &cmd.source_staves {
3932        if !unique_staves.insert(staff_index) {
3933            return Err(Error::InvalidCommand(
3934                "implode source staves must be distinct".into(),
3935            ));
3936        }
3937    }
3938    if !unique_staves.contains(&cmd.target_staff) {
3939        return Err(Error::InvalidCommand(
3940            "implode target staff must be included in source staves".into(),
3941        ));
3942    }
3943    let (start, end) = normalized_structural_measure_range(cmd.start_measure, cmd.end_measure);
3944    let part = score
3945        .parts
3946        .get(cmd.part_index)
3947        .ok_or(Error::PartNotFound(cmd.part_index))?;
3948    for &staff_index in &cmd.source_staves {
3949        if part.staves.get(staff_index).is_none() {
3950            return Err(Error::StaffNotFound(staff_index));
3951        }
3952    }
3953
3954    // Validate all ranges before mutation.  This is intentionally stricter than
3955    // layout validation: a source secondary voice or a cross-staff placement
3956    // would otherwise need a policy that risks silently changing semantics.
3957    for measure_index in start..=end {
3958        let mut expected_beats: Option<f64> = None;
3959        for &staff_index in &cmd.source_staves {
3960            let staff = &part.staves[staff_index];
3961            let measure = staff
3962                .measures
3963                .get(measure_index)
3964                .ok_or(Error::MeasureNotFound(measure_index))?;
3965            let beats = effective_staff_measure_beats(
3966                staff,
3967                &score.settings.time_signature,
3968                measure_index,
3969            )?;
3970            if let Some(expected) = expected_beats
3971                && (expected - beats).abs() > 1e-9
3972            {
3973                return Err(Error::InvalidCommand(
3974                    "implode source staves must have matching measure durations".into(),
3975                ));
3976            }
3977            expected_beats = Some(beats);
3978            if measure.voices[1..].iter().any(|voice| !voice.is_empty()) {
3979                return Err(Error::InvalidCommand(
3980                    "implode requires empty secondary source voices".into(),
3981                ));
3982            }
3983            if measure.voices[0]
3984                .iter()
3985                .any(|note| note.cross_staff.is_some())
3986            {
3987                return Err(Error::InvalidCommand(
3988                    "implode does not support cross-staff source notes".into(),
3989                ));
3990            }
3991            let total: f64 = measure.voices[0].iter().map(Note::beats).sum();
3992            if total > beats + 1e-9 {
3993                return Err(Error::InvalidCommand(
3994                    "implode source voice exceeds its measure duration".into(),
3995                ));
3996            }
3997        }
3998    }
3999
4000    for measure_index in start..=end {
4001        let transferred: Vec<(Vec<Note>, Option<u32>)> = cmd
4002            .source_staves
4003            .iter()
4004            .map(|&staff_index| {
4005                let measure =
4006                    &score.parts[cmd.part_index].staves[staff_index].measures[measure_index];
4007                (measure.voices[0].clone(), measure.source_voice_numbers[0])
4008            })
4009            .collect();
4010        {
4011            let target =
4012                &mut score.parts[cmd.part_index].staves[cmd.target_staff].measures[measure_index];
4013            target.voices = [Vec::new(), Vec::new(), Vec::new(), Vec::new()];
4014            target.source_voice_numbers = [None; 4];
4015            for (voice_index, (notes, source_voice_number)) in transferred.into_iter().enumerate() {
4016                target.voices[voice_index] = notes;
4017                target.source_voice_numbers[voice_index] = source_voice_number;
4018            }
4019        }
4020        for &staff_index in &cmd.source_staves {
4021            if staff_index == cmd.target_staff {
4022                continue;
4023            }
4024            let beats = effective_staff_measure_beats(
4025                &score.parts[cmd.part_index].staves[staff_index],
4026                &score.settings.time_signature,
4027                measure_index,
4028            )?;
4029            let mut rest_voice = Vec::new();
4030            pad_voice_to_measure(&mut rest_voice, beats);
4031            let measure =
4032                &mut score.parts[cmd.part_index].staves[staff_index].measures[measure_index];
4033            measure.voices = [rest_voice, Vec::new(), Vec::new(), Vec::new()];
4034            measure.source_voice_numbers = [None; 4];
4035        }
4036    }
4037    remap_spanners(score, |address| {
4038        if address.part != cmd.part_index || address.measure < start || address.measure > end {
4039            return Some(address.clone());
4040        }
4041        let voice = cmd
4042            .source_staves
4043            .iter()
4044            .position(|&staff| staff == address.staff);
4045        if address.voice != 0 {
4046            return Some(address.clone());
4047        }
4048        voice
4049            .map(|voice| NoteAddr {
4050                staff: cmd.target_staff,
4051                voice,
4052                ..address.clone()
4053            })
4054            .or_else(|| Some(address.clone()))
4055    });
4056    Ok(())
4057}
4058
4059fn apply_explode_voices(cmd: &ExplodeVoicesCmd, score: &mut Score) -> Result<(), Error> {
4060    if !(2..=4).contains(&cmd.target_staves.len()) {
4061        return Err(Error::InvalidCommand(
4062            "explode requires two to four target staves".into(),
4063        ));
4064    }
4065    if cmd.target_staves.first() != Some(&cmd.source_staff) {
4066        return Err(Error::InvalidCommand(
4067            "explode target staves must begin with the source staff".into(),
4068        ));
4069    }
4070    let mut unique_staves = BTreeSet::new();
4071    for &staff_index in &cmd.target_staves {
4072        if !unique_staves.insert(staff_index) {
4073            return Err(Error::InvalidCommand(
4074                "explode target staves must be distinct".into(),
4075            ));
4076        }
4077    }
4078    let (start, end) = normalized_structural_measure_range(cmd.start_measure, cmd.end_measure);
4079    let part = score
4080        .parts
4081        .get(cmd.part_index)
4082        .ok_or(Error::PartNotFound(cmd.part_index))?;
4083    let source = part
4084        .staves
4085        .get(cmd.source_staff)
4086        .ok_or(Error::StaffNotFound(cmd.source_staff))?;
4087    for &staff_index in &cmd.target_staves {
4088        if part.staves.get(staff_index).is_none() {
4089            return Err(Error::StaffNotFound(staff_index));
4090        }
4091    }
4092
4093    for measure_index in start..=end {
4094        let expected =
4095            effective_staff_measure_beats(source, &score.settings.time_signature, measure_index)?;
4096        let source_measure = source
4097            .measures
4098            .get(measure_index)
4099            .ok_or(Error::MeasureNotFound(measure_index))?;
4100        for voice_index in cmd.target_staves.len()..4 {
4101            if !source_measure.voices[voice_index].is_empty() {
4102                return Err(Error::InvalidCommand(
4103                    "explode would discard a source voice without a target staff".into(),
4104                ));
4105            }
4106        }
4107        for voice_index in 0..cmd.target_staves.len() {
4108            if source_measure.voices[voice_index]
4109                .iter()
4110                .any(|note| note.cross_staff.is_some())
4111            {
4112                return Err(Error::InvalidCommand(
4113                    "explode does not support cross-staff source notes".into(),
4114                ));
4115            }
4116            let total: f64 = source_measure.voices[voice_index]
4117                .iter()
4118                .map(Note::beats)
4119                .sum();
4120            if total > expected + 1e-9 {
4121                return Err(Error::InvalidCommand(
4122                    "explode source voice exceeds its measure duration".into(),
4123                ));
4124            }
4125        }
4126        for &staff_index in cmd.target_staves.iter().skip(1) {
4127            let target = &part.staves[staff_index];
4128            let target_beats = effective_staff_measure_beats(
4129                target,
4130                &score.settings.time_signature,
4131                measure_index,
4132            )?;
4133            if (target_beats - expected).abs() > 1e-9 {
4134                return Err(Error::InvalidCommand(
4135                    "explode target staves must have matching measure durations".into(),
4136                ));
4137            }
4138            let measure = target
4139                .measures
4140                .get(measure_index)
4141                .ok_or(Error::MeasureNotFound(measure_index))?;
4142            if measure.voices[0].iter().any(|note| !note.is_rest)
4143                || measure.voices[1..].iter().any(|voice| !voice.is_empty())
4144                || measure.source_voice_numbers.iter().any(Option::is_some)
4145            {
4146                return Err(Error::InvalidCommand(
4147                    "explode destination staff must contain only an unnumbered rest voice".into(),
4148                ));
4149            }
4150        }
4151    }
4152
4153    for measure_index in start..=end {
4154        let transferred: Vec<(Vec<Note>, Option<u32>)> = (0..cmd.target_staves.len())
4155            .map(|voice_index| {
4156                let measure =
4157                    &score.parts[cmd.part_index].staves[cmd.source_staff].measures[measure_index];
4158                (
4159                    measure.voices[voice_index].clone(),
4160                    measure.source_voice_numbers[voice_index],
4161                )
4162            })
4163            .collect();
4164        for (voice_index, &staff_index) in cmd.target_staves.iter().enumerate() {
4165            let (notes, source_voice_number) = &transferred[voice_index];
4166            let target =
4167                &mut score.parts[cmd.part_index].staves[staff_index].measures[measure_index];
4168            target.voices = [notes.clone(), Vec::new(), Vec::new(), Vec::new()];
4169            target.source_voice_numbers = [*source_voice_number, None, None, None];
4170        }
4171    }
4172    remap_spanners(score, |address| {
4173        if address.part != cmd.part_index
4174            || address.staff != cmd.source_staff
4175            || address.measure < start
4176            || address.measure > end
4177            || address.voice >= cmd.target_staves.len()
4178        {
4179            return Some(address.clone());
4180        }
4181        Some(NoteAddr {
4182            staff: cmd.target_staves[address.voice],
4183            voice: 0,
4184            ..address.clone()
4185        })
4186    });
4187    Ok(())
4188}
4189
4190fn clear_derived_chord_note_notation(note: &mut Note) {
4191    note.articulations.clear();
4192    note.dynamic = None;
4193    note.stem_up = None;
4194    note.hairpin_start = None;
4195    note.hairpin_end = false;
4196    note.chord_symbol = None;
4197    note.ottava_start = None;
4198    note.ottava_end = false;
4199    note.lyric = None;
4200    note.additional_lyrics.clear();
4201    note.pedal_start = false;
4202    note.pedal_end = false;
4203    note.slur_start = false;
4204    note.slur_end = false;
4205    note.arpeggiate = None;
4206    note.technique_text = None;
4207    note.glissando_start = false;
4208    note.glissando_end = false;
4209    note.cross_staff = None;
4210    note.fingering = None;
4211    note.fingerings.clear();
4212    note.string_number = None;
4213    note.trill_line_start = false;
4214    note.trill_line_end = false;
4215    note.guitar_technique = None;
4216    note.guitar_bend_alter_cents = None;
4217    note.guitar_bend_curve.clear();
4218}
4219
4220fn exploded_chord_note(source: &Note, pitch_index: usize) -> Note {
4221    if pitch_index == 0 {
4222        let mut retained = source.clone();
4223        if !retained.is_rest {
4224            retained.pitches = vec![source.pitches[0].clone()];
4225            retained.tab_positions = source.tab_positions.first().cloned().into_iter().collect();
4226            retained.tab_position = retained.tab_positions.first().cloned();
4227        }
4228        return retained;
4229    }
4230    let mut derived = source.clone();
4231    derived.id = Uuid::new_v4().to_string();
4232    clear_derived_chord_note_notation(&mut derived);
4233    if derived.is_rest || pitch_index >= source.pitches.len() {
4234        derived.is_rest = true;
4235        derived.is_unpitched = false;
4236        derived.pitches.clear();
4237        derived.tab_position = None;
4238        derived.tab_positions.clear();
4239    } else {
4240        derived.pitches = vec![source.pitches[pitch_index].clone()];
4241        derived.tab_positions = source
4242            .tab_positions
4243            .get(pitch_index)
4244            .cloned()
4245            .into_iter()
4246            .collect();
4247        derived.tab_position = derived.tab_positions.first().cloned();
4248    }
4249    derived
4250}
4251
4252fn apply_explode_chord_pitches(
4253    cmd: &ExplodeChordPitchesCmd,
4254    score: &mut Score,
4255) -> Result<(), Error> {
4256    if !(2..=4).contains(&cmd.target_staves.len()) {
4257        return Err(Error::InvalidCommand(
4258            "chord explode requires two to four target staves".into(),
4259        ));
4260    }
4261    if cmd.target_staves.first() != Some(&cmd.source_staff) {
4262        return Err(Error::InvalidCommand(
4263            "chord explode target staves must begin with the source staff".into(),
4264        ));
4265    }
4266    let mut unique_staves = BTreeSet::new();
4267    for &staff_index in &cmd.target_staves {
4268        if !unique_staves.insert(staff_index) {
4269            return Err(Error::InvalidCommand(
4270                "chord explode target staves must be distinct".into(),
4271            ));
4272        }
4273    }
4274    let (start, end) = normalized_structural_measure_range(cmd.start_measure, cmd.end_measure);
4275    let part = score
4276        .parts
4277        .get(cmd.part_index)
4278        .ok_or(Error::PartNotFound(cmd.part_index))?;
4279    let source = part
4280        .staves
4281        .get(cmd.source_staff)
4282        .ok_or(Error::StaffNotFound(cmd.source_staff))?;
4283    for &staff_index in &cmd.target_staves {
4284        if part.staves.get(staff_index).is_none() {
4285            return Err(Error::StaffNotFound(staff_index));
4286        }
4287    }
4288    for measure_index in start..=end {
4289        let expected =
4290            effective_staff_measure_beats(source, &score.settings.time_signature, measure_index)?;
4291        let source_measure = source
4292            .measures
4293            .get(measure_index)
4294            .ok_or(Error::MeasureNotFound(measure_index))?;
4295        for note in &source_measure.voices[0] {
4296            if note.tuplet.is_some() {
4297                return Err(Error::InvalidCommand(
4298                    "chord explode does not support tuplets".into(),
4299                ));
4300            }
4301            if note.cross_staff.is_some() {
4302                return Err(Error::InvalidCommand(
4303                    "chord explode does not support cross-staff source notes".into(),
4304                ));
4305            }
4306            if note.is_unpitched || note.is_grace || note.is_cue {
4307                return Err(Error::InvalidCommand(
4308                    "chord explode does not support unpitched, grace, or cue notes".into(),
4309                ));
4310            }
4311            if !note.is_rest
4312                && (note.pitches.is_empty() || note.pitches.len() > cmd.target_staves.len())
4313            {
4314                return Err(Error::InvalidCommand(
4315                    "chord explode pitch count must fit target staves".into(),
4316                ));
4317            }
4318        }
4319        let total: f64 = source_measure.voices[0].iter().map(Note::beats).sum();
4320        if total > expected + 1e-9 {
4321            return Err(Error::InvalidCommand(
4322                "chord explode source voice exceeds its measure duration".into(),
4323            ));
4324        }
4325        for &staff_index in cmd.target_staves.iter().skip(1) {
4326            let target = &part.staves[staff_index];
4327            let target_beats = effective_staff_measure_beats(
4328                target,
4329                &score.settings.time_signature,
4330                measure_index,
4331            )?;
4332            if (target_beats - expected).abs() > 1e-9 {
4333                return Err(Error::InvalidCommand(
4334                    "chord explode target staves must have matching measure durations".into(),
4335                ));
4336            }
4337            let measure = target
4338                .measures
4339                .get(measure_index)
4340                .ok_or(Error::MeasureNotFound(measure_index))?;
4341            if measure.voices[0].iter().any(|note| !note.is_rest)
4342                || measure.voices[1..].iter().any(|voice| !voice.is_empty())
4343                || measure.source_voice_numbers.iter().any(Option::is_some)
4344            {
4345                return Err(Error::InvalidCommand(
4346                    "chord explode destination staff must contain only an unnumbered rest voice"
4347                        .into(),
4348                ));
4349            }
4350        }
4351    }
4352    for measure_index in start..=end {
4353        let source_measure =
4354            &score.parts[cmd.part_index].staves[cmd.source_staff].measures[measure_index];
4355        let source_voice_number = source_measure.source_voice_numbers[0];
4356        let mut exploded = vec![Vec::new(); cmd.target_staves.len()];
4357        for note in &source_measure.voices[0] {
4358            for (pitch_index, voice) in exploded.iter_mut().enumerate() {
4359                voice.push(exploded_chord_note(note, pitch_index));
4360            }
4361        }
4362        for (index, &staff_index) in cmd.target_staves.iter().enumerate() {
4363            let target =
4364                &mut score.parts[cmd.part_index].staves[staff_index].measures[measure_index];
4365            target.voices = [exploded[index].clone(), Vec::new(), Vec::new(), Vec::new()];
4366            target.source_voice_numbers = [source_voice_number, None, None, None];
4367        }
4368    }
4369    Ok(())
4370}
4371
4372fn scaled_duration(duration: &Duration, scale: DurationScale) -> Option<Duration> {
4373    match (duration, scale) {
4374        (Duration::Whole, DurationScale::Double) | (Duration::SixtyFourth, DurationScale::Half) => {
4375            None
4376        }
4377        (Duration::Whole, DurationScale::Half) | (Duration::Half, DurationScale::Double) => {
4378            Some(Duration::Half)
4379        }
4380        (Duration::Half, DurationScale::Half) | (Duration::Quarter, DurationScale::Double) => {
4381            Some(Duration::Quarter)
4382        }
4383        (Duration::Quarter, DurationScale::Half) | (Duration::Eighth, DurationScale::Double) => {
4384            Some(Duration::Eighth)
4385        }
4386        (Duration::Eighth, DurationScale::Half) | (Duration::Sixteenth, DurationScale::Double) => {
4387            Some(Duration::Sixteenth)
4388        }
4389        (Duration::Sixteenth, DurationScale::Half)
4390        | (Duration::ThirtySecond, DurationScale::Double) => Some(Duration::ThirtySecond),
4391        (Duration::ThirtySecond, DurationScale::Half)
4392        | (Duration::SixtyFourth, DurationScale::Double) => Some(Duration::SixtyFourth),
4393    }
4394}
4395
4396fn uniform_tuplet_ratio(voice: &[Note]) -> Result<Option<TupletInfo>, Error> {
4397    let mut ratio: Option<TupletInfo> = None;
4398    for note in voice {
4399        let Some(tuplet) = &note.tuplet else {
4400            continue;
4401        };
4402        if tuplet.actual_notes == 0 || tuplet.normal_notes == 0 {
4403            return Err(Error::InvalidCommand(
4404                "duration scaling requires a non-zero tuplet ratio".into(),
4405            ));
4406        }
4407        if let Some(existing) = &ratio {
4408            if existing != tuplet {
4409                return Err(Error::InvalidCommand(
4410                    "duration scaling requires one shared tuplet ratio per voice".into(),
4411                ));
4412            }
4413        } else {
4414            ratio = Some(tuplet.clone());
4415        }
4416    }
4417    Ok(ratio)
4418}
4419
4420fn pad_voice_to_measure_with_tuplet_ratio(
4421    voice: &mut Vec<Note>,
4422    max_beats: f64,
4423    ratio: &TupletInfo,
4424) -> Result<(), Error> {
4425    let mut used: f64 = voice.iter().map(Note::beats).sum();
4426    let ratio_scale = f64::from(ratio.normal_notes) / f64::from(ratio.actual_notes);
4427    while max_beats - used > 1e-9 {
4428        let remaining = max_beats - used;
4429        let duration = [
4430            Duration::Whole,
4431            Duration::Half,
4432            Duration::Quarter,
4433            Duration::Eighth,
4434            Duration::Sixteenth,
4435            Duration::ThirtySecond,
4436            Duration::SixtyFourth,
4437        ]
4438        .into_iter()
4439        .find(|duration| duration.beats(0) * ratio_scale <= remaining + 1e-9)
4440        .ok_or_else(|| {
4441            Error::InvalidCommand(
4442                "duration scaling cannot represent the remaining tuplet duration".into(),
4443            )
4444        })?;
4445        let mut rest = Note::rest(duration);
4446        rest.tuplet = Some(ratio.clone());
4447        used += rest.beats();
4448        voice.push(rest);
4449    }
4450    Ok(())
4451}
4452
4453fn apply_scale_voice_range(cmd: &ScaleVoiceRangeCmd, score: &mut Score) -> Result<(), Error> {
4454    if cmd.voice >= 4 {
4455        return Err(Error::VoiceOutOfRange(cmd.voice));
4456    }
4457    let (start, end) = normalized_structural_measure_range(cmd.start_measure, cmd.end_measure);
4458    let staff = score
4459        .parts
4460        .get(cmd.part_index)
4461        .ok_or(Error::PartNotFound(cmd.part_index))?
4462        .staves
4463        .get(cmd.staff_index)
4464        .ok_or(Error::StaffNotFound(cmd.staff_index))?;
4465    for measure_index in start..=end {
4466        let measure = staff
4467            .measures
4468            .get(measure_index)
4469            .ok_or(Error::MeasureNotFound(measure_index))?;
4470        let expected =
4471            effective_staff_measure_beats(staff, &score.settings.time_signature, measure_index)?;
4472        let voice = &measure.voices[cmd.voice];
4473        match cmd.tuplet_policy {
4474            TupletScalePolicy::PreserveRatio => {
4475                uniform_tuplet_ratio(voice)?;
4476            }
4477        }
4478        let mut scaled_beats = 0.0;
4479        for note in voice {
4480            if scaled_duration(&note.duration, cmd.scale).is_none() {
4481                return Err(Error::InvalidCommand(
4482                    "duration scaling exceeds the portable duration range".into(),
4483                ));
4484            }
4485            if !note.is_grace && !note.is_cue {
4486                scaled_beats += match cmd.scale {
4487                    DurationScale::Half => note.beats() / 2.0,
4488                    DurationScale::Double => note.beats() * 2.0,
4489                };
4490            }
4491        }
4492        if scaled_beats > expected + 1e-9 {
4493            return Err(Error::InvalidCommand(format!(
4494                "duration scaling overflows measure {measure_index}: {scaled_beats} beats exceeds {expected}"
4495            )));
4496        }
4497    }
4498
4499    for measure_index in start..=end {
4500        let expected = effective_staff_measure_beats(
4501            &score.parts[cmd.part_index].staves[cmd.staff_index],
4502            &score.settings.time_signature,
4503            measure_index,
4504        )?;
4505        let voice = &mut score.parts[cmd.part_index].staves[cmd.staff_index].measures
4506            [measure_index]
4507            .voices[cmd.voice];
4508        if voice.is_empty() {
4509            continue;
4510        }
4511        let tuplet_ratio = match cmd.tuplet_policy {
4512            TupletScalePolicy::PreserveRatio => uniform_tuplet_ratio(voice)?,
4513        };
4514        for note in voice.iter_mut() {
4515            note.duration = scaled_duration(&note.duration, cmd.scale).ok_or_else(|| {
4516                Error::InvalidCommand("duration scaling exceeds the portable duration range".into())
4517            })?;
4518        }
4519        if let Some(ratio) = tuplet_ratio {
4520            pad_voice_to_measure_with_tuplet_ratio(voice, expected, &ratio)?;
4521        } else {
4522            pad_voice_to_measure(voice, expected);
4523        }
4524    }
4525    Ok(())
4526}
4527
4528fn apply_paste_score_fragment(cmd: &PasteScoreFragmentCmd, score: &mut Score) -> Result<(), Error> {
4529    if !(MIN_SUPPORTED_SCORE_FRAGMENT_CONTRACT_VERSION..=SCORE_FRAGMENT_CONTRACT_VERSION)
4530        .contains(&cmd.fragment.contract_version)
4531    {
4532        return Err(Error::InvalidCommand(format!(
4533            "unsupported score fragment contract version {}",
4534            cmd.fragment.contract_version
4535        )));
4536    }
4537    if cmd.fragment.voices.is_empty() {
4538        return Err(Error::InvalidCommand(
4539            "cannot paste an empty score fragment".into(),
4540        ));
4541    }
4542
4543    // Resolve every target before mutating the score. CommandStack then also
4544    // validates the complete candidate before adding history, preserving
4545    // score/history atomicity for invalid destinations.
4546    let mut lanes = BTreeSet::new();
4547    let mut replaced = BTreeSet::new();
4548    for lane in &cmd.fragment.voices {
4549        let part_index = cmd
4550            .target
4551            .part
4552            .checked_add(lane.relative_part)
4553            .ok_or_else(|| Error::InvalidCommand("fragment part target overflows".into()))?;
4554        let staff_index = cmd
4555            .target
4556            .staff
4557            .checked_add(lane.relative_staff)
4558            .ok_or_else(|| Error::InvalidCommand("fragment staff target overflows".into()))?;
4559        let voice_index = cmd
4560            .target
4561            .voice
4562            .checked_add(lane.relative_voice)
4563            .ok_or_else(|| Error::InvalidCommand("fragment voice target overflows".into()))?;
4564        if voice_index >= 4 {
4565            return Err(Error::VoiceOutOfRange(voice_index));
4566        }
4567        let staff = score
4568            .parts
4569            .get(part_index)
4570            .ok_or(Error::PartNotFound(part_index))?
4571            .staves
4572            .get(staff_index)
4573            .ok_or(Error::StaffNotFound(staff_index))?;
4574        if !lanes.insert((part_index, staff_index, voice_index)) {
4575            return Err(Error::InvalidCommand(
4576                "fragment contains duplicate destination voice lanes".into(),
4577            ));
4578        }
4579        for measure in &lane.measures {
4580            let measure_index = cmd
4581                .target
4582                .measure
4583                .checked_add(measure.relative_measure)
4584                .ok_or_else(|| Error::InvalidCommand("fragment measure target overflows".into()))?;
4585            if measure_index >= staff.measures.len() {
4586                return Err(Error::MeasureNotFound(measure_index));
4587            }
4588            if !measure.cross_staff_targets.is_empty()
4589                && measure.cross_staff_targets.len() != measure.notes.len()
4590            {
4591                return Err(Error::InvalidCommand(
4592                    "fragment cross-staff targets do not match note count".into(),
4593                ));
4594            }
4595            if cmd.policy == ScoreFragmentPastePolicy::Merge
4596                && !measure.notes.is_empty()
4597                && staff.measures[measure_index].voices[voice_index]
4598                    .iter()
4599                    .any(|note| !note.is_rest)
4600            {
4601                return Err(Error::InvalidCommand(
4602                    "fragment merge would overwrite a sounding destination lane".into(),
4603                ));
4604            }
4605            if cmd.policy == ScoreFragmentPastePolicy::Merge
4606                && measure.attributes.present
4607                && measure.attributes
4608                    != super::fragment::ScoreFragmentMeasureAttributes::from_measure(
4609                        &staff.measures[measure_index],
4610                    )
4611            {
4612                return Err(Error::InvalidCommand(
4613                    "fragment merge would overwrite destination measure attributes".into(),
4614                ));
4615            }
4616            for cross_staff in measure.cross_staff_targets.iter().flatten() {
4617                let target_staff = staff_index as i64 + cross_staff.staff_offset;
4618                if target_staff < 0
4619                    || score.parts[part_index]
4620                        .staves
4621                        .get(target_staff as usize)
4622                        .is_none()
4623                {
4624                    return Err(Error::InvalidCommand(
4625                        "fragment cross-staff target is outside destination part".into(),
4626                    ));
4627                }
4628                if cross_staff.target_voice.is_some_and(|voice| voice >= 4) {
4629                    return Err(Error::InvalidCommand(
4630                        "fragment cross-staff target voice is outside editable range".into(),
4631                    ));
4632                }
4633            }
4634            replaced.insert((part_index, staff_index, measure_index, voice_index));
4635        }
4636    }
4637
4638    // Replacing a lane invalidates its former typed-span endpoints. Remove
4639    // those spans explicitly rather than allowing legacy endpoint flags to
4640    // leave dangling canonical addresses.
4641    score.spanners.retain(|spanner| {
4642        !replaced.contains(&(
4643            spanner.start.part,
4644            spanner.start.staff,
4645            spanner.start.measure,
4646            spanner.start.voice,
4647        )) && !replaced.contains(&(
4648            spanner.end.part,
4649            spanner.end.staff,
4650            spanner.end.measure,
4651            spanner.end.voice,
4652        ))
4653    });
4654    for lane in &cmd.fragment.voices {
4655        let part_index = cmd.target.part + lane.relative_part;
4656        let staff_index = cmd.target.staff + lane.relative_staff;
4657        let voice_index = cmd.target.voice + lane.relative_voice;
4658        let staff = &mut score.parts[part_index].staves[staff_index];
4659        for measure in &lane.measures {
4660            let measure_index = cmd.target.measure + measure.relative_measure;
4661            let target = &mut staff.measures[measure_index];
4662            if cmd.policy == ScoreFragmentPastePolicy::Merge && measure.notes.is_empty() {
4663                continue;
4664            }
4665            let mut notes = measure.notes.clone();
4666            for note in &mut notes {
4667                note.id = Uuid::new_v4().to_string();
4668            }
4669            if !measure.cross_staff_targets.is_empty() {
4670                for (note, cross_staff) in notes.iter_mut().zip(&measure.cross_staff_targets) {
4671                    note.cross_staff = cross_staff.as_ref().map(|cross_staff| CrossStaff {
4672                        target_staff: (staff_index as i64 + cross_staff.staff_offset) as usize,
4673                        target_voice: cross_staff.target_voice,
4674                    });
4675                }
4676            }
4677            target.voices[voice_index] = notes;
4678            target.source_voice_numbers[voice_index] = measure.source_voice_number;
4679            if cmd.policy == ScoreFragmentPastePolicy::Replace {
4680                measure.attributes.apply_to_measure(target);
4681            }
4682        }
4683    }
4684
4685    let mut used_spanner_ids = score
4686        .spanners
4687        .iter()
4688        .map(|spanner| spanner.id.clone())
4689        .collect::<BTreeSet<_>>();
4690    for source in &cmd.fragment.spanners {
4691        let mut copied = source.clone();
4692        copied.start = fragment_destination_address(&cmd.target, &source.start)?;
4693        copied.end = fragment_destination_address(&cmd.target, &source.end)?;
4694        if note_at(score, &copied.start).is_none() {
4695            return Err(Error::NoteNotFound(copied.start.note));
4696        }
4697        if note_at(score, &copied.end).is_none() {
4698            return Err(Error::NoteNotFound(copied.end.note));
4699        }
4700        copied.id = unique_fragment_spanner_id(&used_spanner_ids, &source.id);
4701        used_spanner_ids.insert(copied.id.clone());
4702        score.spanners.push(copied);
4703    }
4704    Ok(())
4705}
4706
4707fn fragment_destination_address(target: &NoteAddr, relative: &NoteAddr) -> Result<NoteAddr, Error> {
4708    Ok(NoteAddr {
4709        part: target
4710            .part
4711            .checked_add(relative.part)
4712            .ok_or_else(|| Error::InvalidCommand("fragment part target overflows".into()))?,
4713        staff: target
4714            .staff
4715            .checked_add(relative.staff)
4716            .ok_or_else(|| Error::InvalidCommand("fragment staff target overflows".into()))?,
4717        measure: target
4718            .measure
4719            .checked_add(relative.measure)
4720            .ok_or_else(|| Error::InvalidCommand("fragment measure target overflows".into()))?,
4721        voice: target
4722            .voice
4723            .checked_add(relative.voice)
4724            .ok_or_else(|| Error::InvalidCommand("fragment voice target overflows".into()))?,
4725        note: relative.note,
4726    })
4727}
4728
4729fn unique_fragment_spanner_id(used: &BTreeSet<String>, source_id: &str) -> String {
4730    let base = format!("{source_id}-copy");
4731    if !used.contains(&base) {
4732        return base;
4733    }
4734    let mut suffix = 2usize;
4735    loop {
4736        let candidate = format!("{base}-{suffix}");
4737        if !used.contains(&candidate) {
4738            return candidate;
4739        }
4740        suffix += 1;
4741    }
4742}
4743
4744fn apply_toggle_slur(cmd: &ToggleSlurCmd, score: &mut Score) -> Result<(), Error> {
4745    let new_start = !{
4746        score
4747            .parts
4748            .get(cmd.start.part)
4749            .ok_or(Error::PartNotFound(cmd.start.part))?
4750            .staves
4751            .get(cmd.start.staff)
4752            .ok_or(Error::StaffNotFound(cmd.start.staff))?
4753            .measures
4754            .get(cmd.start.measure)
4755            .ok_or(Error::MeasureNotFound(cmd.start.measure))?
4756            .voices
4757            .get(cmd.start.voice)
4758            .ok_or(Error::VoiceOutOfRange(cmd.start.voice))?
4759            .get(cmd.start.note)
4760            .ok_or(Error::NoteNotFound(cmd.start.note))?
4761            .slur_start
4762    };
4763    let new_end = !{
4764        score
4765            .parts
4766            .get(cmd.end.part)
4767            .ok_or(Error::PartNotFound(cmd.end.part))?
4768            .staves
4769            .get(cmd.end.staff)
4770            .ok_or(Error::StaffNotFound(cmd.end.staff))?
4771            .measures
4772            .get(cmd.end.measure)
4773            .ok_or(Error::MeasureNotFound(cmd.end.measure))?
4774            .voices
4775            .get(cmd.end.voice)
4776            .ok_or(Error::VoiceOutOfRange(cmd.end.voice))?
4777            .get(cmd.end.note)
4778            .ok_or(Error::NoteNotFound(cmd.end.note))?
4779            .slur_end
4780    };
4781    score.parts[cmd.start.part].staves[cmd.start.staff].measures[cmd.start.measure].voices
4782        [cmd.start.voice][cmd.start.note]
4783        .slur_start = new_start;
4784    score.parts[cmd.end.part].staves[cmd.end.staff].measures[cmd.end.measure].voices
4785        [cmd.end.voice][cmd.end.note]
4786        .slur_end = new_end;
4787    Ok(())
4788}
4789
4790fn apply_toggle_trill_line(cmd: &ToggleTrillLineCmd, score: &mut Score) -> Result<(), Error> {
4791    let new_start = !{
4792        score
4793            .parts
4794            .get(cmd.start.part)
4795            .ok_or(Error::PartNotFound(cmd.start.part))?
4796            .staves
4797            .get(cmd.start.staff)
4798            .ok_or(Error::StaffNotFound(cmd.start.staff))?
4799            .measures
4800            .get(cmd.start.measure)
4801            .ok_or(Error::MeasureNotFound(cmd.start.measure))?
4802            .voices
4803            .get(cmd.start.voice)
4804            .ok_or(Error::VoiceOutOfRange(cmd.start.voice))?
4805            .get(cmd.start.note)
4806            .ok_or(Error::NoteNotFound(cmd.start.note))?
4807            .trill_line_start
4808    };
4809    let new_end = !{
4810        score
4811            .parts
4812            .get(cmd.end.part)
4813            .ok_or(Error::PartNotFound(cmd.end.part))?
4814            .staves
4815            .get(cmd.end.staff)
4816            .ok_or(Error::StaffNotFound(cmd.end.staff))?
4817            .measures
4818            .get(cmd.end.measure)
4819            .ok_or(Error::MeasureNotFound(cmd.end.measure))?
4820            .voices
4821            .get(cmd.end.voice)
4822            .ok_or(Error::VoiceOutOfRange(cmd.end.voice))?
4823            .get(cmd.end.note)
4824            .ok_or(Error::NoteNotFound(cmd.end.note))?
4825            .trill_line_end
4826    };
4827    score.parts[cmd.start.part].staves[cmd.start.staff].measures[cmd.start.measure].voices
4828        [cmd.start.voice][cmd.start.note]
4829        .trill_line_start = new_start;
4830    score.parts[cmd.end.part].staves[cmd.end.staff].measures[cmd.end.measure].voices
4831        [cmd.end.voice][cmd.end.note]
4832        .trill_line_end = new_end;
4833    Ok(())
4834}
4835
4836fn apply_add_staff(cmd: &AddStaffCmd, score: &mut Score) -> Result<(), Error> {
4837    let ts = score.settings.time_signature.clone();
4838    let measure_count = score
4839        .parts
4840        .get(cmd.part_index)
4841        .ok_or(Error::PartNotFound(cmd.part_index))?
4842        .staves
4843        .first()
4844        .map_or(0, |s| s.measures.len());
4845    let mut staff = Staff::new(cmd.clef.clone());
4846    for i in 0..measure_count {
4847        let mut m = Measure::empty(ts.numerator, ts.denominator);
4848        m.number = i as u32 + 1;
4849        staff.measures.push(m);
4850    }
4851    score.parts[cmd.part_index].staves.push(staff);
4852    Ok(())
4853}
4854
4855fn apply_delete_staff(cmd: &DeleteStaffCmd, score: &mut Score) -> Result<(), Error> {
4856    let part = score
4857        .parts
4858        .get_mut(cmd.part_index)
4859        .ok_or(Error::PartNotFound(cmd.part_index))?;
4860    if part.staves.len() <= 1 {
4861        return Err(Error::CannotDeleteLastStaff);
4862    }
4863    if cmd.staff_index >= part.staves.len() {
4864        return Err(Error::StaffNotFound(cmd.staff_index));
4865    }
4866    part.staves.remove(cmd.staff_index);
4867    remap_spanners(score, |address| {
4868        if address.part != cmd.part_index {
4869            Some(address.clone())
4870        } else if address.staff == cmd.staff_index {
4871            None
4872        } else if address.staff > cmd.staff_index {
4873            let mut shifted = address.clone();
4874            shifted.staff -= 1;
4875            Some(shifted)
4876        } else {
4877            Some(address.clone())
4878        }
4879    });
4880    Ok(())
4881}
4882
4883/// Returns true when an address refers to the given canonical voice.
4884fn same_voice(address: &NoteAddr, part: usize, staff: usize, measure: usize, voice: usize) -> bool {
4885    address.part == part
4886        && address.staff == staff
4887        && address.measure == measure
4888        && address.voice == voice
4889}
4890
4891fn trim_voice_to_measure(voice: &mut Vec<Note>, max_beats: f64) {
4892    // A lone plain whole rest is a measure rest and already fills any measure.
4893    if matches!(voice.as_slice(), [only] if only.is_plain_whole_rest()) {
4894        return;
4895    }
4896    let mut total = 0.0f64;
4897    let mut cutoff = voice.len();
4898    for (i, n) in voice.iter().enumerate() {
4899        total += n.beats();
4900        if total > max_beats + 1e-9 {
4901            cutoff = i;
4902            break;
4903        }
4904    }
4905    voice.truncate(cutoff);
4906    pad_voice_to_measure(voice, max_beats);
4907}
4908
4909fn pad_voice_to_measure(voice: &mut Vec<Note>, max_beats: f64) {
4910    let mut used = crate::voice_duration_beats(voice, max_beats);
4911    while max_beats - used > 1e-9 {
4912        let remaining = max_beats - used;
4913        let rest = Note::rest(Duration::whole_filling_beats(remaining));
4914        used += rest.beats();
4915        voice.push(rest);
4916    }
4917}
4918
4919#[cfg(test)]
4920mod tests {
4921    use super::*;
4922    use crate::ScoreEngine;
4923    use crate::model::pitch::Step;
4924
4925    fn default_engine_score() -> Score {
4926        let mut s = Score::default();
4927        for part in &mut s.parts {
4928            for staff in &mut part.staves {
4929                for (i, m) in staff.measures.iter_mut().enumerate() {
4930                    m.number = i as u32 + 1;
4931                }
4932            }
4933        }
4934        s
4935    }
4936
4937    #[test]
4938    fn legacy_scale_voice_range_command_defaults_to_preserve_tuplet_ratio() {
4939        let command: Command = serde_json::from_str(
4940            r#"{"type":"scale_voice_range","part_index":0,"staff_index":0,"voice":0,"start_measure":0,"end_measure":0,"scale":"half"}"#,
4941        )
4942        .expect("legacy scale command deserializes");
4943        let Command::ScaleVoiceRange(command) = command else {
4944            panic!("expected scale command");
4945        };
4946        assert_eq!(command.tuplet_policy, TupletScalePolicy::PreserveRatio);
4947    }
4948
4949    fn score_with_typed_spanner() -> Score {
4950        use crate::model::score::{NotationSpanner, NotationSpannerKind};
4951
4952        let mut score = Score::new("Spanner edits", 120, 4, 4, 0, 1);
4953        score.parts[0].staves[0].measures[0].voices[0] = (0..4)
4954            .map(|offset| Note::new(Pitch::new(Step::C, 4 + offset), Duration::Quarter))
4955            .collect();
4956        score.spanners.push(NotationSpanner {
4957            id: "typed-glissando".to_string(),
4958            kind: NotationSpannerKind::Glissando,
4959            start: NoteAddr {
4960                part: 0,
4961                staff: 0,
4962                measure: 0,
4963                voice: 0,
4964                note: 1,
4965            },
4966            end: NoteAddr {
4967                part: 0,
4968                staff: 0,
4969                measure: 0,
4970                voice: 0,
4971                note: 2,
4972            },
4973            number: Some(1),
4974            line_type: None,
4975            text: None,
4976            placement: None,
4977            ottava_size: None,
4978            ottava_type: None,
4979        });
4980        score
4981    }
4982
4983    fn insert_note_at(position: usize) -> Command {
4984        Command::AddNote(AddNoteCmd {
4985            part_index: 0,
4986            staff_index: 0,
4987            measure_index: 0,
4988            voice: 0,
4989            position,
4990            pitch: Some(Pitch::new(Step::D, 5)),
4991            duration: Duration::Quarter,
4992            dot_count: 0,
4993            is_rest: false,
4994            tuplet: None,
4995        })
4996    }
4997
4998    #[test]
4999    fn add_note_inserts_into_voice() {
5000        let mut score = default_engine_score();
5001        let cmd = Command::AddNote(AddNoteCmd {
5002            part_index: 0,
5003            staff_index: 0,
5004            measure_index: 0,
5005            voice: 0,
5006            position: 0,
5007            pitch: Some(Pitch::new(Step::C, 4)),
5008            duration: Duration::Quarter,
5009            dot_count: 0,
5010            is_rest: false,
5011            tuplet: None,
5012        });
5013        apply_command(&cmd, &mut score).unwrap();
5014        let first = &score.parts[0].staves[0].measures[0].voices[0][0];
5015        assert!(!first.is_rest);
5016        assert_eq!(first.pitches[0].step, Step::C);
5017    }
5018
5019    #[test]
5020    fn typed_spanners_follow_note_insertions_at_every_relative_position() {
5021        for (position, expected_start, expected_end) in [
5022            (0, 2, 3), // before the span
5023            (1, 2, 3), // at its start endpoint
5024            (2, 1, 3), // inside the span
5025            (4, 1, 2), // at the end of the voice
5026            (3, 1, 2), // after the span
5027        ] {
5028            let mut score = score_with_typed_spanner();
5029            apply_command(&insert_note_at(position), &mut score).unwrap();
5030            let span = score.spanners.first().expect("span remains attached");
5031            assert_eq!(span.start.note, expected_start, "position {position}");
5032            assert_eq!(span.end.note, expected_end, "position {position}");
5033        }
5034    }
5035
5036    #[test]
5037    fn deleting_a_typed_spanner_endpoint_removes_the_whole_span_and_undo_redo_is_atomic() {
5038        let mut score = score_with_typed_spanner();
5039        let endpoint_id = score.parts[0].staves[0].measures[0].voices[0][1].id.clone();
5040        let mut stack = CommandStack::new(8);
5041
5042        stack
5043            .execute(
5044                Command::DeleteNote(DeleteNoteCmd {
5045                    note_id: endpoint_id,
5046                    part_index: 0,
5047                    staff_index: 0,
5048                    measure_index: 0,
5049                    voice: 0,
5050                }),
5051                &mut score,
5052            )
5053            .unwrap();
5054        assert!(score.spanners.is_empty());
5055
5056        stack.undo(&mut score).unwrap();
5057        assert_eq!(score.spanners[0].start.note, 1);
5058        assert_eq!(score.spanners[0].end.note, 2);
5059
5060        stack.redo(&mut score).unwrap();
5061        assert!(score.spanners.is_empty());
5062    }
5063
5064    #[test]
5065    fn batched_structural_edits_remap_typed_spanners_and_restore_them_on_undo() {
5066        let mut score = score_with_typed_spanner();
5067        let mut stack = CommandStack::new(8);
5068        stack
5069            .batch_execute(
5070                vec![
5071                    insert_note_at(0),
5072                    Command::SetTempo(SetTempoCmd { bpm: 144 }),
5073                ],
5074                &mut score,
5075            )
5076            .unwrap();
5077        assert_eq!(score.spanners[0].start.note, 2);
5078        assert_eq!(score.spanners[0].end.note, 3);
5079
5080        stack.undo(&mut score).unwrap();
5081        assert_eq!(score.spanners[0].start.note, 1);
5082        assert_eq!(score.spanners[0].end.note, 2);
5083
5084        stack.redo(&mut score).unwrap();
5085        assert_eq!(score.spanners[0].start.note, 2);
5086        assert_eq!(score.spanners[0].end.note, 3);
5087    }
5088
5089    #[test]
5090    fn typed_spanners_remap_only_the_edited_endpoint_across_staff_and_voice() {
5091        use crate::model::score::{NotationSpanner, NotationSpannerKind, ScoreTemplate};
5092
5093        let mut score = Score::template(ScoreTemplate::Piano);
5094        for staff in &mut score.parts[0].staves {
5095            let notes: Vec<Note> = (0..4)
5096                .map(|offset| Note::new(Pitch::new(Step::C, 4 + offset), Duration::Quarter))
5097                .collect();
5098            staff.measures[0].voices[0] = notes.clone();
5099            staff.measures[0].voices[1] = notes;
5100        }
5101        score.spanners.push(NotationSpanner {
5102            id: "cross-staff".to_string(),
5103            kind: NotationSpannerKind::Slur,
5104            start: NoteAddr {
5105                part: 0,
5106                staff: 0,
5107                measure: 0,
5108                voice: 0,
5109                note: 1,
5110            },
5111            end: NoteAddr {
5112                part: 0,
5113                staff: 1,
5114                measure: 0,
5115                voice: 1,
5116                note: 2,
5117            },
5118            number: Some(2),
5119            line_type: None,
5120            text: None,
5121            placement: None,
5122            ottava_size: None,
5123            ottava_type: None,
5124        });
5125
5126        let mut stack = CommandStack::new(8);
5127        stack.execute(insert_note_at(0), &mut score).unwrap();
5128        let span = &score.spanners[0];
5129        assert_eq!(span.start.note, 2);
5130        assert_eq!(span.end.staff, 1);
5131        assert_eq!(span.end.voice, 1);
5132        assert_eq!(span.end.note, 2);
5133    }
5134
5135    #[test]
5136    fn set_fingerings_keeps_first_legacy_value_in_sync() {
5137        let mut score = default_engine_score();
5138        apply_command(
5139            &Command::AddNote(AddNoteCmd {
5140                part_index: 0,
5141                staff_index: 0,
5142                measure_index: 0,
5143                voice: 0,
5144                position: 0,
5145                pitch: Some(Pitch::new(Step::C, 4)),
5146                duration: Duration::Quarter,
5147                dot_count: 0,
5148                is_rest: false,
5149                tuplet: None,
5150            }),
5151            &mut score,
5152        )
5153        .unwrap();
5154        apply_command(
5155            &Command::SetFingerings(SetFingeringsCmd {
5156                part_index: 0,
5157                staff_index: 0,
5158                measure_index: 0,
5159                voice: 0,
5160                note_index: 0,
5161                fingerings: vec![1, 3, 4],
5162            }),
5163            &mut score,
5164        )
5165        .unwrap();
5166        let note = &score.parts[0].staves[0].measures[0].voices[0][0];
5167        assert_eq!(note.fingerings, vec![1, 3, 4]);
5168        assert_eq!(note.fingering, Some(1));
5169    }
5170
5171    #[test]
5172    fn set_figured_bass_replaces_measure_figures() {
5173        let mut score = default_engine_score();
5174        let figures = vec![FiguredBassFigure {
5175            number: "6".to_string(),
5176            alter: Some("-1".to_string()),
5177            prefix: Some("+".to_string()),
5178            suffix: None,
5179            extender: false,
5180        }];
5181        apply_command(
5182            &Command::SetFiguredBass(SetFiguredBassCmd {
5183                measure_index: 0,
5184                figures: figures.clone(),
5185            }),
5186            &mut score,
5187        )
5188        .unwrap();
5189        assert_eq!(score.parts[0].staves[0].measures[0].figured_bass, figures);
5190    }
5191
5192    #[test]
5193    fn set_measure_text_supports_append_replace_remove_and_undo_redo() {
5194        let mut engine = crate::ScoreEngine::new();
5195        let address = SetMeasureTextCmd {
5196            part_index: 0,
5197            staff_index: 0,
5198            measure_index: 0,
5199            text_index: 0,
5200            text: Some(StyledText {
5201                style: crate::TextStyle::Technique,
5202                text: "dolce".to_string(),
5203                placement: None,
5204                offset_x: None,
5205                offset_y: None,
5206                relative_x: None,
5207                relative_y: None,
5208            }),
5209        };
5210        engine.apply(Command::SetMeasureText(address)).unwrap();
5211        assert_eq!(
5212            engine.score.parts[0].staves[0].measures[0].texts[0],
5213            StyledText {
5214                style: crate::TextStyle::Technique,
5215                text: "dolce".to_string(),
5216                placement: None,
5217                offset_x: None,
5218                offset_y: None,
5219                relative_x: None,
5220                relative_y: None,
5221            }
5222        );
5223
5224        engine
5225            .apply(Command::SetMeasureText(SetMeasureTextCmd {
5226                text_index: 0,
5227                text: Some(StyledText {
5228                    style: crate::TextStyle::RehearsalMark,
5229                    text: "A".to_string(),
5230                    placement: None,
5231                    offset_x: None,
5232                    offset_y: None,
5233                    relative_x: None,
5234                    relative_y: None,
5235                }),
5236                ..SetMeasureTextCmd {
5237                    part_index: 0,
5238                    staff_index: 0,
5239                    measure_index: 0,
5240                    text_index: 0,
5241                    text: None,
5242                }
5243            }))
5244            .unwrap();
5245        assert_eq!(
5246            engine.score.parts[0].staves[0].measures[0].texts[0].style,
5247            crate::TextStyle::RehearsalMark
5248        );
5249
5250        engine
5251            .apply(Command::SetMeasureText(SetMeasureTextCmd {
5252                text_index: 0,
5253                text: None,
5254                ..SetMeasureTextCmd {
5255                    part_index: 0,
5256                    staff_index: 0,
5257                    measure_index: 0,
5258                    text_index: 0,
5259                    text: None,
5260                }
5261            }))
5262            .unwrap();
5263        assert!(engine.score.parts[0].staves[0].measures[0].texts.is_empty());
5264        engine.undo().unwrap();
5265        assert_eq!(engine.score.parts[0].staves[0].measures[0].texts.len(), 1);
5266        engine.redo().unwrap();
5267        assert!(engine.score.parts[0].staves[0].measures[0].texts.is_empty());
5268    }
5269
5270    #[test]
5271    fn set_measure_text_rejects_invalid_index_atomically_and_round_trips_json() {
5272        let mut score = default_engine_score();
5273        let before = score.clone();
5274        let command = Command::SetMeasureText(SetMeasureTextCmd {
5275            part_index: 0,
5276            staff_index: 0,
5277            measure_index: 0,
5278            text_index: 2,
5279            text: Some(StyledText {
5280                style: crate::TextStyle::Expression,
5281                text: "espressivo".to_string(),
5282                placement: None,
5283                offset_x: None,
5284                offset_y: None,
5285                relative_x: None,
5286                relative_y: None,
5287            }),
5288        });
5289        let json = serde_json::to_string(&command).unwrap();
5290        let restored: Command = serde_json::from_str(&json).unwrap();
5291        assert_eq!(
5292            serde_json::to_value(&restored).unwrap(),
5293            serde_json::to_value(&command).unwrap()
5294        );
5295        assert!(apply_command(&restored, &mut score).is_err());
5296        assert_eq!(
5297            serde_json::to_value(&score).unwrap(),
5298            serde_json::to_value(&before).unwrap()
5299        );
5300    }
5301
5302    #[test]
5303    fn set_score_text_supports_append_replace_remove_and_undo_redo() {
5304        let mut engine = crate::ScoreEngine::new();
5305        let text = StyledText {
5306            style: crate::TextStyle::Expression,
5307            text: "Title".to_string(),
5308            placement: None,
5309            offset_x: None,
5310            offset_y: None,
5311            relative_x: None,
5312            relative_y: None,
5313        };
5314        engine
5315            .apply(Command::SetScoreText(SetScoreTextCmd {
5316                text_index: 0,
5317                text: Some(text.clone()),
5318            }))
5319            .unwrap();
5320        assert_eq!(engine.score.texts, vec![text.clone()]);
5321
5322        let mut replacement = text.clone();
5323        replacement.text = "Subtitle".to_string();
5324        engine
5325            .apply(Command::SetScoreText(SetScoreTextCmd {
5326                text_index: 0,
5327                text: Some(replacement.clone()),
5328            }))
5329            .unwrap();
5330        assert_eq!(engine.score.texts, vec![replacement]);
5331
5332        engine
5333            .apply(Command::SetScoreText(SetScoreTextCmd {
5334                text_index: 0,
5335                text: None,
5336            }))
5337            .unwrap();
5338        assert!(engine.score.texts.is_empty());
5339        engine.undo().unwrap();
5340        assert_eq!(engine.score.texts.len(), 1);
5341        engine.redo().unwrap();
5342        assert!(engine.score.texts.is_empty());
5343    }
5344
5345    #[test]
5346    fn set_score_style_overrides_is_undoable_and_rejects_invalid_values() {
5347        let mut engine = crate::ScoreEngine::new();
5348        let overrides = vec![ViewStyleOverride {
5349            property: super::super::score::ViewStyleProperty::SystemGap,
5350            value: 2.5,
5351        }];
5352        engine
5353            .apply(Command::SetScoreStyleOverrides(SetScoreStyleOverridesCmd {
5354                overrides: overrides.clone(),
5355            }))
5356            .unwrap();
5357        assert_eq!(engine.score.style_overrides, overrides);
5358        engine.undo().unwrap();
5359        assert!(engine.score.style_overrides.is_empty());
5360        engine.redo().unwrap();
5361        assert_eq!(engine.score.style_overrides, overrides);
5362
5363        let before = engine.score.clone();
5364        assert!(
5365            engine
5366                .apply(Command::SetScoreStyleOverrides(SetScoreStyleOverridesCmd {
5367                    overrides: vec![ViewStyleOverride {
5368                        property: super::super::score::ViewStyleProperty::TextScale,
5369                        value: f32::NAN,
5370                    }],
5371                }))
5372                .is_err()
5373        );
5374        assert_eq!(
5375            serde_json::to_value(&engine.score).unwrap(),
5376            serde_json::to_value(&before).unwrap()
5377        );
5378    }
5379
5380    #[test]
5381    fn set_object_style_overrides_is_undoable_and_requires_existing_target() {
5382        let mut engine = crate::ScoreEngine::new();
5383        engine.score.texts.push(StyledText {
5384            style: crate::TextStyle::Expression,
5385            text: "Allegro".into(),
5386            placement: None,
5387            offset_x: None,
5388            offset_y: None,
5389            relative_x: None,
5390            relative_y: None,
5391        });
5392        let overrides = vec![ObjectStyleOverride {
5393            target: super::super::score::ObjectStyleTarget::ScoreText { text_index: 0 },
5394            property: super::super::score::ViewStyleProperty::TextScale,
5395            value: 1.2,
5396            provenance: None,
5397        }];
5398        engine
5399            .apply(Command::SetObjectStyleOverrides(
5400                SetObjectStyleOverridesCmd {
5401                    overrides: overrides.clone(),
5402                },
5403            ))
5404            .unwrap();
5405        assert_eq!(engine.score.object_style_overrides, overrides);
5406        engine.undo().unwrap();
5407        assert!(engine.score.object_style_overrides.is_empty());
5408
5409        assert!(
5410            engine
5411                .apply(Command::SetObjectStyleOverrides(
5412                    SetObjectStyleOverridesCmd {
5413                        overrides: vec![ObjectStyleOverride {
5414                            target: super::super::score::ObjectStyleTarget::ScoreText {
5415                                text_index: 1,
5416                            },
5417                            property: super::super::score::ViewStyleProperty::TextScale,
5418                            value: 1.2,
5419                            provenance: None,
5420                        }],
5421                    }
5422                ))
5423                .is_err()
5424        );
5425    }
5426
5427    #[test]
5428    fn set_harmony_range_is_undoable_and_json_compatible() {
5429        let mut score = default_engine_score();
5430        score.parts[0].staves[0].measures[0].voices[0] =
5431            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
5432        score.parts[0].staves[0].measures[0].voices[0][0].chord_symbol = Some(ChordSymbol {
5433            root: "C".to_owned(),
5434            kind: "major".to_owned(),
5435            bass: None,
5436            placement: None,
5437            extender: true,
5438            harmonic_degree: None,
5439            harmony_function: None,
5440            harmony_type: None,
5441            chord_ref: None,
5442            range_end: None,
5443            degrees: Vec::new(),
5444        });
5445        let mut engine = ScoreEngine::new();
5446        engine.replace_score(score);
5447        let command = Command::SetHarmonyRange(SetHarmonyRangeCmd {
5448            part_index: 0,
5449            staff_index: 0,
5450            measure_index: 0,
5451            voice: 0,
5452            note_index: 0,
5453            end: Some(NoteAddr {
5454                part: 0,
5455                staff: 0,
5456                measure: 0,
5457                voice: 0,
5458                note: 0,
5459            }),
5460        });
5461        let json = serde_json::to_string(&command).unwrap();
5462        let restored: Command = serde_json::from_str(&json).unwrap();
5463        engine.apply(restored).unwrap();
5464        assert!(
5465            engine.score.parts[0].staves[0].measures[0].voices[0][0]
5466                .chord_symbol
5467                .as_ref()
5468                .and_then(|chord| chord.range_end.as_ref())
5469                .is_some()
5470        );
5471        engine.undo().unwrap();
5472        assert!(
5473            engine.score.parts[0].staves[0].measures[0].voices[0][0]
5474                .chord_symbol
5475                .as_ref()
5476                .is_some_and(|chord| chord.range_end.is_none())
5477        );
5478    }
5479
5480    #[test]
5481    fn set_harp_pedal_diagrams_is_undoable_and_json_compatible() {
5482        let mut engine = ScoreEngine::new();
5483        let mut diagram = HarpPedalDiagram::default();
5484        diagram.positions[0] = super::super::score::HarpPedalPosition::Flat;
5485        diagram.positions[6] = super::super::score::HarpPedalPosition::Sharp;
5486        let command = Command::SetHarpPedalDiagrams(SetHarpPedalDiagramsCmd {
5487            part_index: 0,
5488            staff_index: 0,
5489            measure_index: 0,
5490            diagrams: vec![diagram.clone()],
5491        });
5492        let restored: Command =
5493            serde_json::from_str(&serde_json::to_string(&command).unwrap()).unwrap();
5494        engine.apply(restored).unwrap();
5495        assert_eq!(
5496            engine.score.parts[0].staves[0].measures[0].harp_pedal_diagrams,
5497            vec![diagram]
5498        );
5499        engine.undo().unwrap();
5500        assert!(
5501            engine.score.parts[0].staves[0].measures[0]
5502                .harp_pedal_diagrams
5503                .is_empty()
5504        );
5505    }
5506
5507    #[test]
5508    fn set_note_placement_is_undoable_and_rejects_non_finite_values() {
5509        let mut score = default_engine_score();
5510        score.parts[0].staves[0].measures[0].voices[0] =
5511            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5512        let mut engine = ScoreEngine::new();
5513        engine.replace_score(score);
5514        let command = Command::SetNotePlacement(SetNotePlacementCmd {
5515            part_index: 0,
5516            staff_index: 0,
5517            measure_index: 0,
5518            voice: 0,
5519            note_index: 0,
5520            offset_x: Some(12.5),
5521            offset_y: Some(-3.0),
5522            relative_x: Some(1.25),
5523            relative_y: Some(-0.5),
5524        });
5525        engine.apply(command).unwrap();
5526        let note = &engine.score.parts[0].staves[0].measures[0].voices[0][0];
5527        assert_eq!(note.offset_x, Some(12.5));
5528        assert_eq!(note.relative_y, Some(-0.5));
5529        engine.undo().unwrap();
5530        assert_eq!(
5531            engine.score.parts[0].staves[0].measures[0].voices[0][0].offset_x,
5532            None
5533        );
5534        let invalid = Command::SetNotePlacement(SetNotePlacementCmd {
5535            part_index: 0,
5536            staff_index: 0,
5537            measure_index: 0,
5538            voice: 0,
5539            note_index: 0,
5540            offset_x: Some(f64::NAN),
5541            offset_y: None,
5542            relative_x: None,
5543            relative_y: None,
5544        });
5545        assert!(engine.apply(invalid).is_err());
5546        assert_eq!(
5547            engine.score.parts[0].staves[0].measures[0].voices[0][0].offset_x,
5548            None
5549        );
5550    }
5551
5552    #[test]
5553    fn set_guitar_bend_alter_updates_note_and_can_clear() {
5554        let mut score = default_engine_score();
5555        apply_command(
5556            &Command::AddNote(AddNoteCmd {
5557                part_index: 0,
5558                staff_index: 0,
5559                measure_index: 0,
5560                voice: 0,
5561                position: 0,
5562                pitch: Some(Pitch::new(Step::G, 4)),
5563                duration: Duration::Quarter,
5564                dot_count: 0,
5565                is_rest: false,
5566                tuplet: None,
5567            }),
5568            &mut score,
5569        )
5570        .unwrap();
5571        let location = SetGuitarBendAlterCmd {
5572            part_index: 0,
5573            staff_index: 0,
5574            measure_index: 0,
5575            voice: 0,
5576            note_index: 0,
5577            alter_cents: Some(200),
5578        };
5579        apply_command(&Command::SetGuitarBendAlter(location.clone()), &mut score).unwrap();
5580        assert_eq!(
5581            score.parts[0].staves[0].measures[0].voices[0][0].guitar_bend_alter_cents,
5582            Some(200)
5583        );
5584        let mut cleared = location;
5585        cleared.alter_cents = None;
5586        apply_command(&Command::SetGuitarBendAlter(cleared), &mut score).unwrap();
5587        assert_eq!(
5588            score.parts[0].staves[0].measures[0].voices[0][0].guitar_bend_alter_cents,
5589            None
5590        );
5591    }
5592
5593    #[test]
5594    fn set_guitar_bend_curve_is_undoable_and_json_compatible() {
5595        let mut score = Score::new("T", 120, 4, 4, 0, 1);
5596        let command = Command::SetGuitarBendCurve(SetGuitarBendCurveCmd {
5597            part_index: 0,
5598            staff_index: 0,
5599            measure_index: 0,
5600            voice: 0,
5601            note_index: 0,
5602            points: vec![
5603                crate::GuitarBendPoint {
5604                    position_per_mille: 0,
5605                    alter_cents: 0,
5606                },
5607                crate::GuitarBendPoint {
5608                    position_per_mille: 500,
5609                    alter_cents: 200,
5610                },
5611                crate::GuitarBendPoint {
5612                    position_per_mille: 1000,
5613                    alter_cents: 0,
5614                },
5615            ],
5616        });
5617        let decoded: Command =
5618            serde_json::from_str(&serde_json::to_string(&command).unwrap()).unwrap();
5619        let mut stack = CommandStack::new(16);
5620        stack.execute(decoded, &mut score).unwrap();
5621        assert_eq!(
5622            score.parts[0].staves[0].measures[0].voices[0][0]
5623                .guitar_bend_curve
5624                .len(),
5625            3
5626        );
5627        stack.undo(&mut score).unwrap();
5628        assert!(
5629            score.parts[0].staves[0].measures[0].voices[0][0]
5630                .guitar_bend_curve
5631                .is_empty()
5632        );
5633    }
5634
5635    #[test]
5636    fn set_duration_updates_note_and_preserves_measure_capacity() {
5637        let mut score = default_engine_score();
5638        apply_command(
5639            &Command::AddNote(AddNoteCmd {
5640                part_index: 0,
5641                staff_index: 0,
5642                measure_index: 0,
5643                voice: 0,
5644                position: 0,
5645                pitch: Some(Pitch::new(Step::C, 4)),
5646                duration: Duration::Quarter,
5647                dot_count: 0,
5648                is_rest: false,
5649                tuplet: None,
5650            }),
5651            &mut score,
5652        )
5653        .unwrap();
5654        apply_command(
5655            &Command::SetDuration(SetDurationCmd {
5656                part_index: 0,
5657                staff_index: 0,
5658                measure_index: 0,
5659                voice: 0,
5660                note_index: 0,
5661                duration: Duration::Half,
5662                dot_count: 1,
5663            }),
5664            &mut score,
5665        )
5666        .unwrap();
5667        let voice = &score.parts[0].staves[0].measures[0].voices[0];
5668        assert_eq!(voice[0].duration, Duration::Half);
5669        assert_eq!(voice[0].dot_count, 1);
5670        assert!((voice.iter().map(|note| note.beats()).sum::<f64>() - 4.0).abs() < 1e-9);
5671    }
5672
5673    #[test]
5674    fn set_tempo_updates_score() {
5675        let mut score = default_engine_score();
5676        let cmd = Command::SetTempo(SetTempoCmd { bpm: 160 });
5677        apply_command(&cmd, &mut score).unwrap();
5678        assert_eq!(score.settings.tempo_bpm, 160);
5679    }
5680
5681    #[test]
5682    fn add_measure_increases_count() {
5683        let mut score = default_engine_score();
5684        let before = score.measure_count();
5685        apply_command(
5686            &Command::AddMeasure(AddMeasureCmd { after_index: 0 }),
5687            &mut score,
5688        )
5689        .unwrap();
5690        assert_eq!(score.measure_count(), before + 1);
5691    }
5692
5693    #[test]
5694    fn delete_measure_decreases_count() {
5695        let mut score = default_engine_score();
5696        let before = score.measure_count();
5697        apply_command(
5698            &Command::DeleteMeasure(DeleteMeasureCmd { measure_index: 0 }),
5699            &mut score,
5700        )
5701        .unwrap();
5702        assert_eq!(score.measure_count(), before - 1);
5703    }
5704
5705    #[test]
5706    fn undo_restores_score() {
5707        let mut stack = CommandStack::new(50);
5708        let mut score = default_engine_score();
5709        let before = score.settings.tempo_bpm;
5710        stack
5711            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
5712            .unwrap();
5713        assert_eq!(score.settings.tempo_bpm, 200);
5714        stack.undo(&mut score).unwrap();
5715        assert_eq!(score.settings.tempo_bpm, before);
5716    }
5717
5718    #[test]
5719    fn redo_reapplies_command() {
5720        let mut stack = CommandStack::new(50);
5721        let mut score = default_engine_score();
5722        stack
5723            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
5724            .unwrap();
5725        stack.undo(&mut score).unwrap();
5726        stack.redo(&mut score).unwrap();
5727        assert_eq!(score.settings.tempo_bpm, 200);
5728    }
5729
5730    #[test]
5731    fn undo_nothing_returns_error() {
5732        let mut stack = CommandStack::new(50);
5733        let mut score = default_engine_score();
5734        assert!(stack.undo(&mut score).is_err());
5735    }
5736
5737    #[test]
5738    fn add_part_appends_part() {
5739        let mut score = default_engine_score();
5740        let before = score.parts.len();
5741        apply_command(
5742            &Command::AddPart(AddPartCmd {
5743                name: "Violin".into(),
5744                short_name: "Vln.".into(),
5745                clefs: vec!["Treble".into()],
5746                midi_channel: 0,
5747                midi_program: 0,
5748            }),
5749            &mut score,
5750        )
5751        .unwrap();
5752        assert_eq!(score.parts.len(), before + 1);
5753    }
5754
5755    #[test]
5756    fn delete_part_removes_part() {
5757        let mut score = default_engine_score();
5758        apply_command(
5759            &Command::AddPart(AddPartCmd {
5760                name: "Violin".into(),
5761                short_name: "V.".into(),
5762                clefs: vec!["Treble".into()],
5763                midi_channel: 0,
5764                midi_program: 0,
5765            }),
5766            &mut score,
5767        )
5768        .unwrap();
5769        let before = score.parts.len();
5770        apply_command(
5771            &Command::DeletePart(DeletePartCmd { part_index: 0 }),
5772            &mut score,
5773        )
5774        .unwrap();
5775        assert_eq!(score.parts.len(), before - 1);
5776    }
5777
5778    #[test]
5779    fn delete_part_out_of_range_returns_err() {
5780        let mut score = default_engine_score();
5781        assert!(
5782            apply_command(
5783                &Command::DeletePart(DeletePartCmd { part_index: 99 }),
5784                &mut score
5785            )
5786            .is_err()
5787        );
5788    }
5789
5790    #[test]
5791    fn delete_part_undo_restores_part() {
5792        let mut stack = CommandStack::new(50);
5793        let mut score = default_engine_score();
5794        apply_command(
5795            &Command::AddPart(AddPartCmd {
5796                name: "Violin".into(),
5797                short_name: "V.".into(),
5798                clefs: vec!["Treble".into()],
5799                midi_channel: 0,
5800                midi_program: 0,
5801            }),
5802            &mut score,
5803        )
5804        .unwrap();
5805        let before = score.parts.len();
5806        stack
5807            .execute(
5808                Command::DeletePart(DeletePartCmd { part_index: 0 }),
5809                &mut score,
5810            )
5811            .unwrap();
5812        assert_eq!(score.parts.len(), before - 1);
5813        stack.undo(&mut score).unwrap();
5814        assert_eq!(score.parts.len(), before);
5815    }
5816
5817    #[test]
5818    fn reorder_parts_remaps_linked_views_and_preserves_undo() {
5819        let mut engine = crate::ScoreEngine::new();
5820        engine
5821            .apply(Command::AddPart(AddPartCmd {
5822                name: "Flute".into(),
5823                short_name: "Fl.".into(),
5824                clefs: vec!["Treble".into()],
5825                midi_channel: 1,
5826                midi_program: 73,
5827            }))
5828            .unwrap();
5829        engine
5830            .score
5831            .views
5832            .push(ScoreView::linked_part("flute", "Flute", 1));
5833        engine.score.part_groups.push(PartGroup {
5834            first_part: 0,
5835            last_part: 1,
5836            symbol: super::super::score::PartGroupSymbol::Bracket,
5837            barlines_connect: false,
5838        });
5839
5840        engine
5841            .apply(Command::ReorderParts(ReorderPartsCmd { order: vec![1, 0] }))
5842            .unwrap();
5843        assert_eq!(engine.score.parts[0].name, "Flute");
5844        assert_eq!(engine.score.views[0].parts, vec![0]);
5845        engine.undo().unwrap();
5846        assert_eq!(engine.score.parts[0].name, "Piano");
5847        assert_eq!(engine.score.views[0].parts, vec![1]);
5848    }
5849
5850    #[test]
5851    fn reorder_parts_rejects_splitting_a_part_group_without_mutation() {
5852        let mut score = Score::template(ScoreTemplate::StringQuartet);
5853        score.part_groups.push(PartGroup {
5854            first_part: 0,
5855            last_part: 1,
5856            symbol: super::super::score::PartGroupSymbol::Bracket,
5857            barlines_connect: false,
5858        });
5859        let before = serde_json::to_value(&score).unwrap();
5860
5861        assert!(
5862            apply_command(
5863                &Command::ReorderParts(ReorderPartsCmd {
5864                    order: vec![0, 2, 1, 3],
5865                }),
5866                &mut score,
5867            )
5868            .is_err()
5869        );
5870        assert_eq!(serde_json::to_value(&score).unwrap(), before);
5871    }
5872
5873    #[test]
5874    fn set_metadata_updates_title() {
5875        let mut score = default_engine_score();
5876        apply_command(
5877            &Command::SetMetadata(SetMetadataCmd {
5878                title: Some("New Title".into()),
5879                ..Default::default()
5880            }),
5881            &mut score,
5882        )
5883        .unwrap();
5884        assert_eq!(score.metadata.title, "New Title");
5885    }
5886
5887    #[test]
5888    fn set_metadata_none_fields_skipped() {
5889        let mut score = default_engine_score();
5890        let original_composer = score.metadata.composer.clone();
5891        apply_command(
5892            &Command::SetMetadata(SetMetadataCmd {
5893                title: Some("X".into()),
5894                ..Default::default()
5895            }),
5896            &mut score,
5897        )
5898        .unwrap();
5899        assert_eq!(score.metadata.composer, original_composer);
5900    }
5901
5902    #[test]
5903    fn set_volta_sets_bracket() {
5904        use crate::model::score::VoltaBracket;
5905        let mut score = default_engine_score();
5906        let volta = VoltaBracket {
5907            number: 1,
5908            kind: "begin_end".into(),
5909        };
5910        apply_command(
5911            &Command::SetVolta(SetVoltaCmd {
5912                measure_index: 0,
5913                volta: Some(volta.clone()),
5914            }),
5915            &mut score,
5916        )
5917        .unwrap();
5918        assert!(score.parts[0].staves[0].measures[0].volta.is_some());
5919    }
5920
5921    #[test]
5922    fn set_volta_none_clears_bracket() {
5923        use crate::model::score::VoltaBracket;
5924        let mut score = default_engine_score();
5925        score.parts[0].staves[0].measures[0].volta = Some(VoltaBracket {
5926            number: 1,
5927            kind: "begin_end".into(),
5928        });
5929        apply_command(
5930            &Command::SetVolta(SetVoltaCmd {
5931                measure_index: 0,
5932                volta: None,
5933            }),
5934            &mut score,
5935        )
5936        .unwrap();
5937        assert!(score.parts[0].staves[0].measures[0].volta.is_none());
5938    }
5939
5940    #[test]
5941    fn set_volta_undo_restores_old() {
5942        use crate::model::score::VoltaBracket;
5943        let mut stack = CommandStack::new(50);
5944        let mut score = default_engine_score();
5945        stack
5946            .execute(
5947                Command::SetVolta(SetVoltaCmd {
5948                    measure_index: 0,
5949                    volta: Some(VoltaBracket {
5950                        number: 1,
5951                        kind: "begin_end".into(),
5952                    }),
5953                }),
5954                &mut score,
5955            )
5956            .unwrap();
5957        stack.undo(&mut score).unwrap();
5958        assert!(score.parts[0].staves[0].measures[0].volta.is_none());
5959    }
5960
5961    #[test]
5962    fn set_clef_updates_staff_clef() {
5963        use crate::model::notation::Clef;
5964        let mut score = default_engine_score();
5965        apply_command(
5966            &Command::SetClef(SetClefCmd {
5967                part_index: 0,
5968                staff_index: 0,
5969                clef: Clef::Bass,
5970            }),
5971            &mut score,
5972        )
5973        .unwrap();
5974        assert_eq!(score.parts[0].staves[0].clef, Clef::Bass);
5975    }
5976
5977    #[test]
5978    fn set_clef_out_of_range_returns_err() {
5979        use crate::model::notation::Clef;
5980        let mut score = default_engine_score();
5981        assert!(
5982            apply_command(
5983                &Command::SetClef(SetClefCmd {
5984                    part_index: 99,
5985                    staff_index: 0,
5986                    clef: Clef::Bass,
5987                }),
5988                &mut score
5989            )
5990            .is_err()
5991        );
5992    }
5993
5994    #[test]
5995    fn set_part_name_updates_name() {
5996        let mut score = default_engine_score();
5997        apply_command(
5998            &Command::SetPartName(SetPartNameCmd {
5999                part_index: 0,
6000                name: "Violin".into(),
6001                short_name: "Vln.".into(),
6002            }),
6003            &mut score,
6004        )
6005        .unwrap();
6006        assert_eq!(score.parts[0].name, "Violin");
6007        assert_eq!(score.parts[0].short_name, "Vln.");
6008    }
6009
6010    #[test]
6011    fn set_part_name_undo_restores_old() {
6012        let mut stack = CommandStack::new(50);
6013        let mut score = default_engine_score();
6014        let original = score.parts[0].name.clone();
6015        stack
6016            .execute(
6017                Command::SetPartName(SetPartNameCmd {
6018                    part_index: 0,
6019                    name: "Flute".into(),
6020                    short_name: "Fl.".into(),
6021                }),
6022                &mut score,
6023            )
6024            .unwrap();
6025        stack.undo(&mut score).unwrap();
6026        assert_eq!(score.parts[0].name, original);
6027    }
6028
6029    #[test]
6030    fn set_metadata_undo_restores_old_title() {
6031        let mut stack = CommandStack::new(50);
6032        let mut score = default_engine_score();
6033        let original = score.metadata.title.clone();
6034        stack
6035            .execute(
6036                Command::SetMetadata(SetMetadataCmd {
6037                    title: Some("Changed".into()),
6038                    ..Default::default()
6039                }),
6040                &mut score,
6041            )
6042            .unwrap();
6043        assert_ne!(score.metadata.title, original);
6044        stack.undo(&mut score).unwrap();
6045        assert_eq!(score.metadata.title, original);
6046    }
6047
6048    #[test]
6049    fn set_midi_instrument_updates_channel_and_program() {
6050        let mut score = default_engine_score();
6051        apply_command(
6052            &Command::SetMidiInstrument(SetMidiInstrumentCmd {
6053                part_index: 0,
6054                midi_channel: 2,
6055                midi_program: 40,
6056            }),
6057            &mut score,
6058        )
6059        .unwrap();
6060        assert_eq!(score.parts[0].midi_channel, 2);
6061        assert_eq!(score.parts[0].midi_program, 40);
6062    }
6063
6064    #[test]
6065    fn set_midi_instrument_clamps_channel_to_15() {
6066        let mut score = default_engine_score();
6067        apply_command(
6068            &Command::SetMidiInstrument(SetMidiInstrumentCmd {
6069                part_index: 0,
6070                midi_channel: 20,
6071                midi_program: 0,
6072            }),
6073            &mut score,
6074        )
6075        .unwrap();
6076        assert_eq!(score.parts[0].midi_channel, 15);
6077    }
6078
6079    #[test]
6080    fn set_midi_instrument_undo_restores_old() {
6081        let mut stack = CommandStack::new(50);
6082        let mut score = default_engine_score();
6083        score.parts[0].midi_channel = 3;
6084        score.parts[0].midi_program = 10;
6085        stack
6086            .execute(
6087                Command::SetMidiInstrument(SetMidiInstrumentCmd {
6088                    part_index: 0,
6089                    midi_channel: 9,
6090                    midi_program: 114,
6091                }),
6092                &mut score,
6093            )
6094            .unwrap();
6095        stack.undo(&mut score).unwrap();
6096        assert_eq!(score.parts[0].midi_channel, 3);
6097        assert_eq!(score.parts[0].midi_program, 10);
6098    }
6099
6100    #[test]
6101    fn set_transpose_updates_staff() {
6102        let mut score = default_engine_score();
6103        apply_command(
6104            &Command::SetTranspose(SetTransposeCmd {
6105                part_index: 0,
6106                staff_index: 0,
6107                semitones: -2,
6108            }),
6109            &mut score,
6110        )
6111        .unwrap();
6112        assert_eq!(score.parts[0].staves[0].transpose_semitones, -2);
6113    }
6114
6115    #[test]
6116    fn set_transpose_out_of_range_returns_err() {
6117        let mut score = default_engine_score();
6118        assert!(
6119            apply_command(
6120                &Command::SetTranspose(SetTransposeCmd {
6121                    part_index: 99,
6122                    staff_index: 0,
6123                    semitones: -2,
6124                }),
6125                &mut score
6126            )
6127            .is_err()
6128        );
6129    }
6130
6131    #[test]
6132    fn transpose_staff_region_is_undoable_and_json_compatible() {
6133        let mut stack = CommandStack::new(50);
6134        let mut score = Score::new("Region", 120, 4, 4, 0, 2);
6135        for measure in &mut score.parts[0].staves[0].measures {
6136            measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
6137        }
6138        let command = Command::TransposeStaffRegion(TransposeStaffRegionCmd {
6139            part_index: 0,
6140            staff_index: 0,
6141            start_measure: 1,
6142            end_measure: 2,
6143            semitones: 2,
6144            target: RegionalTranspositionTarget::Written,
6145        });
6146        let json = serde_json::to_string(&command).expect("command serializes");
6147        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
6148        assert_eq!(command_key(&decoded), "TransposeStaffRegion");
6149        stack.execute(decoded, &mut score).expect("region applies");
6150        assert_eq!(
6151            score.parts[0].staves[0].measures[0].voices[0][0].pitches[0].to_midi(),
6152            60
6153        );
6154        assert_eq!(
6155            score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].to_midi(),
6156            62
6157        );
6158        stack.undo(&mut score).expect("region undo applies");
6159        assert_eq!(
6160            score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].to_midi(),
6161            60
6162        );
6163    }
6164
6165    #[test]
6166    fn set_tempo_at_measure_sets_tempo() {
6167        let mut score = default_engine_score();
6168        apply_command(
6169            &Command::SetTempoAtMeasure(SetTempoAtMeasureCmd {
6170                measure_index: 0,
6171                bpm: Some(80),
6172            }),
6173            &mut score,
6174        )
6175        .unwrap();
6176        assert_eq!(score.parts[0].staves[0].measures[0].tempo, Some(80));
6177    }
6178
6179    #[test]
6180    fn set_tempo_at_measure_none_clears_tempo() {
6181        let mut score = default_engine_score();
6182        score.parts[0].staves[0].measures[0].tempo = Some(120);
6183        apply_command(
6184            &Command::SetTempoAtMeasure(SetTempoAtMeasureCmd {
6185                measure_index: 0,
6186                bpm: None,
6187            }),
6188            &mut score,
6189        )
6190        .unwrap();
6191        assert!(score.parts[0].staves[0].measures[0].tempo.is_none());
6192    }
6193
6194    #[test]
6195    fn set_tempo_ramp_at_measure_applies() {
6196        let mut score = default_engine_score();
6197        apply_command(
6198            &Command::SetTempoRampAtMeasure(SetTempoRampAtMeasureCmd {
6199                measure_index: 0,
6200                target_bpm: Some(72),
6201            }),
6202            &mut score,
6203        )
6204        .expect("tempo ramp applies");
6205        assert_eq!(score.parts[0].staves[0].measures[0].tempo_ramp_to, Some(72));
6206    }
6207
6208    // ── batch_execute ─────────────────────────────────────────────────────────
6209
6210    #[test]
6211    fn batch_execute_two_commands_single_undo() {
6212        let mut stack = CommandStack::new(50);
6213        let mut score = default_engine_score();
6214        let original_bpm = score.settings.tempo_bpm;
6215        stack
6216            .batch_execute(
6217                vec![
6218                    Command::SetTempo(SetTempoCmd { bpm: 160 }),
6219                    Command::SetTempo(SetTempoCmd { bpm: 180 }),
6220                ],
6221                &mut score,
6222            )
6223            .unwrap();
6224        assert_eq!(score.settings.tempo_bpm, 180);
6225        stack.undo(&mut score).unwrap();
6226        assert_eq!(score.settings.tempo_bpm, original_bpm);
6227    }
6228
6229    #[test]
6230    fn batch_execute_partial_failure_rollback() {
6231        let mut stack = CommandStack::new(50);
6232        let mut score = default_engine_score();
6233        let original_bpm = score.settings.tempo_bpm;
6234        let result = stack.batch_execute(
6235            vec![
6236                Command::SetTempo(SetTempoCmd { bpm: 160 }),
6237                Command::DeleteNote(DeleteNoteCmd {
6238                    note_id: "nonexistent".into(),
6239                    part_index: 99,
6240                    staff_index: 0,
6241                    measure_index: 0,
6242                    voice: 0,
6243                }),
6244            ],
6245            &mut score,
6246        );
6247        assert!(result.is_err());
6248        assert_eq!(score.settings.tempo_bpm, original_bpm);
6249    }
6250
6251    #[test]
6252    fn execute_rejects_invalid_candidate_without_mutating_or_recording_history() {
6253        let mut stack = CommandStack::new(50);
6254        let mut score = default_engine_score();
6255        score.parts[0].staves[0].tablature = Some(crate::TablatureConfig {
6256            lines: 6,
6257            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6258            capo: 0,
6259        });
6260        stack
6261            .execute(
6262                Command::AddNote(AddNoteCmd {
6263                    part_index: 0,
6264                    staff_index: 0,
6265                    measure_index: 0,
6266                    voice: 0,
6267                    position: 0,
6268                    pitch: Some(Pitch::new(Step::C, 4)),
6269                    duration: Duration::Quarter,
6270                    dot_count: 0,
6271                    is_rest: false,
6272                    tuplet: None,
6273                }),
6274                &mut score,
6275            )
6276            .expect("valid pitched note should be accepted");
6277        let original = score.clone();
6278        let result = stack.execute(
6279            Command::SetTabPosition(SetTabPositionCmd {
6280                part_index: 0,
6281                staff_index: 0,
6282                measure_index: 0,
6283                voice: 0,
6284                note_index: 0,
6285                position: Some(crate::TabPosition { string: 7, fret: 0 }),
6286            }),
6287            &mut score,
6288        );
6289        assert!(matches!(result, Err(Error::InvalidScore)));
6290        let note = &score.parts[0].staves[0].measures[0].voices[0][0];
6291        let original_note = &original.parts[0].staves[0].measures[0].voices[0][0];
6292        assert_eq!(note.pitches, original_note.pitches);
6293        assert_eq!(note.duration, original_note.duration);
6294        assert_eq!(note.tab_position, original_note.tab_position);
6295        assert_eq!(note.tab_positions, original_note.tab_positions);
6296        assert!(stack.can_undo());
6297    }
6298
6299    #[test]
6300    fn set_tablature_config_is_undoable_and_json_compatible() {
6301        let mut stack = CommandStack::new(50);
6302        let mut score = default_engine_score();
6303        let config = crate::TablatureConfig {
6304            lines: 6,
6305            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6306            capo: 2,
6307        };
6308        let command = Command::SetTablatureConfig(SetTablatureConfigCmd {
6309            part_index: 0,
6310            staff_index: 0,
6311            config: Some(config.clone()),
6312        });
6313        let json = serde_json::to_string(&command).expect("command should serialize");
6314        let decoded: Command = serde_json::from_str(&json).expect("command should deserialize");
6315        assert_eq!(command_key(&decoded), "SetTablatureConfig");
6316        stack
6317            .execute(decoded, &mut score)
6318            .expect("config should apply");
6319        assert_eq!(score.parts[0].staves[0].tablature, Some(config));
6320        assert_eq!(
6321            score.parts[0].staves[0].presentation.kind,
6322            StaffKind::Tablature
6323        );
6324        stack.undo(&mut score).expect("config undo should apply");
6325        assert!(score.parts[0].staves[0].tablature.is_none());
6326        assert_eq!(
6327            score.parts[0].staves[0].presentation.kind,
6328            StaffKind::Standard
6329        );
6330        stack.redo(&mut score).expect("config redo should apply");
6331        assert_eq!(
6332            score.parts[0].staves[0]
6333                .tablature
6334                .as_ref()
6335                .map(|tab| tab.capo),
6336            Some(2)
6337        );
6338    }
6339
6340    #[test]
6341    fn set_staff_presentation_is_undoable_and_requires_tablature_config() {
6342        let mut stack = CommandStack::new(50);
6343        let mut score = default_engine_score();
6344        let tab = StaffPresentation {
6345            kind: StaffKind::Tablature,
6346            lines: 6,
6347            line_distance: 1.25,
6348            small: true,
6349            cutaway: false,
6350            visible: true,
6351            notehead_scheme: super::super::score::StaffNoteheadScheme::Standard,
6352            tablature_rhythm_display: super::super::score::TablatureRhythmDisplay::FretOnly,
6353            tablature_fret_mark_style: super::super::score::TablatureFretMarkStyle::Arabic,
6354        };
6355        let rejected = Command::SetStaffPresentation(SetStaffPresentationCmd {
6356            part_index: 0,
6357            staff_index: 0,
6358            presentation: tab.clone(),
6359        });
6360        let before = score.clone();
6361        assert!(stack.execute(rejected, &mut score).is_err());
6362        assert_eq!(
6363            score.parts[0].staves[0].presentation,
6364            before.parts[0].staves[0].presentation
6365        );
6366        assert!(!stack.can_undo());
6367
6368        stack
6369            .execute(
6370                Command::SetTablatureConfig(SetTablatureConfigCmd {
6371                    part_index: 0,
6372                    staff_index: 0,
6373                    config: Some(crate::TablatureConfig {
6374                        lines: 6,
6375                        tuning_midi: vec![40, 45, 50, 55, 59, 64],
6376                        capo: 0,
6377                    }),
6378                }),
6379                &mut score,
6380            )
6381            .expect("tablature configuration should apply");
6382        stack
6383            .execute(
6384                Command::SetStaffPresentation(SetStaffPresentationCmd {
6385                    part_index: 0,
6386                    staff_index: 0,
6387                    presentation: tab.clone(),
6388                }),
6389                &mut score,
6390            )
6391            .expect("staff presentation should apply");
6392        assert_eq!(stack.undo_key().as_deref(), Some("SetStaffPresentation"));
6393        assert_eq!(score.parts[0].staves[0].presentation, tab);
6394        stack
6395            .undo(&mut score)
6396            .expect("presentation undo should apply");
6397        assert_eq!(
6398            score.parts[0].staves[0].presentation.kind,
6399            StaffKind::Tablature
6400        );
6401        stack
6402            .redo(&mut score)
6403            .expect("presentation redo should apply");
6404        assert_eq!(
6405            score.parts[0].staves[0].presentation.kind,
6406            StaffKind::Tablature
6407        );
6408    }
6409
6410    #[test]
6411    fn set_instrument_definition_is_undoable_and_rejects_invalid_ranges() {
6412        let mut stack = CommandStack::new(50);
6413        let mut score = default_engine_score();
6414        let mut invalid = InstrumentDefinition::new("violin", "Violin");
6415        invalid.written_range = Some(InstrumentRange {
6416            lowest: 100,
6417            highest: 55,
6418        });
6419        let before = score.clone();
6420        assert!(
6421            stack
6422                .execute(
6423                    Command::SetInstrumentDefinition(SetInstrumentDefinitionCmd {
6424                        part_index: 0,
6425                        definition: Some(invalid),
6426                    }),
6427                    &mut score,
6428                )
6429                .is_err()
6430        );
6431        assert_eq!(score.parts[0].instrument, before.parts[0].instrument);
6432
6433        let mut definition = InstrumentDefinition::new("violin", "Violin");
6434        definition.short_name = "Vln.".to_string();
6435        definition.family = Some("strings".to_string());
6436        definition.written_range = Some(InstrumentRange {
6437            lowest: 55,
6438            highest: 103,
6439        });
6440        definition.sounding_range = definition.written_range;
6441        definition.default_clefs = vec![Clef::Treble];
6442        definition.midi_program = 40;
6443        let command = Command::SetInstrumentDefinition(SetInstrumentDefinitionCmd {
6444            part_index: 0,
6445            definition: Some(definition.clone()),
6446        });
6447        let json = serde_json::to_string(&command).expect("command should serialize");
6448        let decoded: Command = serde_json::from_str(&json).expect("command should deserialize");
6449        assert_eq!(command_key(&decoded), "SetInstrumentDefinition");
6450        stack
6451            .execute(decoded, &mut score)
6452            .expect("definition applies");
6453        assert_eq!(score.parts[0].instrument, Some(definition));
6454        stack.undo(&mut score).expect("definition undo applies");
6455        assert!(score.parts[0].instrument.is_none());
6456        stack.redo(&mut score).expect("definition redo applies");
6457        assert_eq!(
6458            score.parts[0]
6459                .instrument
6460                .as_ref()
6461                .map(|value| value.id.as_str()),
6462            Some("violin")
6463        );
6464    }
6465
6466    #[test]
6467    fn set_percussion_kit_is_undoable_and_rejects_invalid_entries() {
6468        let mut stack = CommandStack::new(50);
6469        let mut score = default_engine_score();
6470        let invalid = Command::SetPercussionKit(SetPercussionKitCmd {
6471            part_index: 0,
6472            instruments: vec![PercussionInstrument {
6473                id: "snare".to_string(),
6474                name: None,
6475                midi_unpitched: Some(38),
6476                staff_position: Some(33),
6477                notehead: None,
6478                preferred_voice: None,
6479                techniques: Vec::new(),
6480            }],
6481        });
6482        assert!(stack.execute(invalid, &mut score).is_err());
6483        assert!(score.parts[0].percussion_instruments.is_empty());
6484
6485        let kit = vec![PercussionInstrument {
6486            id: "snare".to_string(),
6487            name: Some("Acoustic Snare".to_string()),
6488            midi_unpitched: Some(38),
6489            staff_position: Some(0),
6490            notehead: Some(NoteHead::Cross),
6491            preferred_voice: Some(1),
6492            techniques: vec!["rim-shot".to_string()],
6493        }];
6494        let command = Command::SetPercussionKit(SetPercussionKitCmd {
6495            part_index: 0,
6496            instruments: kit.clone(),
6497        });
6498        let json = serde_json::to_string(&command).expect("command serializes");
6499        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
6500        assert_eq!(command_key(&decoded), "SetPercussionKit");
6501        stack.execute(decoded, &mut score).expect("kit applies");
6502        assert_eq!(score.parts[0].percussion_instruments, kit);
6503        stack.undo(&mut score).expect("kit undo applies");
6504        assert!(score.parts[0].percussion_instruments.is_empty());
6505        stack.redo(&mut score).expect("kit redo applies");
6506        assert_eq!(score.parts[0].percussion_instruments, kit);
6507    }
6508
6509    #[test]
6510    fn set_measure_instrument_change_is_undoable_and_json_compatible() {
6511        let mut stack = CommandStack::new(50);
6512        let mut score = default_engine_score();
6513        let mut definition = InstrumentDefinition::new("clarinet-bb", "B-flat Clarinet");
6514        definition.transpose_semitones = -2;
6515        definition.midi_program = 71;
6516        let command = Command::SetMeasureInstrumentChange(SetMeasureInstrumentChangeCmd {
6517            part_index: 0,
6518            staff_index: 0,
6519            measure_index: 2,
6520            definition: Some(definition.clone()),
6521        });
6522        let json = serde_json::to_string(&command).expect("command should serialize");
6523        let decoded: Command = serde_json::from_str(&json).expect("command should deserialize");
6524        assert_eq!(command_key(&decoded), "SetMeasureInstrumentChange");
6525        stack.execute(decoded, &mut score).expect("change applies");
6526        assert_eq!(
6527            score.parts[0].staves[0].measures[2].instrument_change,
6528            Some(definition)
6529        );
6530        stack.undo(&mut score).expect("undo applies");
6531        assert!(
6532            score.parts[0].staves[0].measures[2]
6533                .instrument_change
6534                .is_none()
6535        );
6536    }
6537
6538    #[test]
6539    fn set_measure_tablature_change_is_undoable_and_uses_base_line_count() {
6540        let mut stack = CommandStack::new(50);
6541        let mut score = default_engine_score();
6542        let base = crate::TablatureConfig {
6543            lines: 6,
6544            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6545            capo: 0,
6546        };
6547        score.parts[0].staves[0].tablature = Some(base.clone());
6548        let mut changed = base;
6549        changed.tuning_midi[0] = 38;
6550        changed.capo = 2;
6551        let command = Command::SetMeasureTablatureChange(SetMeasureTablatureChangeCmd {
6552            part_index: 0,
6553            staff_index: 0,
6554            measure_index: 2,
6555            config: Some(changed.clone()),
6556        });
6557        let json = serde_json::to_string(&command).expect("command serializes");
6558        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
6559        assert_eq!(command_key(&decoded), "SetMeasureTablatureChange");
6560        stack.execute(decoded, &mut score).expect("change applies");
6561        assert_eq!(
6562            score.parts[0].staves[0].tablature_at(1),
6563            score.parts[0].staves[0].tablature
6564        );
6565        assert_eq!(score.parts[0].staves[0].tablature_at(2), Some(changed));
6566        stack.undo(&mut score).expect("change undo applies");
6567        assert!(
6568            score.parts[0].staves[0].measures[2]
6569                .tablature_change
6570                .is_none()
6571        );
6572
6573        let invalid = Command::SetMeasureTablatureChange(SetMeasureTablatureChangeCmd {
6574            part_index: 0,
6575            staff_index: 0,
6576            measure_index: 1,
6577            config: Some(crate::TablatureConfig {
6578                lines: 7,
6579                tuning_midi: vec![40, 45, 50, 55, 59, 64, 69],
6580                capo: 0,
6581            }),
6582        });
6583        assert!(stack.execute(invalid, &mut score).is_err());
6584    }
6585
6586    #[test]
6587    fn batch_execute_empty_is_noop() {
6588        let mut stack = CommandStack::new(50);
6589        let mut score = default_engine_score();
6590        stack.batch_execute(vec![], &mut score).unwrap();
6591        assert!(!stack.can_undo());
6592    }
6593
6594    // ── BatchCmd.label ────────────────────────────────────────────────────────
6595
6596    #[test]
6597    fn batch_label_used_as_command_key() {
6598        let cmd = Command::Batch(BatchCmd {
6599            commands: vec![],
6600            label: Some("ApplyAI".to_string()),
6601        });
6602        assert_eq!(command_key(&cmd), "ApplyAI");
6603    }
6604
6605    #[test]
6606    fn batch_no_label_key_is_batch() {
6607        let cmd = Command::Batch(BatchCmd {
6608            commands: vec![],
6609            label: None,
6610        });
6611        assert_eq!(command_key(&cmd), "Batch");
6612    }
6613
6614    #[test]
6615    fn batch_label_survives_json_roundtrip() {
6616        let cmd = Command::Batch(BatchCmd {
6617            commands: vec![Command::SetTempo(SetTempoCmd { bpm: 120 })],
6618            label: Some("PasteSelection".to_string()),
6619        });
6620        let json = serde_json::to_string(&cmd).unwrap();
6621        let cmd2: Command = serde_json::from_str(&json).unwrap();
6622        assert_eq!(command_key(&cmd2), "PasteSelection");
6623    }
6624
6625    #[test]
6626    fn batch_label_in_undo_key() {
6627        let mut stack = CommandStack::new(50);
6628        let mut score = default_engine_score();
6629        let cmd = Command::Batch(BatchCmd {
6630            commands: vec![Command::SetTempo(SetTempoCmd { bpm: 140 })],
6631            label: Some("ApplyAI".to_string()),
6632        });
6633        stack.execute(cmd, &mut score).unwrap();
6634        assert_eq!(stack.undo_key(), Some("ApplyAI".to_string()));
6635    }
6636
6637    #[test]
6638    fn undo_returns_change_hint() {
6639        use crate::model::change_hint::ChangeScope;
6640        let mut stack = CommandStack::new(50);
6641        let mut score = default_engine_score();
6642        stack
6643            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
6644            .unwrap();
6645        let hint = stack.undo(&mut score).unwrap();
6646        assert_eq!(hint.scope, ChangeScope::Global);
6647        assert!(hint.playback_dirty);
6648    }
6649
6650    #[test]
6651    fn redo_returns_change_hint() {
6652        use crate::model::change_hint::ChangeScope;
6653        let mut stack = CommandStack::new(50);
6654        let mut score = default_engine_score();
6655        stack
6656            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
6657            .unwrap();
6658        stack.undo(&mut score).unwrap();
6659        let hint = stack.redo(&mut score).unwrap();
6660        assert_eq!(hint.scope, ChangeScope::Global);
6661        assert!(hint.playback_dirty);
6662    }
6663
6664    // ── Feature A: ToggleSlur ─────────────────────────────────────────────
6665
6666    #[test]
6667    fn toggle_slur_sets_start_and_end() {
6668        let mut score = default_engine_score();
6669        let cmd = Command::AddNote(AddNoteCmd {
6670            part_index: 0,
6671            staff_index: 0,
6672            measure_index: 0,
6673            voice: 0,
6674            position: 0,
6675            pitch: Some(Pitch::new(Step::C, 4)),
6676            duration: Duration::Quarter,
6677            dot_count: 0,
6678            is_rest: false,
6679            tuplet: None,
6680        });
6681        apply_command(&cmd, &mut score).unwrap();
6682        apply_command(
6683            &Command::AddNote(AddNoteCmd {
6684                part_index: 0,
6685                staff_index: 0,
6686                measure_index: 0,
6687                voice: 0,
6688                position: 1,
6689                pitch: Some(Pitch::new(Step::D, 4)),
6690                duration: Duration::Quarter,
6691                dot_count: 0,
6692                is_rest: false,
6693                tuplet: None,
6694            }),
6695            &mut score,
6696        )
6697        .unwrap();
6698        let start = NoteAddr {
6699            part: 0,
6700            staff: 0,
6701            measure: 0,
6702            voice: 0,
6703            note: 0,
6704        };
6705        let end = NoteAddr {
6706            part: 0,
6707            staff: 0,
6708            measure: 0,
6709            voice: 0,
6710            note: 1,
6711        };
6712        apply_command(
6713            &Command::ToggleSlur(ToggleSlurCmd {
6714                start: start.clone(),
6715                end: end.clone(),
6716            }),
6717            &mut score,
6718        )
6719        .unwrap();
6720        assert!(score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
6721        assert!(score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
6722        // toggle off
6723        apply_command(
6724            &Command::ToggleSlur(ToggleSlurCmd { start, end }),
6725            &mut score,
6726        )
6727        .unwrap();
6728        assert!(!score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
6729        assert!(!score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
6730    }
6731
6732    // ── Feature B: AddStaff / DeleteStaff ────────────────────────────────
6733
6734    #[test]
6735    fn add_staff_appends_staff_with_correct_measure_count() {
6736        let mut score = default_engine_score();
6737        let before = score.parts[0].staves.len();
6738        let measure_count = score.parts[0].staves[0].measures.len();
6739        apply_command(
6740            &Command::AddStaff(AddStaffCmd {
6741                part_index: 0,
6742                clef: Clef::Bass,
6743            }),
6744            &mut score,
6745        )
6746        .unwrap();
6747        assert_eq!(score.parts[0].staves.len(), before + 1);
6748        let new_staff = score.parts[0].staves.last().unwrap();
6749        assert_eq!(new_staff.measures.len(), measure_count);
6750    }
6751
6752    #[test]
6753    fn add_staff_out_of_range_returns_err() {
6754        let mut score = default_engine_score();
6755        let result = apply_command(
6756            &Command::AddStaff(AddStaffCmd {
6757                part_index: 99,
6758                clef: Clef::Treble,
6759            }),
6760            &mut score,
6761        );
6762        assert!(result.is_err());
6763    }
6764
6765    #[test]
6766    fn delete_staff_removes_extra_staff() {
6767        let mut score = default_engine_score();
6768        apply_command(
6769            &Command::AddStaff(AddStaffCmd {
6770                part_index: 0,
6771                clef: Clef::Bass,
6772            }),
6773            &mut score,
6774        )
6775        .unwrap();
6776        assert_eq!(score.parts[0].staves.len(), 2);
6777        apply_command(
6778            &Command::DeleteStaff(DeleteStaffCmd {
6779                part_index: 0,
6780                staff_index: 1,
6781            }),
6782            &mut score,
6783        )
6784        .unwrap();
6785        assert_eq!(score.parts[0].staves.len(), 1);
6786    }
6787
6788    #[test]
6789    fn delete_last_staff_returns_err() {
6790        let mut score = default_engine_score();
6791        assert_eq!(score.parts[0].staves.len(), 1);
6792        let result = apply_command(
6793            &Command::DeleteStaff(DeleteStaffCmd {
6794                part_index: 0,
6795                staff_index: 0,
6796            }),
6797            &mut score,
6798        );
6799        assert!(result.is_err());
6800    }
6801
6802    // ── Feature D: SetTuplet ──────────────────────────────────────────────
6803
6804    #[test]
6805    fn set_tuplet_assigns_and_clears() {
6806        use crate::model::notation::TupletInfo;
6807        let mut score = default_engine_score();
6808        apply_command(
6809            &Command::AddNote(AddNoteCmd {
6810                part_index: 0,
6811                staff_index: 0,
6812                measure_index: 0,
6813                voice: 0,
6814                position: 0,
6815                pitch: Some(Pitch::new(Step::C, 4)),
6816                duration: Duration::Quarter,
6817                dot_count: 0,
6818                is_rest: false,
6819                tuplet: None,
6820            }),
6821            &mut score,
6822        )
6823        .unwrap();
6824        let ti = TupletInfo {
6825            actual_notes: 3,
6826            normal_notes: 2,
6827        };
6828        apply_command(
6829            &Command::SetTuplet(SetTupletCmd {
6830                part_index: 0,
6831                staff_index: 0,
6832                measure_index: 0,
6833                voice_index: 0,
6834                note_index: 0,
6835                tuplet: Some(ti.clone()),
6836            }),
6837            &mut score,
6838        )
6839        .unwrap();
6840        assert_eq!(
6841            score.parts[0].staves[0].measures[0].voices[0][0].tuplet,
6842            Some(ti)
6843        );
6844        apply_command(
6845            &Command::SetTuplet(SetTupletCmd {
6846                part_index: 0,
6847                staff_index: 0,
6848                measure_index: 0,
6849                voice_index: 0,
6850                note_index: 0,
6851                tuplet: None,
6852            }),
6853            &mut score,
6854        )
6855        .unwrap();
6856        assert!(
6857            score.parts[0].staves[0].measures[0].voices[0][0]
6858                .tuplet
6859                .is_none()
6860        );
6861    }
6862
6863    // ── Feature E: RespellScore ───────────────────────────────────────────
6864
6865    #[test]
6866    fn respell_score_cmd_changes_all_pitches() {
6867        use crate::model::pitch::Step;
6868        let mut score = default_engine_score();
6869        apply_command(
6870            &Command::AddNote(AddNoteCmd {
6871                part_index: 0,
6872                staff_index: 0,
6873                measure_index: 0,
6874                voice: 0,
6875                position: 0,
6876                pitch: Some(Pitch::with_alter(Step::C, 4, 1)), // C#4
6877                duration: Duration::Quarter,
6878                dot_count: 0,
6879                is_rest: false,
6880                tuplet: None,
6881            }),
6882            &mut score,
6883        )
6884        .unwrap();
6885        apply_command(
6886            &Command::RespellScore(RespellScoreCmd { prefer_flat: true }),
6887            &mut score,
6888        )
6889        .unwrap();
6890        let pitch = &score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
6891        assert_eq!(pitch.step, Step::D);
6892        assert_eq!(pitch.alter, -1); // Db4
6893    }
6894
6895    fn respell_fixture(measures: u32) -> Score {
6896        use crate::model::pitch::Step;
6897        let mut score = Score::new("Respell", 120, 4, 4, 0, measures);
6898        for measure in &mut score.parts[0].staves[0].measures {
6899            measure.voices[0] = vec![Note::new(Pitch::with_alter(Step::C, 4, 1), Duration::Whole)];
6900        }
6901        score
6902    }
6903
6904    fn spelled(score: &Score, measure: usize) -> (crate::model::pitch::Step, i8) {
6905        let pitch = &score.parts[0].staves[0].measures[measure].voices[0][0].pitches[0];
6906        (pitch.step.clone(), pitch.alter)
6907    }
6908
6909    #[test]
6910    fn respell_staff_region_changes_only_the_selection_and_is_undoable() {
6911        use crate::model::pitch::Step;
6912        let mut score = respell_fixture(4);
6913        let mut stack = CommandStack::new(50);
6914        let command = Command::RespellStaffRegion(RespellStaffRegionCmd {
6915            part_index: 0,
6916            staff_index: 0,
6917            start_measure: 1,
6918            end_measure: 3,
6919            policy: RespellPolicy::Flat,
6920        });
6921        let json = serde_json::to_string(&command).expect("command serializes");
6922        assert!(json.contains("\"policy\":\"flat\""));
6923        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
6924        assert_eq!(command_key(&decoded), "RespellStaffRegion");
6925        assert_eq!(command_label(&decoded), "Respell Pitches (flat)");
6926        stack.execute(decoded, &mut score).expect("region respells");
6927        assert_eq!(spelled(&score, 0), (Step::C, 1));
6928        assert_eq!(spelled(&score, 1), (Step::D, -1));
6929        assert_eq!(spelled(&score, 2), (Step::D, -1));
6930        assert_eq!(spelled(&score, 3), (Step::C, 1));
6931        stack.undo(&mut score).expect("region respell undoes");
6932        assert!((0..4).all(|measure| spelled(&score, measure) == (Step::C, 1)));
6933        stack.redo(&mut score).expect("region respell redoes");
6934        assert_eq!(spelled(&score, 2), (Step::D, -1));
6935    }
6936
6937    #[test]
6938    fn respell_staff_region_keeps_ties_across_the_boundary_in_one_spelling() {
6939        use crate::model::pitch::Step;
6940        let mut score = respell_fixture(4);
6941        {
6942            let measures = &mut score.parts[0].staves[0].measures;
6943            measures[0].voices[0][0].tie_start = true;
6944            measures[1].voices[0][0].tie_end = true;
6945            measures[1].voices[0][0].tie_start = true;
6946            measures[2].voices[0][0].tie_end = true;
6947        }
6948        let changed = respell_staff_region(&mut score, 0, 0, 1, 2, RespellPolicy::Flat)
6949            .expect("tied region respells");
6950        assert_eq!(changed, (0, 2));
6951        assert_eq!(spelled(&score, 0), (Step::D, -1));
6952        assert_eq!(spelled(&score, 1), (Step::D, -1));
6953        assert_eq!(spelled(&score, 2), (Step::D, -1));
6954        assert_eq!(spelled(&score, 3), (Step::C, 1));
6955    }
6956
6957    #[test]
6958    fn respell_staff_region_follows_local_keys_and_skips_unpitched_notes() {
6959        use crate::model::notation::KeySignature;
6960        use crate::model::pitch::Step;
6961        let mut score = respell_fixture(4);
6962        for measure in &mut score.parts[0].staves[0].measures {
6963            measure.voices[0][0].pitches[0] = Pitch::with_alter(Step::D, 4, -1);
6964        }
6965        score.parts[0].staves[0].measures[2].key_sig = Some(KeySignature {
6966            fifths: -3,
6967            mode: "major".into(),
6968        });
6969        score.parts[0].staves[0].measures[1].voices[0][0].is_unpitched = true;
6970        respell_staff_region(&mut score, 0, 0, 0, 4, RespellPolicy::Key).expect("key respell");
6971        assert_eq!(spelled(&score, 0), (Step::C, 1));
6972        assert_eq!(
6973            spelled(&score, 1),
6974            (Step::D, -1),
6975            "unpitched staff position is kept"
6976        );
6977        assert_eq!(spelled(&score, 2), (Step::D, -1));
6978        assert_eq!(spelled(&score, 3), (Step::D, -1));
6979
6980        assert!(matches!(
6981            respell_staff_region(&mut score, 0, 0, 2, 2, RespellPolicy::Sharp),
6982            Err(Error::InvalidCommand(_))
6983        ));
6984        assert!(matches!(
6985            respell_staff_region(&mut score, 0, 5, 0, 1, RespellPolicy::Sharp),
6986            Err(Error::StaffNotFound(5))
6987        ));
6988        assert!(matches!(
6989            respell_staff_region(&mut score, 0, 0, 0, 5, RespellPolicy::Sharp),
6990            Err(Error::InvalidCommand(_))
6991        ));
6992    }
6993
6994    #[test]
6995    fn pickup_measure_length_limits_editing_and_survives_time_signature_changes() {
6996        use crate::model::score::MeasureLength;
6997        let mut score = Score::new("Pickup", 120, 4, 4, 0, 2);
6998        score.parts[0].staves[0].measures[0].actual_length = Some(MeasureLength {
6999            numerator: 1,
7000            denominator: 4,
7001        });
7002        score.parts[0].staves[0].measures[0].voices[0] = vec![Note::rest(Duration::Quarter)];
7003        assert!(validate(&score).is_valid());
7004        assert_eq!(
7005            crate::measure_beats_remaining(&score, 0, 0, 0, 0).expect("capacity"),
7006            0.0
7007        );
7008        let mut stack = CommandStack::new(50);
7009        stack
7010            .execute(
7011                Command::AddNote(AddNoteCmd {
7012                    part_index: 0,
7013                    staff_index: 0,
7014                    measure_index: 0,
7015                    voice: 0,
7016                    position: 0,
7017                    pitch: Some(Pitch::new(Step::G, 4)),
7018                    duration: Duration::Quarter,
7019                    dot_count: 0,
7020                    is_rest: false,
7021                    tuplet: None,
7022                }),
7023                &mut score,
7024            )
7025            .expect("note fits the pickup after trimming");
7026        let pickup = &score.parts[0].staves[0].measures[0].voices[0];
7027        assert_eq!(pickup.len(), 1);
7028        assert!(!pickup[0].is_rest);
7029
7030        stack
7031            .execute(
7032                Command::SetTimeSignature(SetTimeSignatureCmd {
7033                    numerator: 3,
7034                    denominator: 4,
7035                }),
7036                &mut score,
7037            )
7038            .expect("time signature changes");
7039        let beats: f64 = score.parts[0].staves[0].measures[0].voices[0]
7040            .iter()
7041            .map(|note| note.beats())
7042            .sum();
7043        assert!((beats - 1.0).abs() < 1e-9, "the pickup keeps one beat");
7044
7045        score.parts[0].staves[0].measures[1].actual_length = Some(MeasureLength {
7046            numerator: 0,
7047            denominator: 4,
7048        });
7049        assert!(validate(&score).errors.iter().any(|error| matches!(
7050            error,
7051            crate::ValidationError::InvalidMeasureLength { measure: 1, .. }
7052        )));
7053    }
7054
7055    #[test]
7056    fn set_lyric_addresses_verses_without_touching_verse_one() {
7057        use crate::model::notation::VerseLyric;
7058        let mut score = Score::new("Verses", 120, 4, 4, 0, 1);
7059        score.parts[0].staves[0].measures[0].voices[0] =
7060            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
7061        let lyric = |text: &str| Lyric {
7062            text: text.into(),
7063            syllabic: "single".into(),
7064        };
7065        let set = |verse: Option<u8>, text: Option<&str>| {
7066            Command::SetLyric(SetLyricCmd {
7067                part_index: 0,
7068                staff_index: 0,
7069                measure_index: 0,
7070                voice: 0,
7071                note_index: 0,
7072                lyric: text.map(lyric),
7073                verse,
7074            })
7075        };
7076        let mut stack = CommandStack::new(50);
7077        stack
7078            .execute(set(None, Some("one")), &mut score)
7079            .expect("verse 1");
7080        stack
7081            .execute(set(Some(3), Some("three")), &mut score)
7082            .expect("verse 3");
7083        stack
7084            .execute(set(Some(2), Some("two")), &mut score)
7085            .expect("verse 2");
7086        stack
7087            .execute(set(Some(2), Some("TWO")), &mut score)
7088            .expect("replace verse 2");
7089        let note = |score: &Score| score.parts[0].staves[0].measures[0].voices[0][0].clone();
7090        assert_eq!(note(&score).lyric, Some(lyric("one")));
7091        assert_eq!(
7092            note(&score).additional_lyrics,
7093            vec![
7094                VerseLyric {
7095                    verse: 2,
7096                    lyric: lyric("TWO")
7097                },
7098                VerseLyric {
7099                    verse: 3,
7100                    lyric: lyric("three")
7101                }
7102            ]
7103        );
7104        stack
7105            .execute(set(Some(3), None), &mut score)
7106            .expect("clear verse 3");
7107        assert_eq!(note(&score).additional_lyrics.len(), 1);
7108        stack.undo(&mut score).expect("clear undoes");
7109        assert_eq!(note(&score).additional_lyrics.len(), 2);
7110
7111        // Legacy JSON without `verse` still targets verse 1.
7112        let current = serde_json::to_string(&set(None, None)).expect("SetLyric serializes");
7113        assert!(current.contains("\"verse\":null"));
7114        let legacy: Command = serde_json::from_str(&current.replace(",\"verse\":null", ""))
7115            .expect("legacy SetLyric JSON");
7116        stack
7117            .execute(legacy, &mut score)
7118            .expect("legacy clears verse 1");
7119        assert_eq!(note(&score).lyric, None);
7120        assert_eq!(note(&score).additional_lyrics.len(), 2);
7121
7122        assert!(matches!(
7123            apply_command(&set(Some(0), Some("zero")), &mut score),
7124            Err(Error::InvalidCommand(_))
7125        ));
7126        assert!(matches!(
7127            apply_command(&set(Some(33), Some("too far")), &mut score),
7128            Err(Error::InvalidCommand(_))
7129        ));
7130        score.parts[0].staves[0].measures[0].voices[0][0]
7131            .additional_lyrics
7132            .push(VerseLyric {
7133                verse: 2,
7134                lyric: lyric("duplicate"),
7135            });
7136        assert!(validate(&score).errors.iter().any(|error| matches!(
7137            error,
7138            crate::ValidationError::InvalidLyricVerse { verse: 2, .. }
7139        )));
7140    }
7141
7142    #[test]
7143    fn cycle_enharmonic_spelling_targets_one_chord_member_and_undoes() {
7144        let mut score = Score::new("Enharmonic", 120, 4, 4, 0, 1);
7145        let mut chord = Note::new(Pitch::with_alter(Step::C, 4, 1), Duration::Whole);
7146        chord.pitches.push(Pitch::with_alter(Step::G, 4, 1));
7147        score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
7148        let mut stack = CommandStack::new(50);
7149        let command = Command::CycleEnharmonicSpelling(CycleEnharmonicSpellingCmd {
7150            part_index: 0,
7151            staff_index: 0,
7152            measure_index: 0,
7153            voice: 0,
7154            note_index: 0,
7155            pitch_index: Some(1),
7156        });
7157        let json = serde_json::to_string(&command).expect("command serializes");
7158        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
7159        assert_eq!(command_key(&decoded), "CycleEnharmonicSpelling");
7160        stack.execute(decoded, &mut score).expect("spelling cycles");
7161        let pitches = |score: &Score| {
7162            score.parts[0].staves[0].measures[0].voices[0][0]
7163                .pitches
7164                .clone()
7165        };
7166        assert_eq!(
7167            pitches(&score),
7168            vec![
7169                Pitch::with_alter(Step::C, 4, 1),
7170                Pitch::with_alter(Step::A, 4, -1)
7171            ]
7172        );
7173        stack.undo(&mut score).expect("spelling undoes");
7174        assert_eq!(pitches(&score)[1], Pitch::with_alter(Step::G, 4, 1));
7175
7176        let mut rest_score = Score::new("Rest", 120, 4, 4, 0, 1);
7177        rest_score.parts[0].staves[0].measures[0].voices[0] = vec![Note::rest(Duration::Whole)];
7178        assert!(matches!(
7179            apply_command(&command, &mut rest_score),
7180            Err(Error::InvalidCommand(_))
7181        ));
7182    }
7183
7184    #[test]
7185    fn resequence_rehearsal_marks_continues_the_first_sequence() {
7186        let mut score = Score::new("Marks", 120, 4, 4, 0, 30);
7187        let mut stack = CommandStack::new(50);
7188        for (index, text) in [(0, "A"), (4, "C"), (9, "C"), (12, "Q")] {
7189            for_each_measure_at(&mut score, index, |measure| {
7190                measure.rehearsal = Some(text.into())
7191            });
7192        }
7193        let command = Command::ResequenceRehearsalMarks(ResequenceRehearsalMarksCmd::default());
7194        let json = serde_json::to_string(&command).expect("command serializes");
7195        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
7196        assert_eq!(command_key(&decoded), "ResequenceRehearsalMarks");
7197        stack
7198            .execute(decoded, &mut score)
7199            .expect("marks resequence");
7200        let marks = |score: &Score| -> Vec<Option<String>> {
7201            [0, 4, 9, 12]
7202                .iter()
7203                .map(|&index| score.parts[0].staves[0].measures[index].rehearsal.clone())
7204                .collect()
7205        };
7206        assert_eq!(
7207            marks(&score),
7208            ["A", "B", "C", "D"].map(|text| Some(text.to_string()))
7209        );
7210        stack.undo(&mut score).expect("resequence undoes");
7211        assert_eq!(marks(&score)[1].as_deref(), Some("C"));
7212
7213        let sequence = RehearsalSequence::detect("Z", 1).expect("letters");
7214        assert_eq!(sequence.nth(1, 1), "AA");
7215        assert_eq!(sequence.nth(2, 1), "AB");
7216        let lower = RehearsalSequence::detect("y", 1).expect("lower letters");
7217        assert_eq!(lower.nth(2, 1), "aa");
7218        assert_eq!(
7219            RehearsalSequence::detect("7", 3)
7220                .expect("numbers")
7221                .nth(2, 9),
7222            "9"
7223        );
7224        assert_eq!(
7225            RehearsalSequence::detect("5", 5)
7226                .expect("measure numbers")
7227                .nth(3, 17),
7228            "17"
7229        );
7230        assert!(RehearsalSequence::detect("Intro", 1).is_none());
7231
7232        for_each_measure_at(&mut score, 0, |measure| {
7233            measure.rehearsal = Some("Intro".into())
7234        });
7235        assert!(matches!(
7236            apply_command(&command, &mut score),
7237            Err(Error::InvalidCommand(_))
7238        ));
7239    }
7240
7241    #[test]
7242    fn system_break_interval_replaces_line_breaks_only() {
7243        let mut score = Score::new("Breaks", 120, 4, 4, 0, 10);
7244        score.parts[0].staves[0].measures[1].system_break = true;
7245        score.parts[0].staves[0].measures[2].page_break = true;
7246        apply_command(
7247            &Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
7248                interval: 4,
7249                start_measure: None,
7250                end_measure: None,
7251            }),
7252            &mut score,
7253        )
7254        .expect("breaks every four measures");
7255        let breaks: Vec<usize> = score.parts[0].staves[0]
7256            .measures
7257            .iter()
7258            .enumerate()
7259            .filter(|(_, measure)| measure.system_break)
7260            .map(|(index, _)| index)
7261            .collect();
7262        assert_eq!(breaks, vec![3, 7]);
7263        assert!(score.parts[0].staves[0].measures[2].page_break);
7264
7265        let mut exact = Score::new("Exact", 120, 4, 4, 0, 8);
7266        apply_command(
7267            &Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
7268                interval: 4,
7269                start_measure: None,
7270                end_measure: None,
7271            }),
7272            &mut exact,
7273        )
7274        .expect("no break after the final measure");
7275        assert!(!exact.parts[0].staves[0].measures[7].system_break);
7276        assert!(exact.parts[0].staves[0].measures[3].system_break);
7277
7278        apply_command(
7279            &Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
7280                interval: 0,
7281                start_measure: Some(0),
7282                end_measure: Some(6),
7283            }),
7284            &mut score,
7285        )
7286        .expect("breaks removed in range");
7287        assert!(!score.parts[0].staves[0].measures[3].system_break);
7288        assert!(score.parts[0].staves[0].measures[7].system_break);
7289        assert!(matches!(
7290            apply_command(
7291                &Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
7292                    interval: 2,
7293                    start_measure: Some(4),
7294                    end_measure: Some(11),
7295                }),
7296                &mut score,
7297            ),
7298            Err(Error::InvalidCommand(_))
7299        ));
7300    }
7301
7302    #[test]
7303    fn remove_trailing_empty_measures_keeps_content_and_final_barline() {
7304        let mut score = Score::new("Trailing", 120, 4, 4, 0, 6);
7305        let mut stack = CommandStack::new(50);
7306        score.parts[0].staves[0].measures[1].voices[0] =
7307            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
7308        score.parts[0].staves[0].measures[5].barline_right = Barline::Final;
7309        let mut fermata_rest = Note::rest(Duration::Whole);
7310        fermata_rest.articulations = vec![Articulation::Fermata];
7311        score.parts[0].staves[0].measures[3].voices[0] = vec![fermata_rest];
7312        stack
7313            .execute(
7314                Command::RemoveTrailingEmptyMeasures(RemoveTrailingEmptyMeasuresCmd {}),
7315                &mut score,
7316            )
7317            .expect("trailing measures removed");
7318        let staff = &score.parts[0].staves[0];
7319        assert_eq!(staff.measures.len(), 4, "a rest with a fermata is content");
7320        assert!(matches!(staff.measures[3].barline_right, Barline::Final));
7321        assert_eq!(staff.measures[3].number, 4);
7322        stack.undo(&mut score).expect("removal undoes");
7323        assert_eq!(score.parts[0].staves[0].measures.len(), 6);
7324
7325        let mut empty = Score::new("Empty", 120, 4, 4, 0, 3);
7326        apply_command(
7327            &Command::RemoveTrailingEmptyMeasures(RemoveTrailingEmptyMeasuresCmd {}),
7328            &mut empty,
7329        )
7330        .expect("empty score keeps one measure");
7331        assert_eq!(empty.parts[0].staves[0].measures.len(), 1);
7332    }
7333
7334    #[test]
7335    fn typed_spanner_commands_are_atomic_and_undoable() {
7336        use crate::model::score::{NotationSpanner, NotationSpannerKind};
7337
7338        let mut score = default_engine_score();
7339        let address = NoteAddr {
7340            part: 0,
7341            staff: 0,
7342            measure: 0,
7343            voice: 0,
7344            note: 0,
7345        };
7346        let spanner = NotationSpanner {
7347            id: "slur-1".to_string(),
7348            kind: NotationSpannerKind::Slur,
7349            start: address.clone(),
7350            end: address,
7351            number: Some(1),
7352            line_type: Some("dashed".to_string()),
7353            text: None,
7354            placement: Some("above".to_string()),
7355            ottava_size: None,
7356            ottava_type: None,
7357        };
7358        let mut stack = CommandStack::new(8);
7359        stack
7360            .execute(Command::AddSpanner(AddSpannerCmd { spanner }), &mut score)
7361            .unwrap();
7362        assert_eq!(score.spanners.len(), 1);
7363        assert_eq!(stack.undo_key().as_deref(), Some("AddSpanner"));
7364
7365        let before_duplicate = score.clone();
7366        let duplicate = score.spanners[0].clone();
7367        assert!(
7368            stack
7369                .execute(
7370                    Command::AddSpanner(AddSpannerCmd { spanner: duplicate }),
7371                    &mut score,
7372                )
7373                .is_err()
7374        );
7375        assert_eq!(score.spanners, before_duplicate.spanners);
7376
7377        let mut updated = score.spanners[0].clone();
7378        updated.number = Some(2);
7379        stack
7380            .execute(
7381                Command::UpdateSpanner(UpdateSpannerCmd { spanner: updated }),
7382                &mut score,
7383            )
7384            .unwrap();
7385        assert_eq!(score.spanners[0].number, Some(2));
7386        stack.undo(&mut score).unwrap();
7387        assert_eq!(score.spanners[0].number, Some(1));
7388        stack.redo(&mut score).unwrap();
7389        assert_eq!(score.spanners[0].number, Some(2));
7390
7391        stack
7392            .execute(
7393                Command::RemoveSpanner(RemoveSpannerCmd {
7394                    id: "slur-1".to_string(),
7395                }),
7396                &mut score,
7397            )
7398            .unwrap();
7399        assert!(score.spanners.is_empty());
7400        stack.undo(&mut score).unwrap();
7401        assert_eq!(score.spanners.len(), 1);
7402    }
7403
7404    #[test]
7405    fn string_quartet_linked_views_roundtrip_and_undo_redo_as_one_contract() {
7406        let mut score = Score::template(ScoreTemplate::StringQuartet);
7407        let mut stack = CommandStack::new(16);
7408        for (part, id, name) in [
7409            (0, "violin-1", "Violin I"),
7410            (1, "violin-2", "Violin II"),
7411            (2, "viola", "Viola"),
7412            (3, "cello", "Cello"),
7413        ] {
7414            stack
7415                .execute(
7416                    Command::UpsertScoreView(UpsertScoreViewCmd {
7417                        view: ScoreView::linked_part(id, name, part),
7418                    }),
7419                    &mut score,
7420                )
7421                .expect("linked part view applies");
7422        }
7423        assert_eq!(score.parts.len(), 4);
7424        assert_eq!(score.views.len(), 4);
7425        for (part, view) in score.views.iter().enumerate() {
7426            assert_eq!(view.parts, vec![part]);
7427            assert_eq!(score.resolve_view(&view.id).unwrap().parts.len(), 1);
7428        }
7429
7430        let restored: Score =
7431            serde_json::from_str(&serde_json::to_string(&score).expect("quartet score serializes"))
7432                .expect("quartet score deserializes");
7433        assert_eq!(restored.views, score.views);
7434
7435        for _ in 0..4 {
7436            stack.undo(&mut score).expect("view undo applies");
7437        }
7438        assert!(score.views.is_empty());
7439        for _ in 0..4 {
7440            stack.redo(&mut score).expect("view redo applies");
7441        }
7442        assert_eq!(score.views, restored.views);
7443    }
7444
7445    #[test]
7446    fn section_break_is_atomic_undoable_and_synced_across_staves() {
7447        let mut score = Score::template(ScoreTemplate::Piano);
7448        let original = score.clone();
7449        let mut stack = CommandStack::new(4);
7450        stack
7451            .execute(
7452                Command::SetSectionBreak(SetSectionBreakCmd {
7453                    measure_index: 2,
7454                    value: true,
7455                }),
7456                &mut score,
7457            )
7458            .expect("section break applies");
7459        assert!(
7460            score
7461                .parts
7462                .iter()
7463                .flat_map(|part| part.staves.iter())
7464                .all(|staff| staff.measures[2].section_break)
7465        );
7466        stack.undo(&mut score).expect("section break undoes");
7467        assert_eq!(
7468            serde_json::to_value(&score).unwrap(),
7469            serde_json::to_value(&original).unwrap()
7470        );
7471
7472        assert!(
7473            stack
7474                .execute(
7475                    Command::SetSectionBreak(SetSectionBreakCmd {
7476                        measure_index: 99,
7477                        value: true,
7478                    }),
7479                    &mut score,
7480                )
7481                .is_err()
7482        );
7483        assert_eq!(
7484            serde_json::to_value(&score).unwrap(),
7485            serde_json::to_value(&original).unwrap()
7486        );
7487    }
7488}