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