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