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