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 in source_measure.voices.iter().skip(cmd.target_staves.len()) {
4215            if !voice.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 in source_measure.voices.iter().take(cmd.target_staves.len()) {
4222            if voice.iter().any(|note| note.cross_staff.is_some()) {
4223                return Err(Error::InvalidCommand(
4224                    "explode does not support cross-staff source notes".into(),
4225                ));
4226            }
4227            let total: f64 = voice.iter().map(Note::beats).sum();
4228            if total > expected + 1e-9 {
4229                return Err(Error::InvalidCommand(
4230                    "explode source voice exceeds its measure duration".into(),
4231                ));
4232            }
4233        }
4234        for &staff_index in cmd.target_staves.iter().skip(1) {
4235            let target = &part.staves[staff_index];
4236            let target_beats = effective_staff_measure_beats(
4237                target,
4238                &score.settings.time_signature,
4239                measure_index,
4240            )?;
4241            if (target_beats - expected).abs() > 1e-9 {
4242                return Err(Error::InvalidCommand(
4243                    "explode target staves must have matching measure durations".into(),
4244                ));
4245            }
4246            let measure = target
4247                .measures
4248                .get(measure_index)
4249                .ok_or(Error::MeasureNotFound(measure_index))?;
4250            if measure.voices[0].iter().any(|note| !note.is_rest)
4251                || measure.voices[1..].iter().any(|voice| !voice.is_empty())
4252                || measure.source_voice_numbers.iter().any(Option::is_some)
4253            {
4254                return Err(Error::InvalidCommand(
4255                    "explode destination staff must contain only an unnumbered rest voice".into(),
4256                ));
4257            }
4258        }
4259    }
4260
4261    for measure_index in start..=end {
4262        let transferred: Vec<(Vec<Note>, Option<u32>)> = (0..cmd.target_staves.len())
4263            .map(|voice_index| {
4264                let measure =
4265                    &score.parts[cmd.part_index].staves[cmd.source_staff].measures[measure_index];
4266                (
4267                    measure.voices[voice_index].clone(),
4268                    measure.source_voice_numbers[voice_index],
4269                )
4270            })
4271            .collect();
4272        for (voice_index, &staff_index) in cmd.target_staves.iter().enumerate() {
4273            let (notes, source_voice_number) = &transferred[voice_index];
4274            let target =
4275                &mut score.parts[cmd.part_index].staves[staff_index].measures[measure_index];
4276            target.voices = [notes.clone(), Vec::new(), Vec::new(), Vec::new()];
4277            target.source_voice_numbers = [*source_voice_number, None, None, None];
4278        }
4279    }
4280    remap_spanners(score, |address| {
4281        if address.part != cmd.part_index
4282            || address.staff != cmd.source_staff
4283            || address.measure < start
4284            || address.measure > end
4285            || address.voice >= cmd.target_staves.len()
4286        {
4287            return Some(address.clone());
4288        }
4289        Some(NoteAddr {
4290            staff: cmd.target_staves[address.voice],
4291            voice: 0,
4292            ..address.clone()
4293        })
4294    });
4295    Ok(())
4296}
4297
4298fn clear_derived_chord_note_notation(note: &mut Note) {
4299    note.articulations.clear();
4300    note.dynamic = None;
4301    note.stem_up = None;
4302    note.hairpin_start = None;
4303    note.hairpin_above = None;
4304    note.hairpin_end = false;
4305    note.chord_symbol = None;
4306    note.ottava_start = None;
4307    note.ottava_end = false;
4308    note.lyric = None;
4309    note.additional_lyrics.clear();
4310    note.pedal_start = false;
4311    note.pedal_end = false;
4312    note.slur_start = false;
4313    note.slur_end = false;
4314    note.arpeggiate = None;
4315    note.technique_text = None;
4316    note.glissando_start = false;
4317    note.glissando_end = false;
4318    note.cross_staff = None;
4319    note.fingering = None;
4320    note.fingerings.clear();
4321    note.string_number = None;
4322    note.trill_line_start = false;
4323    note.trill_line_end = false;
4324    note.guitar_technique = None;
4325    note.guitar_bend_alter_cents = None;
4326    note.guitar_bend_curve.clear();
4327}
4328
4329fn exploded_chord_note(source: &Note, pitch_index: usize) -> Note {
4330    if pitch_index == 0 {
4331        let mut retained = source.clone();
4332        if !retained.is_rest {
4333            retained.pitches = vec![source.pitches[0].clone()];
4334            retained.tab_positions = source.tab_positions.first().cloned().into_iter().collect();
4335            retained.tab_position = retained.tab_positions.first().cloned();
4336        }
4337        return retained;
4338    }
4339    let mut derived = source.clone();
4340    derived.id = Uuid::new_v4().to_string();
4341    clear_derived_chord_note_notation(&mut derived);
4342    if derived.is_rest || pitch_index >= source.pitches.len() {
4343        derived.is_rest = true;
4344        derived.is_unpitched = false;
4345        derived.pitches.clear();
4346        derived.tab_position = None;
4347        derived.tab_positions.clear();
4348    } else {
4349        derived.pitches = vec![source.pitches[pitch_index].clone()];
4350        derived.tab_positions = source
4351            .tab_positions
4352            .get(pitch_index)
4353            .cloned()
4354            .into_iter()
4355            .collect();
4356        derived.tab_position = derived.tab_positions.first().cloned();
4357    }
4358    derived
4359}
4360
4361fn apply_explode_chord_pitches(
4362    cmd: &ExplodeChordPitchesCmd,
4363    score: &mut Score,
4364) -> Result<(), Error> {
4365    if !(2..=4).contains(&cmd.target_staves.len()) {
4366        return Err(Error::InvalidCommand(
4367            "chord explode requires two to four target staves".into(),
4368        ));
4369    }
4370    if cmd.target_staves.first() != Some(&cmd.source_staff) {
4371        return Err(Error::InvalidCommand(
4372            "chord explode target staves must begin with the source staff".into(),
4373        ));
4374    }
4375    let mut unique_staves = BTreeSet::new();
4376    for &staff_index in &cmd.target_staves {
4377        if !unique_staves.insert(staff_index) {
4378            return Err(Error::InvalidCommand(
4379                "chord explode target staves must be distinct".into(),
4380            ));
4381        }
4382    }
4383    let (start, end) = normalized_structural_measure_range(cmd.start_measure, cmd.end_measure);
4384    let part = score
4385        .parts
4386        .get(cmd.part_index)
4387        .ok_or(Error::PartNotFound(cmd.part_index))?;
4388    let source = part
4389        .staves
4390        .get(cmd.source_staff)
4391        .ok_or(Error::StaffNotFound(cmd.source_staff))?;
4392    for &staff_index in &cmd.target_staves {
4393        if part.staves.get(staff_index).is_none() {
4394            return Err(Error::StaffNotFound(staff_index));
4395        }
4396    }
4397    for measure_index in start..=end {
4398        let expected =
4399            effective_staff_measure_beats(source, &score.settings.time_signature, measure_index)?;
4400        let source_measure = source
4401            .measures
4402            .get(measure_index)
4403            .ok_or(Error::MeasureNotFound(measure_index))?;
4404        for note in &source_measure.voices[0] {
4405            if note.tuplet.is_some() {
4406                return Err(Error::InvalidCommand(
4407                    "chord explode does not support tuplets".into(),
4408                ));
4409            }
4410            if note.cross_staff.is_some() {
4411                return Err(Error::InvalidCommand(
4412                    "chord explode does not support cross-staff source notes".into(),
4413                ));
4414            }
4415            if note.is_unpitched || note.is_grace || note.is_cue {
4416                return Err(Error::InvalidCommand(
4417                    "chord explode does not support unpitched, grace, or cue notes".into(),
4418                ));
4419            }
4420            if !note.is_rest
4421                && (note.pitches.is_empty() || note.pitches.len() > cmd.target_staves.len())
4422            {
4423                return Err(Error::InvalidCommand(
4424                    "chord explode pitch count must fit target staves".into(),
4425                ));
4426            }
4427        }
4428        let total: f64 = source_measure.voices[0].iter().map(Note::beats).sum();
4429        if total > expected + 1e-9 {
4430            return Err(Error::InvalidCommand(
4431                "chord explode source voice exceeds its measure duration".into(),
4432            ));
4433        }
4434        for &staff_index in cmd.target_staves.iter().skip(1) {
4435            let target = &part.staves[staff_index];
4436            let target_beats = effective_staff_measure_beats(
4437                target,
4438                &score.settings.time_signature,
4439                measure_index,
4440            )?;
4441            if (target_beats - expected).abs() > 1e-9 {
4442                return Err(Error::InvalidCommand(
4443                    "chord explode target staves must have matching measure durations".into(),
4444                ));
4445            }
4446            let measure = target
4447                .measures
4448                .get(measure_index)
4449                .ok_or(Error::MeasureNotFound(measure_index))?;
4450            if measure.voices[0].iter().any(|note| !note.is_rest)
4451                || measure.voices[1..].iter().any(|voice| !voice.is_empty())
4452                || measure.source_voice_numbers.iter().any(Option::is_some)
4453            {
4454                return Err(Error::InvalidCommand(
4455                    "chord explode destination staff must contain only an unnumbered rest voice"
4456                        .into(),
4457                ));
4458            }
4459        }
4460    }
4461    for measure_index in start..=end {
4462        let source_measure =
4463            &score.parts[cmd.part_index].staves[cmd.source_staff].measures[measure_index];
4464        let source_voice_number = source_measure.source_voice_numbers[0];
4465        let mut exploded = vec![Vec::new(); cmd.target_staves.len()];
4466        for note in &source_measure.voices[0] {
4467            for (pitch_index, voice) in exploded.iter_mut().enumerate() {
4468                voice.push(exploded_chord_note(note, pitch_index));
4469            }
4470        }
4471        for (index, &staff_index) in cmd.target_staves.iter().enumerate() {
4472            let target =
4473                &mut score.parts[cmd.part_index].staves[staff_index].measures[measure_index];
4474            target.voices = [exploded[index].clone(), Vec::new(), Vec::new(), Vec::new()];
4475            target.source_voice_numbers = [source_voice_number, None, None, None];
4476        }
4477    }
4478    Ok(())
4479}
4480
4481fn scaled_duration(duration: &Duration, scale: DurationScale) -> Option<Duration> {
4482    const ORDER: [Duration; 10] = [
4483        Duration::Longa,
4484        Duration::Breve,
4485        Duration::Whole,
4486        Duration::Half,
4487        Duration::Quarter,
4488        Duration::Eighth,
4489        Duration::Sixteenth,
4490        Duration::ThirtySecond,
4491        Duration::SixtyFourth,
4492        Duration::HundredTwentyEighth,
4493    ];
4494    let index = ORDER.iter().position(|value| value == duration)?;
4495    let scaled = match scale {
4496        DurationScale::Double => index.checked_sub(1)?,
4497        DurationScale::Half => index + 1,
4498    };
4499    ORDER.get(scaled).cloned()
4500}
4501
4502fn uniform_tuplet_ratio(voice: &[Note]) -> Result<Option<TupletInfo>, Error> {
4503    let mut ratio: Option<TupletInfo> = None;
4504    for note in voice {
4505        let Some(tuplet) = &note.tuplet else {
4506            continue;
4507        };
4508        if tuplet.actual_notes == 0 || tuplet.normal_notes == 0 {
4509            return Err(Error::InvalidCommand(
4510                "duration scaling requires a non-zero tuplet ratio".into(),
4511            ));
4512        }
4513        if let Some(existing) = &ratio {
4514            if existing != tuplet {
4515                return Err(Error::InvalidCommand(
4516                    "duration scaling requires one shared tuplet ratio per voice".into(),
4517                ));
4518            }
4519        } else {
4520            ratio = Some(tuplet.clone());
4521        }
4522    }
4523    Ok(ratio)
4524}
4525
4526fn pad_voice_to_measure_with_tuplet_ratio(
4527    voice: &mut Vec<Note>,
4528    max_beats: f64,
4529    ratio: &TupletInfo,
4530) -> Result<(), Error> {
4531    let mut used: f64 = voice.iter().map(Note::beats).sum();
4532    let ratio_scale = f64::from(ratio.normal_notes) / f64::from(ratio.actual_notes);
4533    while max_beats - used > 1e-9 {
4534        let remaining = max_beats - used;
4535        let duration = [
4536            Duration::Whole,
4537            Duration::Half,
4538            Duration::Quarter,
4539            Duration::Eighth,
4540            Duration::Sixteenth,
4541            Duration::ThirtySecond,
4542            Duration::SixtyFourth,
4543            Duration::HundredTwentyEighth,
4544        ]
4545        .into_iter()
4546        .find(|duration| duration.beats(0) * ratio_scale <= remaining + 1e-9)
4547        .ok_or_else(|| {
4548            Error::InvalidCommand(
4549                "duration scaling cannot represent the remaining tuplet duration".into(),
4550            )
4551        })?;
4552        let mut rest = Note::rest(duration);
4553        rest.tuplet = Some(ratio.clone());
4554        used += rest.beats();
4555        voice.push(rest);
4556    }
4557    Ok(())
4558}
4559
4560fn apply_scale_voice_range(cmd: &ScaleVoiceRangeCmd, score: &mut Score) -> Result<(), Error> {
4561    if cmd.voice >= 4 {
4562        return Err(Error::VoiceOutOfRange(cmd.voice));
4563    }
4564    let (start, end) = normalized_structural_measure_range(cmd.start_measure, cmd.end_measure);
4565    let staff = score
4566        .parts
4567        .get(cmd.part_index)
4568        .ok_or(Error::PartNotFound(cmd.part_index))?
4569        .staves
4570        .get(cmd.staff_index)
4571        .ok_or(Error::StaffNotFound(cmd.staff_index))?;
4572    for measure_index in start..=end {
4573        let measure = staff
4574            .measures
4575            .get(measure_index)
4576            .ok_or(Error::MeasureNotFound(measure_index))?;
4577        let expected =
4578            effective_staff_measure_beats(staff, &score.settings.time_signature, measure_index)?;
4579        let voice = &measure.voices[cmd.voice];
4580        match cmd.tuplet_policy {
4581            TupletScalePolicy::PreserveRatio => {
4582                uniform_tuplet_ratio(voice)?;
4583            }
4584        }
4585        let mut scaled_beats = 0.0;
4586        for note in voice {
4587            if scaled_duration(&note.duration, cmd.scale).is_none() {
4588                return Err(Error::InvalidCommand(
4589                    "duration scaling exceeds the portable duration range".into(),
4590                ));
4591            }
4592            if !note.is_grace && !note.is_cue {
4593                scaled_beats += match cmd.scale {
4594                    DurationScale::Half => note.beats() / 2.0,
4595                    DurationScale::Double => note.beats() * 2.0,
4596                };
4597            }
4598        }
4599        if scaled_beats > expected + 1e-9 {
4600            return Err(Error::InvalidCommand(format!(
4601                "duration scaling overflows measure {measure_index}: {scaled_beats} beats exceeds {expected}"
4602            )));
4603        }
4604    }
4605
4606    for measure_index in start..=end {
4607        let expected = effective_staff_measure_beats(
4608            &score.parts[cmd.part_index].staves[cmd.staff_index],
4609            &score.settings.time_signature,
4610            measure_index,
4611        )?;
4612        let voice = &mut score.parts[cmd.part_index].staves[cmd.staff_index].measures
4613            [measure_index]
4614            .voices[cmd.voice];
4615        if voice.is_empty() {
4616            continue;
4617        }
4618        let tuplet_ratio = match cmd.tuplet_policy {
4619            TupletScalePolicy::PreserveRatio => uniform_tuplet_ratio(voice)?,
4620        };
4621        for note in voice.iter_mut() {
4622            note.duration = scaled_duration(&note.duration, cmd.scale).ok_or_else(|| {
4623                Error::InvalidCommand("duration scaling exceeds the portable duration range".into())
4624            })?;
4625        }
4626        if let Some(ratio) = tuplet_ratio {
4627            pad_voice_to_measure_with_tuplet_ratio(voice, expected, &ratio)?;
4628        } else {
4629            pad_voice_to_measure(voice, expected);
4630        }
4631    }
4632    Ok(())
4633}
4634
4635fn apply_paste_score_fragment(cmd: &PasteScoreFragmentCmd, score: &mut Score) -> Result<(), Error> {
4636    if !(MIN_SUPPORTED_SCORE_FRAGMENT_CONTRACT_VERSION..=SCORE_FRAGMENT_CONTRACT_VERSION)
4637        .contains(&cmd.fragment.contract_version)
4638    {
4639        return Err(Error::InvalidCommand(format!(
4640            "unsupported score fragment contract version {}",
4641            cmd.fragment.contract_version
4642        )));
4643    }
4644    if cmd.fragment.voices.is_empty() {
4645        return Err(Error::InvalidCommand(
4646            "cannot paste an empty score fragment".into(),
4647        ));
4648    }
4649
4650    // Resolve every target before mutating the score. CommandStack then also
4651    // validates the complete candidate before adding history, preserving
4652    // score/history atomicity for invalid destinations.
4653    let mut lanes = BTreeSet::new();
4654    let mut replaced = BTreeSet::new();
4655    for lane in &cmd.fragment.voices {
4656        let part_index = cmd
4657            .target
4658            .part
4659            .checked_add(lane.relative_part)
4660            .ok_or_else(|| Error::InvalidCommand("fragment part target overflows".into()))?;
4661        let staff_index = cmd
4662            .target
4663            .staff
4664            .checked_add(lane.relative_staff)
4665            .ok_or_else(|| Error::InvalidCommand("fragment staff target overflows".into()))?;
4666        let voice_index = cmd
4667            .target
4668            .voice
4669            .checked_add(lane.relative_voice)
4670            .ok_or_else(|| Error::InvalidCommand("fragment voice target overflows".into()))?;
4671        if voice_index >= 4 {
4672            return Err(Error::VoiceOutOfRange(voice_index));
4673        }
4674        let staff = score
4675            .parts
4676            .get(part_index)
4677            .ok_or(Error::PartNotFound(part_index))?
4678            .staves
4679            .get(staff_index)
4680            .ok_or(Error::StaffNotFound(staff_index))?;
4681        if !lanes.insert((part_index, staff_index, voice_index)) {
4682            return Err(Error::InvalidCommand(
4683                "fragment contains duplicate destination voice lanes".into(),
4684            ));
4685        }
4686        for measure in &lane.measures {
4687            let measure_index = cmd
4688                .target
4689                .measure
4690                .checked_add(measure.relative_measure)
4691                .ok_or_else(|| Error::InvalidCommand("fragment measure target overflows".into()))?;
4692            if measure_index >= staff.measures.len() {
4693                return Err(Error::MeasureNotFound(measure_index));
4694            }
4695            if !measure.cross_staff_targets.is_empty()
4696                && measure.cross_staff_targets.len() != measure.notes.len()
4697            {
4698                return Err(Error::InvalidCommand(
4699                    "fragment cross-staff targets do not match note count".into(),
4700                ));
4701            }
4702            if cmd.policy == ScoreFragmentPastePolicy::Merge
4703                && !measure.notes.is_empty()
4704                && staff.measures[measure_index].voices[voice_index]
4705                    .iter()
4706                    .any(|note| !note.is_rest)
4707            {
4708                return Err(Error::InvalidCommand(
4709                    "fragment merge would overwrite a sounding destination lane".into(),
4710                ));
4711            }
4712            if cmd.policy == ScoreFragmentPastePolicy::Merge
4713                && measure.attributes.present
4714                && measure.attributes
4715                    != super::fragment::ScoreFragmentMeasureAttributes::from_measure(
4716                        &staff.measures[measure_index],
4717                    )
4718            {
4719                return Err(Error::InvalidCommand(
4720                    "fragment merge would overwrite destination measure attributes".into(),
4721                ));
4722            }
4723            for cross_staff in measure.cross_staff_targets.iter().flatten() {
4724                let target_staff = staff_index as i64 + cross_staff.staff_offset;
4725                if target_staff < 0
4726                    || score.parts[part_index]
4727                        .staves
4728                        .get(target_staff as usize)
4729                        .is_none()
4730                {
4731                    return Err(Error::InvalidCommand(
4732                        "fragment cross-staff target is outside destination part".into(),
4733                    ));
4734                }
4735                if cross_staff.target_voice.is_some_and(|voice| voice >= 4) {
4736                    return Err(Error::InvalidCommand(
4737                        "fragment cross-staff target voice is outside editable range".into(),
4738                    ));
4739                }
4740            }
4741            replaced.insert((part_index, staff_index, measure_index, voice_index));
4742        }
4743    }
4744
4745    // Replacing a lane invalidates its former typed-span endpoints. Remove
4746    // those spans explicitly rather than allowing legacy endpoint flags to
4747    // leave dangling canonical addresses.
4748    score.spanners.retain(|spanner| {
4749        !replaced.contains(&(
4750            spanner.start.part,
4751            spanner.start.staff,
4752            spanner.start.measure,
4753            spanner.start.voice,
4754        )) && !replaced.contains(&(
4755            spanner.end.part,
4756            spanner.end.staff,
4757            spanner.end.measure,
4758            spanner.end.voice,
4759        ))
4760    });
4761    for lane in &cmd.fragment.voices {
4762        let part_index = cmd.target.part + lane.relative_part;
4763        let staff_index = cmd.target.staff + lane.relative_staff;
4764        let voice_index = cmd.target.voice + lane.relative_voice;
4765        let staff = &mut score.parts[part_index].staves[staff_index];
4766        for measure in &lane.measures {
4767            let measure_index = cmd.target.measure + measure.relative_measure;
4768            let target = &mut staff.measures[measure_index];
4769            if cmd.policy == ScoreFragmentPastePolicy::Merge && measure.notes.is_empty() {
4770                continue;
4771            }
4772            let mut notes = measure.notes.clone();
4773            for note in &mut notes {
4774                note.id = Uuid::new_v4().to_string();
4775            }
4776            if !measure.cross_staff_targets.is_empty() {
4777                for (note, cross_staff) in notes.iter_mut().zip(&measure.cross_staff_targets) {
4778                    note.cross_staff = cross_staff.as_ref().map(|cross_staff| CrossStaff {
4779                        target_staff: (staff_index as i64 + cross_staff.staff_offset) as usize,
4780                        target_voice: cross_staff.target_voice,
4781                    });
4782                }
4783            }
4784            target.voices[voice_index] = notes;
4785            target.source_voice_numbers[voice_index] = measure.source_voice_number;
4786            if cmd.policy == ScoreFragmentPastePolicy::Replace {
4787                measure.attributes.apply_to_measure(target);
4788            }
4789        }
4790    }
4791
4792    let mut used_spanner_ids = score
4793        .spanners
4794        .iter()
4795        .map(|spanner| spanner.id.clone())
4796        .collect::<BTreeSet<_>>();
4797    for source in &cmd.fragment.spanners {
4798        let mut copied = source.clone();
4799        copied.start = fragment_destination_address(&cmd.target, &source.start)?;
4800        copied.end = fragment_destination_address(&cmd.target, &source.end)?;
4801        if note_at(score, &copied.start).is_none() {
4802            return Err(Error::NoteNotFound(copied.start.note));
4803        }
4804        if note_at(score, &copied.end).is_none() {
4805            return Err(Error::NoteNotFound(copied.end.note));
4806        }
4807        copied.id = unique_fragment_spanner_id(&used_spanner_ids, &source.id);
4808        used_spanner_ids.insert(copied.id.clone());
4809        score.spanners.push(copied);
4810    }
4811    Ok(())
4812}
4813
4814fn fragment_destination_address(target: &NoteAddr, relative: &NoteAddr) -> Result<NoteAddr, Error> {
4815    Ok(NoteAddr {
4816        part: target
4817            .part
4818            .checked_add(relative.part)
4819            .ok_or_else(|| Error::InvalidCommand("fragment part target overflows".into()))?,
4820        staff: target
4821            .staff
4822            .checked_add(relative.staff)
4823            .ok_or_else(|| Error::InvalidCommand("fragment staff target overflows".into()))?,
4824        measure: target
4825            .measure
4826            .checked_add(relative.measure)
4827            .ok_or_else(|| Error::InvalidCommand("fragment measure target overflows".into()))?,
4828        voice: target
4829            .voice
4830            .checked_add(relative.voice)
4831            .ok_or_else(|| Error::InvalidCommand("fragment voice target overflows".into()))?,
4832        note: relative.note,
4833    })
4834}
4835
4836fn unique_fragment_spanner_id(used: &BTreeSet<String>, source_id: &str) -> String {
4837    let base = format!("{source_id}-copy");
4838    if !used.contains(&base) {
4839        return base;
4840    }
4841    let mut suffix = 2usize;
4842    loop {
4843        let candidate = format!("{base}-{suffix}");
4844        if !used.contains(&candidate) {
4845            return candidate;
4846        }
4847        suffix += 1;
4848    }
4849}
4850
4851fn apply_toggle_slur(cmd: &ToggleSlurCmd, score: &mut Score) -> Result<(), Error> {
4852    let new_start = !{
4853        score
4854            .parts
4855            .get(cmd.start.part)
4856            .ok_or(Error::PartNotFound(cmd.start.part))?
4857            .staves
4858            .get(cmd.start.staff)
4859            .ok_or(Error::StaffNotFound(cmd.start.staff))?
4860            .measures
4861            .get(cmd.start.measure)
4862            .ok_or(Error::MeasureNotFound(cmd.start.measure))?
4863            .voices
4864            .get(cmd.start.voice)
4865            .ok_or(Error::VoiceOutOfRange(cmd.start.voice))?
4866            .get(cmd.start.note)
4867            .ok_or(Error::NoteNotFound(cmd.start.note))?
4868            .slur_start
4869    };
4870    let new_end = !{
4871        score
4872            .parts
4873            .get(cmd.end.part)
4874            .ok_or(Error::PartNotFound(cmd.end.part))?
4875            .staves
4876            .get(cmd.end.staff)
4877            .ok_or(Error::StaffNotFound(cmd.end.staff))?
4878            .measures
4879            .get(cmd.end.measure)
4880            .ok_or(Error::MeasureNotFound(cmd.end.measure))?
4881            .voices
4882            .get(cmd.end.voice)
4883            .ok_or(Error::VoiceOutOfRange(cmd.end.voice))?
4884            .get(cmd.end.note)
4885            .ok_or(Error::NoteNotFound(cmd.end.note))?
4886            .slur_end
4887    };
4888    score.parts[cmd.start.part].staves[cmd.start.staff].measures[cmd.start.measure].voices
4889        [cmd.start.voice][cmd.start.note]
4890        .slur_start = new_start;
4891    score.parts[cmd.end.part].staves[cmd.end.staff].measures[cmd.end.measure].voices
4892        [cmd.end.voice][cmd.end.note]
4893        .slur_end = new_end;
4894    Ok(())
4895}
4896
4897fn apply_toggle_trill_line(cmd: &ToggleTrillLineCmd, score: &mut Score) -> Result<(), Error> {
4898    let new_start = !{
4899        score
4900            .parts
4901            .get(cmd.start.part)
4902            .ok_or(Error::PartNotFound(cmd.start.part))?
4903            .staves
4904            .get(cmd.start.staff)
4905            .ok_or(Error::StaffNotFound(cmd.start.staff))?
4906            .measures
4907            .get(cmd.start.measure)
4908            .ok_or(Error::MeasureNotFound(cmd.start.measure))?
4909            .voices
4910            .get(cmd.start.voice)
4911            .ok_or(Error::VoiceOutOfRange(cmd.start.voice))?
4912            .get(cmd.start.note)
4913            .ok_or(Error::NoteNotFound(cmd.start.note))?
4914            .trill_line_start
4915    };
4916    let new_end = !{
4917        score
4918            .parts
4919            .get(cmd.end.part)
4920            .ok_or(Error::PartNotFound(cmd.end.part))?
4921            .staves
4922            .get(cmd.end.staff)
4923            .ok_or(Error::StaffNotFound(cmd.end.staff))?
4924            .measures
4925            .get(cmd.end.measure)
4926            .ok_or(Error::MeasureNotFound(cmd.end.measure))?
4927            .voices
4928            .get(cmd.end.voice)
4929            .ok_or(Error::VoiceOutOfRange(cmd.end.voice))?
4930            .get(cmd.end.note)
4931            .ok_or(Error::NoteNotFound(cmd.end.note))?
4932            .trill_line_end
4933    };
4934    score.parts[cmd.start.part].staves[cmd.start.staff].measures[cmd.start.measure].voices
4935        [cmd.start.voice][cmd.start.note]
4936        .trill_line_start = new_start;
4937    score.parts[cmd.end.part].staves[cmd.end.staff].measures[cmd.end.measure].voices
4938        [cmd.end.voice][cmd.end.note]
4939        .trill_line_end = new_end;
4940    Ok(())
4941}
4942
4943fn apply_add_staff(cmd: &AddStaffCmd, score: &mut Score) -> Result<(), Error> {
4944    let ts = score.settings.time_signature.clone();
4945    let measure_count = score
4946        .parts
4947        .get(cmd.part_index)
4948        .ok_or(Error::PartNotFound(cmd.part_index))?
4949        .staves
4950        .first()
4951        .map_or(0, |s| s.measures.len());
4952    let mut staff = Staff::new(cmd.clef.clone());
4953    for i in 0..measure_count {
4954        let mut m = Measure::empty(ts.numerator, ts.denominator);
4955        m.number = i as u32 + 1;
4956        staff.measures.push(m);
4957    }
4958    score.parts[cmd.part_index].staves.push(staff);
4959    Ok(())
4960}
4961
4962fn apply_delete_staff(cmd: &DeleteStaffCmd, score: &mut Score) -> Result<(), Error> {
4963    let part = score
4964        .parts
4965        .get_mut(cmd.part_index)
4966        .ok_or(Error::PartNotFound(cmd.part_index))?;
4967    if part.staves.len() <= 1 {
4968        return Err(Error::CannotDeleteLastStaff);
4969    }
4970    if cmd.staff_index >= part.staves.len() {
4971        return Err(Error::StaffNotFound(cmd.staff_index));
4972    }
4973    part.staves.remove(cmd.staff_index);
4974    remap_spanners(score, |address| {
4975        if address.part != cmd.part_index {
4976            Some(address.clone())
4977        } else if address.staff == cmd.staff_index {
4978            None
4979        } else if address.staff > cmd.staff_index {
4980            let mut shifted = address.clone();
4981            shifted.staff -= 1;
4982            Some(shifted)
4983        } else {
4984            Some(address.clone())
4985        }
4986    });
4987    Ok(())
4988}
4989
4990/// Returns true when an address refers to the given canonical voice.
4991fn same_voice(address: &NoteAddr, part: usize, staff: usize, measure: usize, voice: usize) -> bool {
4992    address.part == part
4993        && address.staff == staff
4994        && address.measure == measure
4995        && address.voice == voice
4996}
4997
4998fn trim_voice_to_measure(voice: &mut Vec<Note>, max_beats: f64) {
4999    // A lone plain whole rest is a measure rest and already fills any measure.
5000    if matches!(voice.as_slice(), [only] if only.is_plain_whole_rest()) {
5001        return;
5002    }
5003    let mut total = 0.0f64;
5004    let mut cutoff = voice.len();
5005    for (i, n) in voice.iter().enumerate() {
5006        total += n.beats();
5007        if total > max_beats + 1e-9 {
5008            cutoff = i;
5009            break;
5010        }
5011    }
5012    voice.truncate(cutoff);
5013    pad_voice_to_measure(voice, max_beats);
5014}
5015
5016fn pad_voice_to_measure(voice: &mut Vec<Note>, max_beats: f64) {
5017    let mut used = crate::voice_duration_beats(voice, max_beats);
5018    while max_beats - used > 1e-9 {
5019        let remaining = max_beats - used;
5020        let rest = Note::rest(Duration::whole_filling_beats(remaining));
5021        used += rest.beats();
5022        voice.push(rest);
5023    }
5024}
5025
5026#[cfg(test)]
5027mod tests {
5028    use super::*;
5029    use crate::ScoreEngine;
5030    use crate::model::pitch::Step;
5031
5032    fn default_engine_score() -> Score {
5033        let mut s = Score::default();
5034        for part in &mut s.parts {
5035            for staff in &mut part.staves {
5036                for (i, m) in staff.measures.iter_mut().enumerate() {
5037                    m.number = i as u32 + 1;
5038                }
5039            }
5040        }
5041        s
5042    }
5043
5044    #[test]
5045    fn legacy_scale_voice_range_command_defaults_to_preserve_tuplet_ratio() {
5046        let command: Command = serde_json::from_str(
5047            r#"{"type":"scale_voice_range","part_index":0,"staff_index":0,"voice":0,"start_measure":0,"end_measure":0,"scale":"half"}"#,
5048        )
5049        .expect("legacy scale command deserializes");
5050        let Command::ScaleVoiceRange(command) = command else {
5051            panic!("expected scale command");
5052        };
5053        assert_eq!(command.tuplet_policy, TupletScalePolicy::PreserveRatio);
5054    }
5055
5056    fn score_with_typed_spanner() -> Score {
5057        use crate::model::score::{NotationSpanner, NotationSpannerKind};
5058
5059        let mut score = Score::new("Spanner edits", 120, 4, 4, 0, 1);
5060        score.parts[0].staves[0].measures[0].voices[0] = (0..4)
5061            .map(|offset| Note::new(Pitch::new(Step::C, 4 + offset), Duration::Quarter))
5062            .collect();
5063        score.spanners.push(NotationSpanner {
5064            id: "typed-glissando".to_string(),
5065            kind: NotationSpannerKind::Glissando,
5066            start: NoteAddr {
5067                part: 0,
5068                staff: 0,
5069                measure: 0,
5070                voice: 0,
5071                note: 1,
5072            },
5073            end: NoteAddr {
5074                part: 0,
5075                staff: 0,
5076                measure: 0,
5077                voice: 0,
5078                note: 2,
5079            },
5080            number: Some(1),
5081            line_type: None,
5082            text: None,
5083            placement: None,
5084            ottava_size: None,
5085            ottava_type: None,
5086        });
5087        score
5088    }
5089
5090    #[test]
5091    fn deleting_staff_remaps_spanners_and_undo_restores_them() {
5092        let mut score = score_with_typed_spanner();
5093        let staff = score.parts[0].staves[0].clone();
5094        score.parts[0].staves.extend([staff.clone(), staff]);
5095        score.parts.push(score.parts[0].clone());
5096
5097        let mut shifted = score.spanners[0].clone();
5098        shifted.id = "shifted".into();
5099        shifted.start.staff = 2;
5100        shifted.end.staff = 2;
5101        let mut removed = score.spanners[0].clone();
5102        removed.id = "removed".into();
5103        removed.start.staff = 1;
5104        removed.end.staff = 1;
5105        let mut other_part = score.spanners[0].clone();
5106        other_part.id = "other-part".into();
5107        other_part.start.part = 1;
5108        other_part.end.part = 1;
5109        score.spanners.extend([shifted, removed, other_part]);
5110        let original = score.spanners.clone();
5111
5112        let mut stack = CommandStack::new(8);
5113        stack
5114            .execute(
5115                Command::DeleteStaff(DeleteStaffCmd {
5116                    part_index: 0,
5117                    staff_index: 1,
5118                }),
5119                &mut score,
5120            )
5121            .unwrap();
5122        assert_eq!(score.parts[0].staves.len(), 2);
5123        assert!(!score.spanners.iter().any(|span| span.id == "removed"));
5124        let shifted = score
5125            .spanners
5126            .iter()
5127            .find(|span| span.id == "shifted")
5128            .unwrap();
5129        assert_eq!((shifted.start.staff, shifted.end.staff), (1, 1));
5130        let other = score
5131            .spanners
5132            .iter()
5133            .find(|span| span.id == "other-part")
5134            .unwrap();
5135        assert_eq!((other.start.part, other.end.part), (1, 1));
5136
5137        stack.undo(&mut score).unwrap();
5138        assert_eq!(score.parts[0].staves.len(), 3);
5139        assert_eq!(score.spanners, original);
5140        stack.redo(&mut score).unwrap();
5141        assert_eq!(score.parts[0].staves.len(), 2);
5142        assert!(!score.spanners.iter().any(|span| span.id == "removed"));
5143    }
5144
5145    fn insert_note_at(position: usize) -> Command {
5146        Command::AddNote(AddNoteCmd {
5147            part_index: 0,
5148            staff_index: 0,
5149            measure_index: 0,
5150            voice: 0,
5151            position,
5152            pitch: Some(Pitch::new(Step::D, 5)),
5153            duration: Duration::Quarter,
5154            dot_count: 0,
5155            is_rest: false,
5156            tuplet: None,
5157        })
5158    }
5159
5160    #[test]
5161    fn add_note_inserts_into_voice() {
5162        let mut score = default_engine_score();
5163        let cmd = Command::AddNote(AddNoteCmd {
5164            part_index: 0,
5165            staff_index: 0,
5166            measure_index: 0,
5167            voice: 0,
5168            position: 0,
5169            pitch: Some(Pitch::new(Step::C, 4)),
5170            duration: Duration::Quarter,
5171            dot_count: 0,
5172            is_rest: false,
5173            tuplet: None,
5174        });
5175        apply_command(&cmd, &mut score).unwrap();
5176        let first = &score.parts[0].staves[0].measures[0].voices[0][0];
5177        assert!(!first.is_rest);
5178        assert_eq!(first.pitches[0].step, Step::C);
5179    }
5180
5181    #[test]
5182    fn typed_spanners_follow_note_insertions_at_every_relative_position() {
5183        for (position, expected_start, expected_end) in [
5184            (0, 2, 3), // before the span
5185            (1, 2, 3), // at its start endpoint
5186            (2, 1, 3), // inside the span
5187            (4, 1, 2), // at the end of the voice
5188            (3, 1, 2), // after the span
5189        ] {
5190            let mut score = score_with_typed_spanner();
5191            apply_command(&insert_note_at(position), &mut score).unwrap();
5192            let span = score.spanners.first().expect("span remains attached");
5193            assert_eq!(span.start.note, expected_start, "position {position}");
5194            assert_eq!(span.end.note, expected_end, "position {position}");
5195        }
5196    }
5197
5198    #[test]
5199    fn deleting_a_typed_spanner_endpoint_removes_the_whole_span_and_undo_redo_is_atomic() {
5200        let mut score = score_with_typed_spanner();
5201        let endpoint_id = score.parts[0].staves[0].measures[0].voices[0][1].id.clone();
5202        let mut stack = CommandStack::new(8);
5203
5204        stack
5205            .execute(
5206                Command::DeleteNote(DeleteNoteCmd {
5207                    note_id: endpoint_id,
5208                    part_index: 0,
5209                    staff_index: 0,
5210                    measure_index: 0,
5211                    voice: 0,
5212                }),
5213                &mut score,
5214            )
5215            .unwrap();
5216        assert!(score.spanners.is_empty());
5217
5218        stack.undo(&mut score).unwrap();
5219        assert_eq!(score.spanners[0].start.note, 1);
5220        assert_eq!(score.spanners[0].end.note, 2);
5221
5222        stack.redo(&mut score).unwrap();
5223        assert!(score.spanners.is_empty());
5224    }
5225
5226    #[test]
5227    fn batched_structural_edits_remap_typed_spanners_and_restore_them_on_undo() {
5228        let mut score = score_with_typed_spanner();
5229        let mut stack = CommandStack::new(8);
5230        stack
5231            .batch_execute(
5232                vec![
5233                    insert_note_at(0),
5234                    Command::SetTempo(SetTempoCmd { bpm: 144 }),
5235                ],
5236                &mut score,
5237            )
5238            .unwrap();
5239        assert_eq!(score.spanners[0].start.note, 2);
5240        assert_eq!(score.spanners[0].end.note, 3);
5241
5242        stack.undo(&mut score).unwrap();
5243        assert_eq!(score.spanners[0].start.note, 1);
5244        assert_eq!(score.spanners[0].end.note, 2);
5245
5246        stack.redo(&mut score).unwrap();
5247        assert_eq!(score.spanners[0].start.note, 2);
5248        assert_eq!(score.spanners[0].end.note, 3);
5249    }
5250
5251    #[test]
5252    fn typed_spanners_remap_only_the_edited_endpoint_across_staff_and_voice() {
5253        use crate::model::score::{NotationSpanner, NotationSpannerKind, ScoreTemplate};
5254
5255        let mut score = Score::template(ScoreTemplate::Piano);
5256        for staff in &mut score.parts[0].staves {
5257            let notes: Vec<Note> = (0..4)
5258                .map(|offset| Note::new(Pitch::new(Step::C, 4 + offset), Duration::Quarter))
5259                .collect();
5260            staff.measures[0].voices[0] = notes.clone();
5261            staff.measures[0].voices[1] = notes;
5262        }
5263        score.spanners.push(NotationSpanner {
5264            id: "cross-staff".to_string(),
5265            kind: NotationSpannerKind::Slur,
5266            start: NoteAddr {
5267                part: 0,
5268                staff: 0,
5269                measure: 0,
5270                voice: 0,
5271                note: 1,
5272            },
5273            end: NoteAddr {
5274                part: 0,
5275                staff: 1,
5276                measure: 0,
5277                voice: 1,
5278                note: 2,
5279            },
5280            number: Some(2),
5281            line_type: None,
5282            text: None,
5283            placement: None,
5284            ottava_size: None,
5285            ottava_type: None,
5286        });
5287
5288        let mut stack = CommandStack::new(8);
5289        stack.execute(insert_note_at(0), &mut score).unwrap();
5290        let span = &score.spanners[0];
5291        assert_eq!(span.start.note, 2);
5292        assert_eq!(span.end.staff, 1);
5293        assert_eq!(span.end.voice, 1);
5294        assert_eq!(span.end.note, 2);
5295    }
5296
5297    #[test]
5298    fn set_fingerings_keeps_first_legacy_value_in_sync() {
5299        let mut score = default_engine_score();
5300        apply_command(
5301            &Command::AddNote(AddNoteCmd {
5302                part_index: 0,
5303                staff_index: 0,
5304                measure_index: 0,
5305                voice: 0,
5306                position: 0,
5307                pitch: Some(Pitch::new(Step::C, 4)),
5308                duration: Duration::Quarter,
5309                dot_count: 0,
5310                is_rest: false,
5311                tuplet: None,
5312            }),
5313            &mut score,
5314        )
5315        .unwrap();
5316        apply_command(
5317            &Command::SetFingerings(SetFingeringsCmd {
5318                part_index: 0,
5319                staff_index: 0,
5320                measure_index: 0,
5321                voice: 0,
5322                note_index: 0,
5323                fingerings: vec![1, 3, 4],
5324            }),
5325            &mut score,
5326        )
5327        .unwrap();
5328        let note = &score.parts[0].staves[0].measures[0].voices[0][0];
5329        assert_eq!(note.fingerings, vec![1, 3, 4]);
5330        assert_eq!(note.fingering, Some(1));
5331    }
5332
5333    #[test]
5334    fn set_figured_bass_replaces_measure_figures() {
5335        let mut score = default_engine_score();
5336        let figures = vec![FiguredBassFigure {
5337            number: "6".to_string(),
5338            alter: Some("-1".to_string()),
5339            prefix: Some("+".to_string()),
5340            suffix: None,
5341            extender: false,
5342        }];
5343        apply_command(
5344            &Command::SetFiguredBass(SetFiguredBassCmd {
5345                measure_index: 0,
5346                figures: figures.clone(),
5347            }),
5348            &mut score,
5349        )
5350        .unwrap();
5351        assert_eq!(score.parts[0].staves[0].measures[0].figured_bass, figures);
5352    }
5353
5354    #[test]
5355    fn set_measure_text_supports_append_replace_remove_and_undo_redo() {
5356        let mut engine = crate::ScoreEngine::new();
5357        let address = SetMeasureTextCmd {
5358            part_index: 0,
5359            staff_index: 0,
5360            measure_index: 0,
5361            text_index: 0,
5362            text: Some(StyledText {
5363                position_beats: None,
5364                style: crate::TextStyle::Technique,
5365                text: "dolce".to_string(),
5366                placement: None,
5367                offset_x: None,
5368                offset_y: None,
5369                relative_x: None,
5370                relative_y: None,
5371            }),
5372        };
5373        engine.apply(Command::SetMeasureText(address)).unwrap();
5374        assert_eq!(
5375            engine.score.parts[0].staves[0].measures[0].texts[0],
5376            StyledText {
5377                position_beats: None,
5378                style: crate::TextStyle::Technique,
5379                text: "dolce".to_string(),
5380                placement: None,
5381                offset_x: None,
5382                offset_y: None,
5383                relative_x: None,
5384                relative_y: None,
5385            }
5386        );
5387
5388        engine
5389            .apply(Command::SetMeasureText(SetMeasureTextCmd {
5390                text_index: 0,
5391                text: Some(StyledText {
5392                    position_beats: None,
5393                    style: crate::TextStyle::RehearsalMark,
5394                    text: "A".to_string(),
5395                    placement: None,
5396                    offset_x: None,
5397                    offset_y: None,
5398                    relative_x: None,
5399                    relative_y: None,
5400                }),
5401                ..SetMeasureTextCmd {
5402                    part_index: 0,
5403                    staff_index: 0,
5404                    measure_index: 0,
5405                    text_index: 0,
5406                    text: None,
5407                }
5408            }))
5409            .unwrap();
5410        assert_eq!(
5411            engine.score.parts[0].staves[0].measures[0].texts[0].style,
5412            crate::TextStyle::RehearsalMark
5413        );
5414
5415        engine
5416            .apply(Command::SetMeasureText(SetMeasureTextCmd {
5417                text_index: 0,
5418                text: None,
5419                ..SetMeasureTextCmd {
5420                    part_index: 0,
5421                    staff_index: 0,
5422                    measure_index: 0,
5423                    text_index: 0,
5424                    text: None,
5425                }
5426            }))
5427            .unwrap();
5428        assert!(engine.score.parts[0].staves[0].measures[0].texts.is_empty());
5429        engine.undo().unwrap();
5430        assert_eq!(engine.score.parts[0].staves[0].measures[0].texts.len(), 1);
5431        engine.redo().unwrap();
5432        assert!(engine.score.parts[0].staves[0].measures[0].texts.is_empty());
5433    }
5434
5435    #[test]
5436    fn set_measure_text_rejects_invalid_index_atomically_and_round_trips_json() {
5437        let mut score = default_engine_score();
5438        let before = score.clone();
5439        let command = Command::SetMeasureText(SetMeasureTextCmd {
5440            part_index: 0,
5441            staff_index: 0,
5442            measure_index: 0,
5443            text_index: 2,
5444            text: Some(StyledText {
5445                position_beats: None,
5446                style: crate::TextStyle::Expression,
5447                text: "espressivo".to_string(),
5448                placement: None,
5449                offset_x: None,
5450                offset_y: None,
5451                relative_x: None,
5452                relative_y: None,
5453            }),
5454        });
5455        let json = serde_json::to_string(&command).unwrap();
5456        let restored: Command = serde_json::from_str(&json).unwrap();
5457        assert_eq!(
5458            serde_json::to_value(&restored).unwrap(),
5459            serde_json::to_value(&command).unwrap()
5460        );
5461        assert!(apply_command(&restored, &mut score).is_err());
5462        assert_eq!(
5463            serde_json::to_value(&score).unwrap(),
5464            serde_json::to_value(&before).unwrap()
5465        );
5466    }
5467
5468    #[test]
5469    fn set_score_text_supports_append_replace_remove_and_undo_redo() {
5470        let mut engine = crate::ScoreEngine::new();
5471        let text = StyledText {
5472            position_beats: None,
5473            style: crate::TextStyle::Expression,
5474            text: "Title".to_string(),
5475            placement: None,
5476            offset_x: None,
5477            offset_y: None,
5478            relative_x: None,
5479            relative_y: None,
5480        };
5481        engine
5482            .apply(Command::SetScoreText(SetScoreTextCmd {
5483                text_index: 0,
5484                text: Some(text.clone()),
5485            }))
5486            .unwrap();
5487        assert_eq!(engine.score.texts, vec![text.clone()]);
5488
5489        let mut replacement = text.clone();
5490        replacement.text = "Subtitle".to_string();
5491        engine
5492            .apply(Command::SetScoreText(SetScoreTextCmd {
5493                text_index: 0,
5494                text: Some(replacement.clone()),
5495            }))
5496            .unwrap();
5497        assert_eq!(engine.score.texts, vec![replacement]);
5498
5499        engine
5500            .apply(Command::SetScoreText(SetScoreTextCmd {
5501                text_index: 0,
5502                text: None,
5503            }))
5504            .unwrap();
5505        assert!(engine.score.texts.is_empty());
5506        engine.undo().unwrap();
5507        assert_eq!(engine.score.texts.len(), 1);
5508        engine.redo().unwrap();
5509        assert!(engine.score.texts.is_empty());
5510    }
5511
5512    #[test]
5513    fn set_score_style_overrides_is_undoable_and_rejects_invalid_values() {
5514        let mut engine = crate::ScoreEngine::new();
5515        let overrides = vec![ViewStyleOverride {
5516            property: super::super::score::ViewStyleProperty::SystemGap,
5517            value: 2.5,
5518        }];
5519        engine
5520            .apply(Command::SetScoreStyleOverrides(SetScoreStyleOverridesCmd {
5521                overrides: overrides.clone(),
5522            }))
5523            .unwrap();
5524        assert_eq!(engine.score.style_overrides, overrides);
5525        engine.undo().unwrap();
5526        assert!(engine.score.style_overrides.is_empty());
5527        engine.redo().unwrap();
5528        assert_eq!(engine.score.style_overrides, overrides);
5529
5530        let before = engine.score.clone();
5531        assert!(
5532            engine
5533                .apply(Command::SetScoreStyleOverrides(SetScoreStyleOverridesCmd {
5534                    overrides: vec![ViewStyleOverride {
5535                        property: super::super::score::ViewStyleProperty::TextScale,
5536                        value: f32::NAN,
5537                    }],
5538                }))
5539                .is_err()
5540        );
5541        assert_eq!(
5542            serde_json::to_value(&engine.score).unwrap(),
5543            serde_json::to_value(&before).unwrap()
5544        );
5545    }
5546
5547    #[test]
5548    fn set_object_style_overrides_is_undoable_and_requires_existing_target() {
5549        let mut engine = crate::ScoreEngine::new();
5550        engine.score.texts.push(StyledText {
5551            position_beats: None,
5552            style: crate::TextStyle::Expression,
5553            text: "Allegro".into(),
5554            placement: None,
5555            offset_x: None,
5556            offset_y: None,
5557            relative_x: None,
5558            relative_y: None,
5559        });
5560        let overrides = vec![ObjectStyleOverride {
5561            target: super::super::score::ObjectStyleTarget::ScoreText { text_index: 0 },
5562            property: super::super::score::ViewStyleProperty::TextScale,
5563            value: 1.2,
5564            provenance: None,
5565        }];
5566        engine
5567            .apply(Command::SetObjectStyleOverrides(
5568                SetObjectStyleOverridesCmd {
5569                    overrides: overrides.clone(),
5570                },
5571            ))
5572            .unwrap();
5573        assert_eq!(engine.score.object_style_overrides, overrides);
5574        engine.undo().unwrap();
5575        assert!(engine.score.object_style_overrides.is_empty());
5576
5577        assert!(
5578            engine
5579                .apply(Command::SetObjectStyleOverrides(
5580                    SetObjectStyleOverridesCmd {
5581                        overrides: vec![ObjectStyleOverride {
5582                            target: super::super::score::ObjectStyleTarget::ScoreText {
5583                                text_index: 1,
5584                            },
5585                            property: super::super::score::ViewStyleProperty::TextScale,
5586                            value: 1.2,
5587                            provenance: None,
5588                        }],
5589                    }
5590                ))
5591                .is_err()
5592        );
5593    }
5594
5595    #[test]
5596    fn set_harmony_range_is_undoable_and_json_compatible() {
5597        let mut score = default_engine_score();
5598        score.parts[0].staves[0].measures[0].voices[0] =
5599            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
5600        score.parts[0].staves[0].measures[0].voices[0][0].chord_symbol = Some(ChordSymbol {
5601            root: "C".to_owned(),
5602            kind: "major".to_owned(),
5603            bass: None,
5604            placement: None,
5605            extender: true,
5606            harmonic_degree: None,
5607            harmony_function: None,
5608            harmony_type: None,
5609            chord_ref: None,
5610            range_end: None,
5611            degrees: Vec::new(),
5612        });
5613        let mut engine = ScoreEngine::new();
5614        engine.replace_score(score);
5615        let command = Command::SetHarmonyRange(SetHarmonyRangeCmd {
5616            part_index: 0,
5617            staff_index: 0,
5618            measure_index: 0,
5619            voice: 0,
5620            note_index: 0,
5621            end: Some(NoteAddr {
5622                part: 0,
5623                staff: 0,
5624                measure: 0,
5625                voice: 0,
5626                note: 0,
5627            }),
5628        });
5629        let json = serde_json::to_string(&command).unwrap();
5630        let restored: Command = serde_json::from_str(&json).unwrap();
5631        engine.apply(restored).unwrap();
5632        assert!(
5633            engine.score.parts[0].staves[0].measures[0].voices[0][0]
5634                .chord_symbol
5635                .as_ref()
5636                .and_then(|chord| chord.range_end.as_ref())
5637                .is_some()
5638        );
5639        engine.undo().unwrap();
5640        assert!(
5641            engine.score.parts[0].staves[0].measures[0].voices[0][0]
5642                .chord_symbol
5643                .as_ref()
5644                .is_some_and(|chord| chord.range_end.is_none())
5645        );
5646    }
5647
5648    #[test]
5649    fn set_harp_pedal_diagrams_is_undoable_and_json_compatible() {
5650        let mut engine = ScoreEngine::new();
5651        let mut diagram = HarpPedalDiagram::default();
5652        diagram.positions[0] = super::super::score::HarpPedalPosition::Flat;
5653        diagram.positions[6] = super::super::score::HarpPedalPosition::Sharp;
5654        let command = Command::SetHarpPedalDiagrams(SetHarpPedalDiagramsCmd {
5655            part_index: 0,
5656            staff_index: 0,
5657            measure_index: 0,
5658            diagrams: vec![diagram.clone()],
5659        });
5660        let restored: Command =
5661            serde_json::from_str(&serde_json::to_string(&command).unwrap()).unwrap();
5662        engine.apply(restored).unwrap();
5663        assert_eq!(
5664            engine.score.parts[0].staves[0].measures[0].harp_pedal_diagrams,
5665            vec![diagram]
5666        );
5667        engine.undo().unwrap();
5668        assert!(
5669            engine.score.parts[0].staves[0].measures[0]
5670                .harp_pedal_diagrams
5671                .is_empty()
5672        );
5673    }
5674
5675    #[test]
5676    fn set_note_placement_is_undoable_and_rejects_non_finite_values() {
5677        let mut score = default_engine_score();
5678        score.parts[0].staves[0].measures[0].voices[0] =
5679            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5680        let mut engine = ScoreEngine::new();
5681        engine.replace_score(score);
5682        let command = Command::SetNotePlacement(SetNotePlacementCmd {
5683            part_index: 0,
5684            staff_index: 0,
5685            measure_index: 0,
5686            voice: 0,
5687            note_index: 0,
5688            offset_x: Some(12.5),
5689            offset_y: Some(-3.0),
5690            relative_x: Some(1.25),
5691            relative_y: Some(-0.5),
5692        });
5693        engine.apply(command).unwrap();
5694        let note = &engine.score.parts[0].staves[0].measures[0].voices[0][0];
5695        assert_eq!(note.offset_x, Some(12.5));
5696        assert_eq!(note.relative_y, Some(-0.5));
5697        engine.undo().unwrap();
5698        assert_eq!(
5699            engine.score.parts[0].staves[0].measures[0].voices[0][0].offset_x,
5700            None
5701        );
5702        let invalid = Command::SetNotePlacement(SetNotePlacementCmd {
5703            part_index: 0,
5704            staff_index: 0,
5705            measure_index: 0,
5706            voice: 0,
5707            note_index: 0,
5708            offset_x: Some(f64::NAN),
5709            offset_y: None,
5710            relative_x: None,
5711            relative_y: None,
5712        });
5713        assert!(engine.apply(invalid).is_err());
5714        assert_eq!(
5715            engine.score.parts[0].staves[0].measures[0].voices[0][0].offset_x,
5716            None
5717        );
5718    }
5719
5720    #[test]
5721    fn set_guitar_bend_alter_updates_note_and_can_clear() {
5722        let mut score = default_engine_score();
5723        apply_command(
5724            &Command::AddNote(AddNoteCmd {
5725                part_index: 0,
5726                staff_index: 0,
5727                measure_index: 0,
5728                voice: 0,
5729                position: 0,
5730                pitch: Some(Pitch::new(Step::G, 4)),
5731                duration: Duration::Quarter,
5732                dot_count: 0,
5733                is_rest: false,
5734                tuplet: None,
5735            }),
5736            &mut score,
5737        )
5738        .unwrap();
5739        let location = SetGuitarBendAlterCmd {
5740            part_index: 0,
5741            staff_index: 0,
5742            measure_index: 0,
5743            voice: 0,
5744            note_index: 0,
5745            alter_cents: Some(200),
5746        };
5747        apply_command(&Command::SetGuitarBendAlter(location.clone()), &mut score).unwrap();
5748        assert_eq!(
5749            score.parts[0].staves[0].measures[0].voices[0][0].guitar_bend_alter_cents,
5750            Some(200)
5751        );
5752        let mut cleared = location;
5753        cleared.alter_cents = None;
5754        apply_command(&Command::SetGuitarBendAlter(cleared), &mut score).unwrap();
5755        assert_eq!(
5756            score.parts[0].staves[0].measures[0].voices[0][0].guitar_bend_alter_cents,
5757            None
5758        );
5759    }
5760
5761    #[test]
5762    fn set_guitar_bend_curve_is_undoable_and_json_compatible() {
5763        let mut score = Score::new("T", 120, 4, 4, 0, 1);
5764        let command = Command::SetGuitarBendCurve(SetGuitarBendCurveCmd {
5765            part_index: 0,
5766            staff_index: 0,
5767            measure_index: 0,
5768            voice: 0,
5769            note_index: 0,
5770            points: vec![
5771                crate::GuitarBendPoint {
5772                    position_per_mille: 0,
5773                    alter_cents: 0,
5774                },
5775                crate::GuitarBendPoint {
5776                    position_per_mille: 500,
5777                    alter_cents: 200,
5778                },
5779                crate::GuitarBendPoint {
5780                    position_per_mille: 1000,
5781                    alter_cents: 0,
5782                },
5783            ],
5784        });
5785        let decoded: Command =
5786            serde_json::from_str(&serde_json::to_string(&command).unwrap()).unwrap();
5787        let mut stack = CommandStack::new(16);
5788        stack.execute(decoded, &mut score).unwrap();
5789        assert_eq!(
5790            score.parts[0].staves[0].measures[0].voices[0][0]
5791                .guitar_bend_curve
5792                .len(),
5793            3
5794        );
5795        stack.undo(&mut score).unwrap();
5796        assert!(
5797            score.parts[0].staves[0].measures[0].voices[0][0]
5798                .guitar_bend_curve
5799                .is_empty()
5800        );
5801    }
5802
5803    #[test]
5804    fn set_duration_updates_note_and_preserves_measure_capacity() {
5805        let mut score = default_engine_score();
5806        apply_command(
5807            &Command::AddNote(AddNoteCmd {
5808                part_index: 0,
5809                staff_index: 0,
5810                measure_index: 0,
5811                voice: 0,
5812                position: 0,
5813                pitch: Some(Pitch::new(Step::C, 4)),
5814                duration: Duration::Quarter,
5815                dot_count: 0,
5816                is_rest: false,
5817                tuplet: None,
5818            }),
5819            &mut score,
5820        )
5821        .unwrap();
5822        apply_command(
5823            &Command::SetDuration(SetDurationCmd {
5824                part_index: 0,
5825                staff_index: 0,
5826                measure_index: 0,
5827                voice: 0,
5828                note_index: 0,
5829                duration: Duration::Half,
5830                dot_count: 1,
5831            }),
5832            &mut score,
5833        )
5834        .unwrap();
5835        let voice = &score.parts[0].staves[0].measures[0].voices[0];
5836        assert_eq!(voice[0].duration, Duration::Half);
5837        assert_eq!(voice[0].dot_count, 1);
5838        assert!((voice.iter().map(|note| note.beats()).sum::<f64>() - 4.0).abs() < 1e-9);
5839    }
5840
5841    #[test]
5842    fn set_tempo_updates_score() {
5843        let mut score = default_engine_score();
5844        let cmd = Command::SetTempo(SetTempoCmd { bpm: 160 });
5845        apply_command(&cmd, &mut score).unwrap();
5846        assert_eq!(score.settings.tempo_bpm, 160);
5847    }
5848
5849    #[test]
5850    fn add_measure_increases_count() {
5851        let mut score = default_engine_score();
5852        let before = score.measure_count();
5853        apply_command(
5854            &Command::AddMeasure(AddMeasureCmd { after_index: 0 }),
5855            &mut score,
5856        )
5857        .unwrap();
5858        assert_eq!(score.measure_count(), before + 1);
5859    }
5860
5861    #[test]
5862    fn delete_measure_decreases_count() {
5863        let mut score = default_engine_score();
5864        let before = score.measure_count();
5865        apply_command(
5866            &Command::DeleteMeasure(DeleteMeasureCmd { measure_index: 0 }),
5867            &mut score,
5868        )
5869        .unwrap();
5870        assert_eq!(score.measure_count(), before - 1);
5871    }
5872
5873    #[test]
5874    fn undo_restores_score() {
5875        let mut stack = CommandStack::new(50);
5876        let mut score = default_engine_score();
5877        let before = score.settings.tempo_bpm;
5878        stack
5879            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
5880            .unwrap();
5881        assert_eq!(score.settings.tempo_bpm, 200);
5882        stack.undo(&mut score).unwrap();
5883        assert_eq!(score.settings.tempo_bpm, before);
5884    }
5885
5886    #[test]
5887    fn redo_reapplies_command() {
5888        let mut stack = CommandStack::new(50);
5889        let mut score = default_engine_score();
5890        stack
5891            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
5892            .unwrap();
5893        stack.undo(&mut score).unwrap();
5894        stack.redo(&mut score).unwrap();
5895        assert_eq!(score.settings.tempo_bpm, 200);
5896    }
5897
5898    #[test]
5899    fn undo_nothing_returns_error() {
5900        let mut stack = CommandStack::new(50);
5901        let mut score = default_engine_score();
5902        assert!(stack.undo(&mut score).is_err());
5903    }
5904
5905    #[test]
5906    fn add_part_appends_part() {
5907        let mut score = default_engine_score();
5908        let before = score.parts.len();
5909        apply_command(
5910            &Command::AddPart(AddPartCmd {
5911                name: "Violin".into(),
5912                short_name: "Vln.".into(),
5913                clefs: vec!["Treble".into()],
5914                midi_channel: 0,
5915                midi_program: 0,
5916            }),
5917            &mut score,
5918        )
5919        .unwrap();
5920        assert_eq!(score.parts.len(), before + 1);
5921    }
5922
5923    #[test]
5924    fn delete_part_removes_part() {
5925        let mut score = default_engine_score();
5926        apply_command(
5927            &Command::AddPart(AddPartCmd {
5928                name: "Violin".into(),
5929                short_name: "V.".into(),
5930                clefs: vec!["Treble".into()],
5931                midi_channel: 0,
5932                midi_program: 0,
5933            }),
5934            &mut score,
5935        )
5936        .unwrap();
5937        let before = score.parts.len();
5938        apply_command(
5939            &Command::DeletePart(DeletePartCmd { part_index: 0 }),
5940            &mut score,
5941        )
5942        .unwrap();
5943        assert_eq!(score.parts.len(), before - 1);
5944    }
5945
5946    #[test]
5947    fn delete_part_out_of_range_returns_err() {
5948        let mut score = default_engine_score();
5949        assert!(
5950            apply_command(
5951                &Command::DeletePart(DeletePartCmd { part_index: 99 }),
5952                &mut score
5953            )
5954            .is_err()
5955        );
5956    }
5957
5958    #[test]
5959    fn delete_part_undo_restores_part() {
5960        let mut stack = CommandStack::new(50);
5961        let mut score = default_engine_score();
5962        apply_command(
5963            &Command::AddPart(AddPartCmd {
5964                name: "Violin".into(),
5965                short_name: "V.".into(),
5966                clefs: vec!["Treble".into()],
5967                midi_channel: 0,
5968                midi_program: 0,
5969            }),
5970            &mut score,
5971        )
5972        .unwrap();
5973        let before = score.parts.len();
5974        stack
5975            .execute(
5976                Command::DeletePart(DeletePartCmd { part_index: 0 }),
5977                &mut score,
5978            )
5979            .unwrap();
5980        assert_eq!(score.parts.len(), before - 1);
5981        stack.undo(&mut score).unwrap();
5982        assert_eq!(score.parts.len(), before);
5983    }
5984
5985    #[test]
5986    fn reorder_parts_remaps_linked_views_and_preserves_undo() {
5987        let mut engine = crate::ScoreEngine::new();
5988        engine
5989            .apply(Command::AddPart(AddPartCmd {
5990                name: "Flute".into(),
5991                short_name: "Fl.".into(),
5992                clefs: vec!["Treble".into()],
5993                midi_channel: 1,
5994                midi_program: 73,
5995            }))
5996            .unwrap();
5997        engine
5998            .score
5999            .views
6000            .push(ScoreView::linked_part("flute", "Flute", 1));
6001        engine.score.part_groups.push(PartGroup {
6002            first_part: 0,
6003            last_part: 1,
6004            symbol: super::super::score::PartGroupSymbol::Bracket,
6005            barlines_connect: false,
6006        });
6007
6008        engine
6009            .apply(Command::ReorderParts(ReorderPartsCmd { order: vec![1, 0] }))
6010            .unwrap();
6011        assert_eq!(engine.score.parts[0].name, "Flute");
6012        assert_eq!(engine.score.views[0].parts, vec![0]);
6013        engine.undo().unwrap();
6014        assert_eq!(engine.score.parts[0].name, "Piano");
6015        assert_eq!(engine.score.views[0].parts, vec![1]);
6016    }
6017
6018    #[test]
6019    fn reorder_parts_rejects_splitting_a_part_group_without_mutation() {
6020        let mut score = Score::template(ScoreTemplate::StringQuartet);
6021        score.part_groups.push(PartGroup {
6022            first_part: 0,
6023            last_part: 1,
6024            symbol: super::super::score::PartGroupSymbol::Bracket,
6025            barlines_connect: false,
6026        });
6027        let before = serde_json::to_value(&score).unwrap();
6028
6029        assert!(
6030            apply_command(
6031                &Command::ReorderParts(ReorderPartsCmd {
6032                    order: vec![0, 2, 1, 3],
6033                }),
6034                &mut score,
6035            )
6036            .is_err()
6037        );
6038        assert_eq!(serde_json::to_value(&score).unwrap(), before);
6039    }
6040
6041    #[test]
6042    fn set_metadata_updates_title() {
6043        let mut score = default_engine_score();
6044        apply_command(
6045            &Command::SetMetadata(SetMetadataCmd {
6046                title: Some("New Title".into()),
6047                ..Default::default()
6048            }),
6049            &mut score,
6050        )
6051        .unwrap();
6052        assert_eq!(score.metadata.title, "New Title");
6053    }
6054
6055    #[test]
6056    fn set_metadata_none_fields_skipped() {
6057        let mut score = default_engine_score();
6058        let original_composer = score.metadata.composer.clone();
6059        apply_command(
6060            &Command::SetMetadata(SetMetadataCmd {
6061                title: Some("X".into()),
6062                ..Default::default()
6063            }),
6064            &mut score,
6065        )
6066        .unwrap();
6067        assert_eq!(score.metadata.composer, original_composer);
6068    }
6069
6070    #[test]
6071    fn set_volta_sets_bracket() {
6072        use crate::model::score::VoltaBracket;
6073        let mut score = default_engine_score();
6074        let volta = VoltaBracket {
6075            number: 1,
6076            kind: "begin_end".into(),
6077            numbers: Vec::new(),
6078        };
6079        apply_command(
6080            &Command::SetVolta(SetVoltaCmd {
6081                measure_index: 0,
6082                volta: Some(volta.clone()),
6083            }),
6084            &mut score,
6085        )
6086        .unwrap();
6087        assert!(score.parts[0].staves[0].measures[0].volta.is_some());
6088    }
6089
6090    #[test]
6091    fn set_volta_none_clears_bracket() {
6092        use crate::model::score::VoltaBracket;
6093        let mut score = default_engine_score();
6094        score.parts[0].staves[0].measures[0].volta = Some(VoltaBracket {
6095            number: 1,
6096            kind: "begin_end".into(),
6097            numbers: Vec::new(),
6098        });
6099        apply_command(
6100            &Command::SetVolta(SetVoltaCmd {
6101                measure_index: 0,
6102                volta: None,
6103            }),
6104            &mut score,
6105        )
6106        .unwrap();
6107        assert!(score.parts[0].staves[0].measures[0].volta.is_none());
6108    }
6109
6110    #[test]
6111    fn set_volta_undo_restores_old() {
6112        use crate::model::score::VoltaBracket;
6113        let mut stack = CommandStack::new(50);
6114        let mut score = default_engine_score();
6115        stack
6116            .execute(
6117                Command::SetVolta(SetVoltaCmd {
6118                    measure_index: 0,
6119                    volta: Some(VoltaBracket {
6120                        number: 1,
6121                        kind: "begin_end".into(),
6122                        numbers: Vec::new(),
6123                    }),
6124                }),
6125                &mut score,
6126            )
6127            .unwrap();
6128        stack.undo(&mut score).unwrap();
6129        assert!(score.parts[0].staves[0].measures[0].volta.is_none());
6130    }
6131
6132    #[test]
6133    fn set_clef_updates_staff_clef() {
6134        use crate::model::notation::Clef;
6135        let mut score = default_engine_score();
6136        apply_command(
6137            &Command::SetClef(SetClefCmd {
6138                part_index: 0,
6139                staff_index: 0,
6140                clef: Clef::Bass,
6141            }),
6142            &mut score,
6143        )
6144        .unwrap();
6145        assert_eq!(score.parts[0].staves[0].clef, Clef::Bass);
6146    }
6147
6148    #[test]
6149    fn set_clef_out_of_range_returns_err() {
6150        use crate::model::notation::Clef;
6151        let mut score = default_engine_score();
6152        assert!(
6153            apply_command(
6154                &Command::SetClef(SetClefCmd {
6155                    part_index: 99,
6156                    staff_index: 0,
6157                    clef: Clef::Bass,
6158                }),
6159                &mut score
6160            )
6161            .is_err()
6162        );
6163    }
6164
6165    #[test]
6166    fn set_part_name_updates_name() {
6167        let mut score = default_engine_score();
6168        apply_command(
6169            &Command::SetPartName(SetPartNameCmd {
6170                part_index: 0,
6171                name: "Violin".into(),
6172                short_name: "Vln.".into(),
6173            }),
6174            &mut score,
6175        )
6176        .unwrap();
6177        assert_eq!(score.parts[0].name, "Violin");
6178        assert_eq!(score.parts[0].short_name, "Vln.");
6179    }
6180
6181    #[test]
6182    fn set_part_name_undo_restores_old() {
6183        let mut stack = CommandStack::new(50);
6184        let mut score = default_engine_score();
6185        let original = score.parts[0].name.clone();
6186        stack
6187            .execute(
6188                Command::SetPartName(SetPartNameCmd {
6189                    part_index: 0,
6190                    name: "Flute".into(),
6191                    short_name: "Fl.".into(),
6192                }),
6193                &mut score,
6194            )
6195            .unwrap();
6196        stack.undo(&mut score).unwrap();
6197        assert_eq!(score.parts[0].name, original);
6198    }
6199
6200    #[test]
6201    fn set_metadata_undo_restores_old_title() {
6202        let mut stack = CommandStack::new(50);
6203        let mut score = default_engine_score();
6204        let original = score.metadata.title.clone();
6205        stack
6206            .execute(
6207                Command::SetMetadata(SetMetadataCmd {
6208                    title: Some("Changed".into()),
6209                    ..Default::default()
6210                }),
6211                &mut score,
6212            )
6213            .unwrap();
6214        assert_ne!(score.metadata.title, original);
6215        stack.undo(&mut score).unwrap();
6216        assert_eq!(score.metadata.title, original);
6217    }
6218
6219    #[test]
6220    fn set_midi_instrument_updates_channel_and_program() {
6221        let mut score = default_engine_score();
6222        apply_command(
6223            &Command::SetMidiInstrument(SetMidiInstrumentCmd {
6224                part_index: 0,
6225                midi_channel: 2,
6226                midi_program: 40,
6227            }),
6228            &mut score,
6229        )
6230        .unwrap();
6231        assert_eq!(score.parts[0].midi_channel, 2);
6232        assert_eq!(score.parts[0].midi_program, 40);
6233    }
6234
6235    #[test]
6236    fn set_midi_instrument_clamps_channel_to_15() {
6237        let mut score = default_engine_score();
6238        apply_command(
6239            &Command::SetMidiInstrument(SetMidiInstrumentCmd {
6240                part_index: 0,
6241                midi_channel: 20,
6242                midi_program: 0,
6243            }),
6244            &mut score,
6245        )
6246        .unwrap();
6247        assert_eq!(score.parts[0].midi_channel, 15);
6248    }
6249
6250    #[test]
6251    fn set_midi_instrument_undo_restores_old() {
6252        let mut stack = CommandStack::new(50);
6253        let mut score = default_engine_score();
6254        score.parts[0].midi_channel = 3;
6255        score.parts[0].midi_program = 10;
6256        stack
6257            .execute(
6258                Command::SetMidiInstrument(SetMidiInstrumentCmd {
6259                    part_index: 0,
6260                    midi_channel: 9,
6261                    midi_program: 114,
6262                }),
6263                &mut score,
6264            )
6265            .unwrap();
6266        stack.undo(&mut score).unwrap();
6267        assert_eq!(score.parts[0].midi_channel, 3);
6268        assert_eq!(score.parts[0].midi_program, 10);
6269    }
6270
6271    #[test]
6272    fn set_transpose_updates_staff() {
6273        let mut score = default_engine_score();
6274        apply_command(
6275            &Command::SetTranspose(SetTransposeCmd {
6276                part_index: 0,
6277                staff_index: 0,
6278                semitones: -2,
6279            }),
6280            &mut score,
6281        )
6282        .unwrap();
6283        assert_eq!(score.parts[0].staves[0].transpose_semitones, -2);
6284    }
6285
6286    #[test]
6287    fn set_transpose_out_of_range_returns_err() {
6288        let mut score = default_engine_score();
6289        assert!(
6290            apply_command(
6291                &Command::SetTranspose(SetTransposeCmd {
6292                    part_index: 99,
6293                    staff_index: 0,
6294                    semitones: -2,
6295                }),
6296                &mut score
6297            )
6298            .is_err()
6299        );
6300    }
6301
6302    #[test]
6303    fn transpose_staff_region_is_undoable_and_json_compatible() {
6304        let mut stack = CommandStack::new(50);
6305        let mut score = Score::new("Region", 120, 4, 4, 0, 2);
6306        for measure in &mut score.parts[0].staves[0].measures {
6307            measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
6308        }
6309        let command = Command::TransposeStaffRegion(TransposeStaffRegionCmd {
6310            part_index: 0,
6311            staff_index: 0,
6312            start_measure: 1,
6313            end_measure: 2,
6314            semitones: 2,
6315            target: RegionalTranspositionTarget::Written,
6316        });
6317        let json = serde_json::to_string(&command).expect("command serializes");
6318        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
6319        assert_eq!(command_key(&decoded), "TransposeStaffRegion");
6320        stack.execute(decoded, &mut score).expect("region applies");
6321        assert_eq!(
6322            score.parts[0].staves[0].measures[0].voices[0][0].pitches[0].to_midi(),
6323            60
6324        );
6325        assert_eq!(
6326            score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].to_midi(),
6327            62
6328        );
6329        stack.undo(&mut score).expect("region undo applies");
6330        assert_eq!(
6331            score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].to_midi(),
6332            60
6333        );
6334    }
6335
6336    #[test]
6337    fn set_tempo_at_measure_sets_tempo() {
6338        let mut score = default_engine_score();
6339        apply_command(
6340            &Command::SetTempoAtMeasure(SetTempoAtMeasureCmd {
6341                measure_index: 0,
6342                bpm: Some(80),
6343            }),
6344            &mut score,
6345        )
6346        .unwrap();
6347        assert_eq!(score.parts[0].staves[0].measures[0].tempo, Some(80));
6348    }
6349
6350    #[test]
6351    fn set_tempo_at_measure_none_clears_tempo() {
6352        let mut score = default_engine_score();
6353        score.parts[0].staves[0].measures[0].tempo = Some(120);
6354        apply_command(
6355            &Command::SetTempoAtMeasure(SetTempoAtMeasureCmd {
6356                measure_index: 0,
6357                bpm: None,
6358            }),
6359            &mut score,
6360        )
6361        .unwrap();
6362        assert!(score.parts[0].staves[0].measures[0].tempo.is_none());
6363    }
6364
6365    #[test]
6366    fn set_tempo_ramp_at_measure_applies() {
6367        let mut score = default_engine_score();
6368        apply_command(
6369            &Command::SetTempoRampAtMeasure(SetTempoRampAtMeasureCmd {
6370                measure_index: 0,
6371                target_bpm: Some(72),
6372            }),
6373            &mut score,
6374        )
6375        .expect("tempo ramp applies");
6376        assert_eq!(score.parts[0].staves[0].measures[0].tempo_ramp_to, Some(72));
6377    }
6378
6379    // ── batch_execute ─────────────────────────────────────────────────────────
6380
6381    #[test]
6382    fn batch_execute_two_commands_single_undo() {
6383        let mut stack = CommandStack::new(50);
6384        let mut score = default_engine_score();
6385        let original_bpm = score.settings.tempo_bpm;
6386        stack
6387            .batch_execute(
6388                vec![
6389                    Command::SetTempo(SetTempoCmd { bpm: 160 }),
6390                    Command::SetTempo(SetTempoCmd { bpm: 180 }),
6391                ],
6392                &mut score,
6393            )
6394            .unwrap();
6395        assert_eq!(score.settings.tempo_bpm, 180);
6396        stack.undo(&mut score).unwrap();
6397        assert_eq!(score.settings.tempo_bpm, original_bpm);
6398    }
6399
6400    #[test]
6401    fn batch_execute_partial_failure_rollback() {
6402        let mut stack = CommandStack::new(50);
6403        let mut score = default_engine_score();
6404        let original_bpm = score.settings.tempo_bpm;
6405        let result = stack.batch_execute(
6406            vec![
6407                Command::SetTempo(SetTempoCmd { bpm: 160 }),
6408                Command::DeleteNote(DeleteNoteCmd {
6409                    note_id: "nonexistent".into(),
6410                    part_index: 99,
6411                    staff_index: 0,
6412                    measure_index: 0,
6413                    voice: 0,
6414                }),
6415            ],
6416            &mut score,
6417        );
6418        assert!(result.is_err());
6419        assert_eq!(score.settings.tempo_bpm, original_bpm);
6420    }
6421
6422    #[test]
6423    fn execute_rejects_invalid_candidate_without_mutating_or_recording_history() {
6424        let mut stack = CommandStack::new(50);
6425        let mut score = default_engine_score();
6426        score.parts[0].staves[0].tablature = Some(crate::TablatureConfig {
6427            lines: 6,
6428            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6429            capo: 0,
6430        });
6431        stack
6432            .execute(
6433                Command::AddNote(AddNoteCmd {
6434                    part_index: 0,
6435                    staff_index: 0,
6436                    measure_index: 0,
6437                    voice: 0,
6438                    position: 0,
6439                    pitch: Some(Pitch::new(Step::C, 4)),
6440                    duration: Duration::Quarter,
6441                    dot_count: 0,
6442                    is_rest: false,
6443                    tuplet: None,
6444                }),
6445                &mut score,
6446            )
6447            .expect("valid pitched note should be accepted");
6448        let original = score.clone();
6449        let result = stack.execute(
6450            Command::SetTabPosition(SetTabPositionCmd {
6451                part_index: 0,
6452                staff_index: 0,
6453                measure_index: 0,
6454                voice: 0,
6455                note_index: 0,
6456                position: Some(crate::TabPosition { string: 7, fret: 0 }),
6457            }),
6458            &mut score,
6459        );
6460        assert!(matches!(result, Err(Error::InvalidScore)));
6461        let note = &score.parts[0].staves[0].measures[0].voices[0][0];
6462        let original_note = &original.parts[0].staves[0].measures[0].voices[0][0];
6463        assert_eq!(note.pitches, original_note.pitches);
6464        assert_eq!(note.duration, original_note.duration);
6465        assert_eq!(note.tab_position, original_note.tab_position);
6466        assert_eq!(note.tab_positions, original_note.tab_positions);
6467        assert!(stack.can_undo());
6468    }
6469
6470    #[test]
6471    fn set_tablature_config_is_undoable_and_json_compatible() {
6472        let mut stack = CommandStack::new(50);
6473        let mut score = default_engine_score();
6474        let config = crate::TablatureConfig {
6475            lines: 6,
6476            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6477            capo: 2,
6478        };
6479        let command = Command::SetTablatureConfig(SetTablatureConfigCmd {
6480            part_index: 0,
6481            staff_index: 0,
6482            config: Some(config.clone()),
6483        });
6484        let json = serde_json::to_string(&command).expect("command should serialize");
6485        let decoded: Command = serde_json::from_str(&json).expect("command should deserialize");
6486        assert_eq!(command_key(&decoded), "SetTablatureConfig");
6487        stack
6488            .execute(decoded, &mut score)
6489            .expect("config should apply");
6490        assert_eq!(score.parts[0].staves[0].tablature, Some(config));
6491        assert_eq!(
6492            score.parts[0].staves[0].presentation.kind,
6493            StaffKind::Tablature
6494        );
6495        stack.undo(&mut score).expect("config undo should apply");
6496        assert!(score.parts[0].staves[0].tablature.is_none());
6497        assert_eq!(
6498            score.parts[0].staves[0].presentation.kind,
6499            StaffKind::Standard
6500        );
6501        stack.redo(&mut score).expect("config redo should apply");
6502        assert_eq!(
6503            score.parts[0].staves[0]
6504                .tablature
6505                .as_ref()
6506                .map(|tab| tab.capo),
6507            Some(2)
6508        );
6509    }
6510
6511    #[test]
6512    fn set_staff_presentation_is_undoable_and_requires_tablature_config() {
6513        let mut stack = CommandStack::new(50);
6514        let mut score = default_engine_score();
6515        let tab = StaffPresentation {
6516            kind: StaffKind::Tablature,
6517            lines: 6,
6518            line_distance: 1.25,
6519            small: true,
6520            cutaway: false,
6521            visible: true,
6522            notehead_scheme: super::super::score::StaffNoteheadScheme::Standard,
6523            tablature_rhythm_display: super::super::score::TablatureRhythmDisplay::FretOnly,
6524            tablature_fret_mark_style: super::super::score::TablatureFretMarkStyle::Arabic,
6525        };
6526        let rejected = Command::SetStaffPresentation(SetStaffPresentationCmd {
6527            part_index: 0,
6528            staff_index: 0,
6529            presentation: tab.clone(),
6530        });
6531        let before = score.clone();
6532        assert!(stack.execute(rejected, &mut score).is_err());
6533        assert_eq!(
6534            score.parts[0].staves[0].presentation,
6535            before.parts[0].staves[0].presentation
6536        );
6537        assert!(!stack.can_undo());
6538
6539        stack
6540            .execute(
6541                Command::SetTablatureConfig(SetTablatureConfigCmd {
6542                    part_index: 0,
6543                    staff_index: 0,
6544                    config: Some(crate::TablatureConfig {
6545                        lines: 6,
6546                        tuning_midi: vec![40, 45, 50, 55, 59, 64],
6547                        capo: 0,
6548                    }),
6549                }),
6550                &mut score,
6551            )
6552            .expect("tablature configuration should apply");
6553        stack
6554            .execute(
6555                Command::SetStaffPresentation(SetStaffPresentationCmd {
6556                    part_index: 0,
6557                    staff_index: 0,
6558                    presentation: tab.clone(),
6559                }),
6560                &mut score,
6561            )
6562            .expect("staff presentation should apply");
6563        assert_eq!(stack.undo_key().as_deref(), Some("SetStaffPresentation"));
6564        assert_eq!(score.parts[0].staves[0].presentation, tab);
6565        stack
6566            .undo(&mut score)
6567            .expect("presentation undo should apply");
6568        assert_eq!(
6569            score.parts[0].staves[0].presentation.kind,
6570            StaffKind::Tablature
6571        );
6572        stack
6573            .redo(&mut score)
6574            .expect("presentation redo should apply");
6575        assert_eq!(
6576            score.parts[0].staves[0].presentation.kind,
6577            StaffKind::Tablature
6578        );
6579    }
6580
6581    #[test]
6582    fn set_instrument_definition_is_undoable_and_rejects_invalid_ranges() {
6583        let mut stack = CommandStack::new(50);
6584        let mut score = default_engine_score();
6585        let mut invalid = InstrumentDefinition::new("violin", "Violin");
6586        invalid.written_range = Some(InstrumentRange {
6587            lowest: 100,
6588            highest: 55,
6589        });
6590        let before = score.clone();
6591        assert!(
6592            stack
6593                .execute(
6594                    Command::SetInstrumentDefinition(SetInstrumentDefinitionCmd {
6595                        part_index: 0,
6596                        definition: Some(invalid),
6597                    }),
6598                    &mut score,
6599                )
6600                .is_err()
6601        );
6602        assert_eq!(score.parts[0].instrument, before.parts[0].instrument);
6603
6604        let mut definition = InstrumentDefinition::new("violin", "Violin");
6605        definition.short_name = "Vln.".to_string();
6606        definition.family = Some("strings".to_string());
6607        definition.written_range = Some(InstrumentRange {
6608            lowest: 55,
6609            highest: 103,
6610        });
6611        definition.sounding_range = definition.written_range;
6612        definition.default_clefs = vec![Clef::Treble];
6613        definition.midi_program = 40;
6614        let command = Command::SetInstrumentDefinition(SetInstrumentDefinitionCmd {
6615            part_index: 0,
6616            definition: Some(definition.clone()),
6617        });
6618        let json = serde_json::to_string(&command).expect("command should serialize");
6619        let decoded: Command = serde_json::from_str(&json).expect("command should deserialize");
6620        assert_eq!(command_key(&decoded), "SetInstrumentDefinition");
6621        stack
6622            .execute(decoded, &mut score)
6623            .expect("definition applies");
6624        assert_eq!(score.parts[0].instrument, Some(definition));
6625        stack.undo(&mut score).expect("definition undo applies");
6626        assert!(score.parts[0].instrument.is_none());
6627        stack.redo(&mut score).expect("definition redo applies");
6628        assert_eq!(
6629            score.parts[0]
6630                .instrument
6631                .as_ref()
6632                .map(|value| value.id.as_str()),
6633            Some("violin")
6634        );
6635    }
6636
6637    #[test]
6638    fn set_percussion_kit_is_undoable_and_rejects_invalid_entries() {
6639        let mut stack = CommandStack::new(50);
6640        let mut score = default_engine_score();
6641        let invalid = Command::SetPercussionKit(SetPercussionKitCmd {
6642            part_index: 0,
6643            instruments: vec![PercussionInstrument {
6644                id: "snare".to_string(),
6645                name: None,
6646                midi_unpitched: Some(38),
6647                staff_position: Some(33),
6648                notehead: None,
6649                preferred_voice: None,
6650                techniques: Vec::new(),
6651            }],
6652        });
6653        assert!(stack.execute(invalid, &mut score).is_err());
6654        assert!(score.parts[0].percussion_instruments.is_empty());
6655
6656        let kit = vec![PercussionInstrument {
6657            id: "snare".to_string(),
6658            name: Some("Acoustic Snare".to_string()),
6659            midi_unpitched: Some(38),
6660            staff_position: Some(0),
6661            notehead: Some(NoteHead::Cross),
6662            preferred_voice: Some(1),
6663            techniques: vec!["rim-shot".to_string()],
6664        }];
6665        let command = Command::SetPercussionKit(SetPercussionKitCmd {
6666            part_index: 0,
6667            instruments: kit.clone(),
6668        });
6669        let json = serde_json::to_string(&command).expect("command serializes");
6670        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
6671        assert_eq!(command_key(&decoded), "SetPercussionKit");
6672        stack.execute(decoded, &mut score).expect("kit applies");
6673        assert_eq!(score.parts[0].percussion_instruments, kit);
6674        stack.undo(&mut score).expect("kit undo applies");
6675        assert!(score.parts[0].percussion_instruments.is_empty());
6676        stack.redo(&mut score).expect("kit redo applies");
6677        assert_eq!(score.parts[0].percussion_instruments, kit);
6678    }
6679
6680    #[test]
6681    fn set_measure_instrument_change_is_undoable_and_json_compatible() {
6682        let mut stack = CommandStack::new(50);
6683        let mut score = default_engine_score();
6684        let mut definition = InstrumentDefinition::new("clarinet-bb", "B-flat Clarinet");
6685        definition.transpose_semitones = -2;
6686        definition.midi_program = 71;
6687        let command = Command::SetMeasureInstrumentChange(SetMeasureInstrumentChangeCmd {
6688            part_index: 0,
6689            staff_index: 0,
6690            measure_index: 2,
6691            definition: Some(definition.clone()),
6692        });
6693        let json = serde_json::to_string(&command).expect("command should serialize");
6694        let decoded: Command = serde_json::from_str(&json).expect("command should deserialize");
6695        assert_eq!(command_key(&decoded), "SetMeasureInstrumentChange");
6696        stack.execute(decoded, &mut score).expect("change applies");
6697        assert_eq!(
6698            score.parts[0].staves[0].measures[2].instrument_change,
6699            Some(definition)
6700        );
6701        stack.undo(&mut score).expect("undo applies");
6702        assert!(
6703            score.parts[0].staves[0].measures[2]
6704                .instrument_change
6705                .is_none()
6706        );
6707    }
6708
6709    #[test]
6710    fn set_measure_tablature_change_is_undoable_and_uses_base_line_count() {
6711        let mut stack = CommandStack::new(50);
6712        let mut score = default_engine_score();
6713        let base = crate::TablatureConfig {
6714            lines: 6,
6715            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6716            capo: 0,
6717        };
6718        score.parts[0].staves[0].tablature = Some(base.clone());
6719        let mut changed = base;
6720        changed.tuning_midi[0] = 38;
6721        changed.capo = 2;
6722        let command = Command::SetMeasureTablatureChange(SetMeasureTablatureChangeCmd {
6723            part_index: 0,
6724            staff_index: 0,
6725            measure_index: 2,
6726            config: Some(changed.clone()),
6727        });
6728        let json = serde_json::to_string(&command).expect("command serializes");
6729        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
6730        assert_eq!(command_key(&decoded), "SetMeasureTablatureChange");
6731        stack.execute(decoded, &mut score).expect("change applies");
6732        assert_eq!(
6733            score.parts[0].staves[0].tablature_at(1),
6734            score.parts[0].staves[0].tablature
6735        );
6736        assert_eq!(score.parts[0].staves[0].tablature_at(2), Some(changed));
6737        stack.undo(&mut score).expect("change undo applies");
6738        assert!(
6739            score.parts[0].staves[0].measures[2]
6740                .tablature_change
6741                .is_none()
6742        );
6743
6744        let invalid = Command::SetMeasureTablatureChange(SetMeasureTablatureChangeCmd {
6745            part_index: 0,
6746            staff_index: 0,
6747            measure_index: 1,
6748            config: Some(crate::TablatureConfig {
6749                lines: 7,
6750                tuning_midi: vec![40, 45, 50, 55, 59, 64, 69],
6751                capo: 0,
6752            }),
6753        });
6754        assert!(stack.execute(invalid, &mut score).is_err());
6755    }
6756
6757    #[test]
6758    fn batch_execute_empty_is_noop() {
6759        let mut stack = CommandStack::new(50);
6760        let mut score = default_engine_score();
6761        stack.batch_execute(vec![], &mut score).unwrap();
6762        assert!(!stack.can_undo());
6763    }
6764
6765    // ── BatchCmd.label ────────────────────────────────────────────────────────
6766
6767    #[test]
6768    fn batch_label_used_as_command_key() {
6769        let cmd = Command::Batch(BatchCmd {
6770            commands: vec![],
6771            label: Some("ApplyAI".to_string()),
6772        });
6773        assert_eq!(command_key(&cmd), "ApplyAI");
6774    }
6775
6776    #[test]
6777    fn batch_no_label_key_is_batch() {
6778        let cmd = Command::Batch(BatchCmd {
6779            commands: vec![],
6780            label: None,
6781        });
6782        assert_eq!(command_key(&cmd), "Batch");
6783    }
6784
6785    #[test]
6786    fn batch_label_survives_json_roundtrip() {
6787        let cmd = Command::Batch(BatchCmd {
6788            commands: vec![Command::SetTempo(SetTempoCmd { bpm: 120 })],
6789            label: Some("PasteSelection".to_string()),
6790        });
6791        let json = serde_json::to_string(&cmd).unwrap();
6792        let cmd2: Command = serde_json::from_str(&json).unwrap();
6793        assert_eq!(command_key(&cmd2), "PasteSelection");
6794    }
6795
6796    #[test]
6797    fn batch_label_in_undo_key() {
6798        let mut stack = CommandStack::new(50);
6799        let mut score = default_engine_score();
6800        let cmd = Command::Batch(BatchCmd {
6801            commands: vec![Command::SetTempo(SetTempoCmd { bpm: 140 })],
6802            label: Some("ApplyAI".to_string()),
6803        });
6804        stack.execute(cmd, &mut score).unwrap();
6805        assert_eq!(stack.undo_key(), Some("ApplyAI".to_string()));
6806    }
6807
6808    #[test]
6809    fn undo_returns_change_hint() {
6810        use crate::model::change_hint::ChangeScope;
6811        let mut stack = CommandStack::new(50);
6812        let mut score = default_engine_score();
6813        stack
6814            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
6815            .unwrap();
6816        let hint = stack.undo(&mut score).unwrap();
6817        assert_eq!(hint.scope, ChangeScope::Global);
6818        assert!(hint.playback_dirty);
6819    }
6820
6821    #[test]
6822    fn redo_returns_change_hint() {
6823        use crate::model::change_hint::ChangeScope;
6824        let mut stack = CommandStack::new(50);
6825        let mut score = default_engine_score();
6826        stack
6827            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
6828            .unwrap();
6829        stack.undo(&mut score).unwrap();
6830        let hint = stack.redo(&mut score).unwrap();
6831        assert_eq!(hint.scope, ChangeScope::Global);
6832        assert!(hint.playback_dirty);
6833    }
6834
6835    // ── Feature A: ToggleSlur ─────────────────────────────────────────────
6836
6837    #[test]
6838    fn toggle_slur_sets_start_and_end() {
6839        let mut score = default_engine_score();
6840        let cmd = Command::AddNote(AddNoteCmd {
6841            part_index: 0,
6842            staff_index: 0,
6843            measure_index: 0,
6844            voice: 0,
6845            position: 0,
6846            pitch: Some(Pitch::new(Step::C, 4)),
6847            duration: Duration::Quarter,
6848            dot_count: 0,
6849            is_rest: false,
6850            tuplet: None,
6851        });
6852        apply_command(&cmd, &mut score).unwrap();
6853        apply_command(
6854            &Command::AddNote(AddNoteCmd {
6855                part_index: 0,
6856                staff_index: 0,
6857                measure_index: 0,
6858                voice: 0,
6859                position: 1,
6860                pitch: Some(Pitch::new(Step::D, 4)),
6861                duration: Duration::Quarter,
6862                dot_count: 0,
6863                is_rest: false,
6864                tuplet: None,
6865            }),
6866            &mut score,
6867        )
6868        .unwrap();
6869        let start = NoteAddr {
6870            part: 0,
6871            staff: 0,
6872            measure: 0,
6873            voice: 0,
6874            note: 0,
6875        };
6876        let end = NoteAddr {
6877            part: 0,
6878            staff: 0,
6879            measure: 0,
6880            voice: 0,
6881            note: 1,
6882        };
6883        apply_command(
6884            &Command::ToggleSlur(ToggleSlurCmd {
6885                start: start.clone(),
6886                end: end.clone(),
6887            }),
6888            &mut score,
6889        )
6890        .unwrap();
6891        assert!(score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
6892        assert!(score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
6893        // toggle off
6894        apply_command(
6895            &Command::ToggleSlur(ToggleSlurCmd { start, end }),
6896            &mut score,
6897        )
6898        .unwrap();
6899        assert!(!score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
6900        assert!(!score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
6901    }
6902
6903    // ── Feature B: AddStaff / DeleteStaff ────────────────────────────────
6904
6905    #[test]
6906    fn add_staff_appends_staff_with_correct_measure_count() {
6907        let mut score = default_engine_score();
6908        let before = score.parts[0].staves.len();
6909        let measure_count = score.parts[0].staves[0].measures.len();
6910        apply_command(
6911            &Command::AddStaff(AddStaffCmd {
6912                part_index: 0,
6913                clef: Clef::Bass,
6914            }),
6915            &mut score,
6916        )
6917        .unwrap();
6918        assert_eq!(score.parts[0].staves.len(), before + 1);
6919        let new_staff = score.parts[0].staves.last().unwrap();
6920        assert_eq!(new_staff.measures.len(), measure_count);
6921    }
6922
6923    #[test]
6924    fn add_staff_out_of_range_returns_err() {
6925        let mut score = default_engine_score();
6926        let result = apply_command(
6927            &Command::AddStaff(AddStaffCmd {
6928                part_index: 99,
6929                clef: Clef::Treble,
6930            }),
6931            &mut score,
6932        );
6933        assert!(result.is_err());
6934    }
6935
6936    #[test]
6937    fn delete_staff_removes_extra_staff() {
6938        let mut score = default_engine_score();
6939        apply_command(
6940            &Command::AddStaff(AddStaffCmd {
6941                part_index: 0,
6942                clef: Clef::Bass,
6943            }),
6944            &mut score,
6945        )
6946        .unwrap();
6947        assert_eq!(score.parts[0].staves.len(), 2);
6948        apply_command(
6949            &Command::DeleteStaff(DeleteStaffCmd {
6950                part_index: 0,
6951                staff_index: 1,
6952            }),
6953            &mut score,
6954        )
6955        .unwrap();
6956        assert_eq!(score.parts[0].staves.len(), 1);
6957    }
6958
6959    #[test]
6960    fn delete_last_staff_returns_err() {
6961        let mut score = default_engine_score();
6962        assert_eq!(score.parts[0].staves.len(), 1);
6963        let result = apply_command(
6964            &Command::DeleteStaff(DeleteStaffCmd {
6965                part_index: 0,
6966                staff_index: 0,
6967            }),
6968            &mut score,
6969        );
6970        assert!(result.is_err());
6971    }
6972
6973    // ── Feature D: SetTuplet ──────────────────────────────────────────────
6974
6975    #[test]
6976    fn set_tuplet_assigns_and_clears() {
6977        use crate::model::notation::TupletInfo;
6978        let mut score = default_engine_score();
6979        apply_command(
6980            &Command::AddNote(AddNoteCmd {
6981                part_index: 0,
6982                staff_index: 0,
6983                measure_index: 0,
6984                voice: 0,
6985                position: 0,
6986                pitch: Some(Pitch::new(Step::C, 4)),
6987                duration: Duration::Quarter,
6988                dot_count: 0,
6989                is_rest: false,
6990                tuplet: None,
6991            }),
6992            &mut score,
6993        )
6994        .unwrap();
6995        let ti = TupletInfo {
6996            actual_notes: 3,
6997            normal_notes: 2,
6998        };
6999        apply_command(
7000            &Command::SetTuplet(SetTupletCmd {
7001                part_index: 0,
7002                staff_index: 0,
7003                measure_index: 0,
7004                voice_index: 0,
7005                note_index: 0,
7006                tuplet: Some(ti.clone()),
7007            }),
7008            &mut score,
7009        )
7010        .unwrap();
7011        assert_eq!(
7012            score.parts[0].staves[0].measures[0].voices[0][0].tuplet,
7013            Some(ti)
7014        );
7015        apply_command(
7016            &Command::SetTuplet(SetTupletCmd {
7017                part_index: 0,
7018                staff_index: 0,
7019                measure_index: 0,
7020                voice_index: 0,
7021                note_index: 0,
7022                tuplet: None,
7023            }),
7024            &mut score,
7025        )
7026        .unwrap();
7027        assert!(
7028            score.parts[0].staves[0].measures[0].voices[0][0]
7029                .tuplet
7030                .is_none()
7031        );
7032    }
7033
7034    // ── Feature E: RespellScore ───────────────────────────────────────────
7035
7036    #[test]
7037    fn respell_score_cmd_changes_all_pitches() {
7038        use crate::model::pitch::Step;
7039        let mut score = default_engine_score();
7040        apply_command(
7041            &Command::AddNote(AddNoteCmd {
7042                part_index: 0,
7043                staff_index: 0,
7044                measure_index: 0,
7045                voice: 0,
7046                position: 0,
7047                pitch: Some(Pitch::with_alter(Step::C, 4, 1)), // C#4
7048                duration: Duration::Quarter,
7049                dot_count: 0,
7050                is_rest: false,
7051                tuplet: None,
7052            }),
7053            &mut score,
7054        )
7055        .unwrap();
7056        apply_command(
7057            &Command::RespellScore(RespellScoreCmd { prefer_flat: true }),
7058            &mut score,
7059        )
7060        .unwrap();
7061        let pitch = &score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
7062        assert_eq!(pitch.step, Step::D);
7063        assert_eq!(pitch.alter, -1); // Db4
7064    }
7065
7066    fn respell_fixture(measures: u32) -> Score {
7067        use crate::model::pitch::Step;
7068        let mut score = Score::new("Respell", 120, 4, 4, 0, measures);
7069        for measure in &mut score.parts[0].staves[0].measures {
7070            measure.voices[0] = vec![Note::new(Pitch::with_alter(Step::C, 4, 1), Duration::Whole)];
7071        }
7072        score
7073    }
7074
7075    fn spelled(score: &Score, measure: usize) -> (crate::model::pitch::Step, i8) {
7076        let pitch = &score.parts[0].staves[0].measures[measure].voices[0][0].pitches[0];
7077        (pitch.step.clone(), pitch.alter)
7078    }
7079
7080    #[test]
7081    fn respell_staff_region_changes_only_the_selection_and_is_undoable() {
7082        use crate::model::pitch::Step;
7083        let mut score = respell_fixture(4);
7084        let mut stack = CommandStack::new(50);
7085        let command = Command::RespellStaffRegion(RespellStaffRegionCmd {
7086            part_index: 0,
7087            staff_index: 0,
7088            start_measure: 1,
7089            end_measure: 3,
7090            policy: RespellPolicy::Flat,
7091        });
7092        let json = serde_json::to_string(&command).expect("command serializes");
7093        assert!(json.contains("\"policy\":\"flat\""));
7094        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
7095        assert_eq!(command_key(&decoded), "RespellStaffRegion");
7096        assert_eq!(command_label(&decoded), "Respell Pitches (flat)");
7097        stack.execute(decoded, &mut score).expect("region respells");
7098        assert_eq!(spelled(&score, 0), (Step::C, 1));
7099        assert_eq!(spelled(&score, 1), (Step::D, -1));
7100        assert_eq!(spelled(&score, 2), (Step::D, -1));
7101        assert_eq!(spelled(&score, 3), (Step::C, 1));
7102        stack.undo(&mut score).expect("region respell undoes");
7103        assert!((0..4).all(|measure| spelled(&score, measure) == (Step::C, 1)));
7104        stack.redo(&mut score).expect("region respell redoes");
7105        assert_eq!(spelled(&score, 2), (Step::D, -1));
7106    }
7107
7108    #[test]
7109    fn respell_staff_region_keeps_ties_across_the_boundary_in_one_spelling() {
7110        use crate::model::pitch::Step;
7111        let mut score = respell_fixture(4);
7112        {
7113            let measures = &mut score.parts[0].staves[0].measures;
7114            measures[0].voices[0][0].tie_start = true;
7115            measures[1].voices[0][0].tie_end = true;
7116            measures[1].voices[0][0].tie_start = true;
7117            measures[2].voices[0][0].tie_end = true;
7118        }
7119        let changed = respell_staff_region(&mut score, 0, 0, 1, 2, RespellPolicy::Flat)
7120            .expect("tied region respells");
7121        assert_eq!(changed, (0, 2));
7122        assert_eq!(spelled(&score, 0), (Step::D, -1));
7123        assert_eq!(spelled(&score, 1), (Step::D, -1));
7124        assert_eq!(spelled(&score, 2), (Step::D, -1));
7125        assert_eq!(spelled(&score, 3), (Step::C, 1));
7126    }
7127
7128    #[test]
7129    fn respell_staff_region_follows_local_keys_and_skips_unpitched_notes() {
7130        use crate::model::notation::KeySignature;
7131        use crate::model::pitch::Step;
7132        let mut score = respell_fixture(4);
7133        for measure in &mut score.parts[0].staves[0].measures {
7134            measure.voices[0][0].pitches[0] = Pitch::with_alter(Step::D, 4, -1);
7135        }
7136        score.parts[0].staves[0].measures[2].key_sig = Some(KeySignature {
7137            fifths: -3,
7138            mode: "major".into(),
7139        });
7140        score.parts[0].staves[0].measures[1].voices[0][0].is_unpitched = true;
7141        respell_staff_region(&mut score, 0, 0, 0, 4, RespellPolicy::Key).expect("key respell");
7142        assert_eq!(spelled(&score, 0), (Step::C, 1));
7143        assert_eq!(
7144            spelled(&score, 1),
7145            (Step::D, -1),
7146            "unpitched staff position is kept"
7147        );
7148        assert_eq!(spelled(&score, 2), (Step::D, -1));
7149        assert_eq!(spelled(&score, 3), (Step::D, -1));
7150
7151        assert!(matches!(
7152            respell_staff_region(&mut score, 0, 0, 2, 2, RespellPolicy::Sharp),
7153            Err(Error::InvalidCommand(_))
7154        ));
7155        assert!(matches!(
7156            respell_staff_region(&mut score, 0, 5, 0, 1, RespellPolicy::Sharp),
7157            Err(Error::StaffNotFound(5))
7158        ));
7159        assert!(matches!(
7160            respell_staff_region(&mut score, 0, 0, 0, 5, RespellPolicy::Sharp),
7161            Err(Error::InvalidCommand(_))
7162        ));
7163    }
7164
7165    #[test]
7166    fn pickup_measure_length_limits_editing_and_survives_time_signature_changes() {
7167        use crate::model::score::MeasureLength;
7168        let mut score = Score::new("Pickup", 120, 4, 4, 0, 2);
7169        score.parts[0].staves[0].measures[0].actual_length = Some(MeasureLength {
7170            numerator: 1,
7171            denominator: 4,
7172        });
7173        score.parts[0].staves[0].measures[0].voices[0] = vec![Note::rest(Duration::Quarter)];
7174        assert!(validate(&score).is_valid());
7175        assert_eq!(
7176            crate::measure_beats_remaining(&score, 0, 0, 0, 0).expect("capacity"),
7177            0.0
7178        );
7179        let mut stack = CommandStack::new(50);
7180        stack
7181            .execute(
7182                Command::AddNote(AddNoteCmd {
7183                    part_index: 0,
7184                    staff_index: 0,
7185                    measure_index: 0,
7186                    voice: 0,
7187                    position: 0,
7188                    pitch: Some(Pitch::new(Step::G, 4)),
7189                    duration: Duration::Quarter,
7190                    dot_count: 0,
7191                    is_rest: false,
7192                    tuplet: None,
7193                }),
7194                &mut score,
7195            )
7196            .expect("note fits the pickup after trimming");
7197        let pickup = &score.parts[0].staves[0].measures[0].voices[0];
7198        assert_eq!(pickup.len(), 1);
7199        assert!(!pickup[0].is_rest);
7200
7201        stack
7202            .execute(
7203                Command::SetTimeSignature(SetTimeSignatureCmd {
7204                    numerator: 3,
7205                    denominator: 4,
7206                }),
7207                &mut score,
7208            )
7209            .expect("time signature changes");
7210        let beats: f64 = score.parts[0].staves[0].measures[0].voices[0]
7211            .iter()
7212            .map(|note| note.beats())
7213            .sum();
7214        assert!((beats - 1.0).abs() < 1e-9, "the pickup keeps one beat");
7215
7216        score.parts[0].staves[0].measures[1].actual_length = Some(MeasureLength {
7217            numerator: 0,
7218            denominator: 4,
7219        });
7220        assert!(validate(&score).errors.iter().any(|error| matches!(
7221            error,
7222            crate::ValidationError::InvalidMeasureLength { measure: 1, .. }
7223        )));
7224    }
7225
7226    #[test]
7227    fn set_lyric_addresses_verses_without_touching_verse_one() {
7228        use crate::model::notation::VerseLyric;
7229        let mut score = Score::new("Verses", 120, 4, 4, 0, 1);
7230        score.parts[0].staves[0].measures[0].voices[0] =
7231            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
7232        let lyric = |text: &str| Lyric {
7233            text: text.into(),
7234            syllabic: "single".into(),
7235            extend: false,
7236        };
7237        let set = |verse: Option<u8>, text: Option<&str>| {
7238            Command::SetLyric(SetLyricCmd {
7239                part_index: 0,
7240                staff_index: 0,
7241                measure_index: 0,
7242                voice: 0,
7243                note_index: 0,
7244                lyric: text.map(lyric),
7245                verse,
7246            })
7247        };
7248        let mut stack = CommandStack::new(50);
7249        stack
7250            .execute(set(None, Some("one")), &mut score)
7251            .expect("verse 1");
7252        stack
7253            .execute(set(Some(3), Some("three")), &mut score)
7254            .expect("verse 3");
7255        stack
7256            .execute(set(Some(2), Some("two")), &mut score)
7257            .expect("verse 2");
7258        stack
7259            .execute(set(Some(2), Some("TWO")), &mut score)
7260            .expect("replace verse 2");
7261        let note = |score: &Score| score.parts[0].staves[0].measures[0].voices[0][0].clone();
7262        assert_eq!(note(&score).lyric, Some(lyric("one")));
7263        assert_eq!(
7264            note(&score).additional_lyrics,
7265            vec![
7266                VerseLyric {
7267                    verse: 2,
7268                    lyric: lyric("TWO")
7269                },
7270                VerseLyric {
7271                    verse: 3,
7272                    lyric: lyric("three")
7273                }
7274            ]
7275        );
7276        stack
7277            .execute(set(Some(3), None), &mut score)
7278            .expect("clear verse 3");
7279        assert_eq!(note(&score).additional_lyrics.len(), 1);
7280        stack.undo(&mut score).expect("clear undoes");
7281        assert_eq!(note(&score).additional_lyrics.len(), 2);
7282
7283        // Legacy JSON without `verse` still targets verse 1.
7284        let current = serde_json::to_string(&set(None, None)).expect("SetLyric serializes");
7285        assert!(current.contains("\"verse\":null"));
7286        let legacy: Command = serde_json::from_str(&current.replace(",\"verse\":null", ""))
7287            .expect("legacy SetLyric JSON");
7288        stack
7289            .execute(legacy, &mut score)
7290            .expect("legacy clears verse 1");
7291        assert_eq!(note(&score).lyric, None);
7292        assert_eq!(note(&score).additional_lyrics.len(), 2);
7293
7294        assert!(matches!(
7295            apply_command(&set(Some(0), Some("zero")), &mut score),
7296            Err(Error::InvalidCommand(_))
7297        ));
7298        assert!(matches!(
7299            apply_command(&set(Some(33), Some("too far")), &mut score),
7300            Err(Error::InvalidCommand(_))
7301        ));
7302        score.parts[0].staves[0].measures[0].voices[0][0]
7303            .additional_lyrics
7304            .push(VerseLyric {
7305                verse: 2,
7306                lyric: lyric("duplicate"),
7307            });
7308        assert!(validate(&score).errors.iter().any(|error| matches!(
7309            error,
7310            crate::ValidationError::InvalidLyricVerse { verse: 2, .. }
7311        )));
7312    }
7313
7314    #[test]
7315    fn cycle_enharmonic_spelling_targets_one_chord_member_and_undoes() {
7316        let mut score = Score::new("Enharmonic", 120, 4, 4, 0, 1);
7317        let mut chord = Note::new(Pitch::with_alter(Step::C, 4, 1), Duration::Whole);
7318        chord.pitches.push(Pitch::with_alter(Step::G, 4, 1));
7319        score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
7320        let mut stack = CommandStack::new(50);
7321        let command = Command::CycleEnharmonicSpelling(CycleEnharmonicSpellingCmd {
7322            part_index: 0,
7323            staff_index: 0,
7324            measure_index: 0,
7325            voice: 0,
7326            note_index: 0,
7327            pitch_index: Some(1),
7328        });
7329        let json = serde_json::to_string(&command).expect("command serializes");
7330        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
7331        assert_eq!(command_key(&decoded), "CycleEnharmonicSpelling");
7332        stack.execute(decoded, &mut score).expect("spelling cycles");
7333        let pitches = |score: &Score| {
7334            score.parts[0].staves[0].measures[0].voices[0][0]
7335                .pitches
7336                .clone()
7337        };
7338        assert_eq!(
7339            pitches(&score),
7340            vec![
7341                Pitch::with_alter(Step::C, 4, 1),
7342                Pitch::with_alter(Step::A, 4, -1)
7343            ]
7344        );
7345        stack.undo(&mut score).expect("spelling undoes");
7346        assert_eq!(pitches(&score)[1], Pitch::with_alter(Step::G, 4, 1));
7347
7348        let mut rest_score = Score::new("Rest", 120, 4, 4, 0, 1);
7349        rest_score.parts[0].staves[0].measures[0].voices[0] = vec![Note::rest(Duration::Whole)];
7350        assert!(matches!(
7351            apply_command(&command, &mut rest_score),
7352            Err(Error::InvalidCommand(_))
7353        ));
7354    }
7355
7356    #[test]
7357    fn resequence_rehearsal_marks_continues_the_first_sequence() {
7358        let mut score = Score::new("Marks", 120, 4, 4, 0, 30);
7359        let mut stack = CommandStack::new(50);
7360        for (index, text) in [(0, "A"), (4, "C"), (9, "C"), (12, "Q")] {
7361            for_each_measure_at(&mut score, index, |measure| {
7362                measure.rehearsal = Some(text.into())
7363            });
7364        }
7365        let command = Command::ResequenceRehearsalMarks(ResequenceRehearsalMarksCmd::default());
7366        let json = serde_json::to_string(&command).expect("command serializes");
7367        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
7368        assert_eq!(command_key(&decoded), "ResequenceRehearsalMarks");
7369        stack
7370            .execute(decoded, &mut score)
7371            .expect("marks resequence");
7372        let marks = |score: &Score| -> Vec<Option<String>> {
7373            [0, 4, 9, 12]
7374                .iter()
7375                .map(|&index| score.parts[0].staves[0].measures[index].rehearsal.clone())
7376                .collect()
7377        };
7378        assert_eq!(
7379            marks(&score),
7380            ["A", "B", "C", "D"].map(|text| Some(text.to_string()))
7381        );
7382        stack.undo(&mut score).expect("resequence undoes");
7383        assert_eq!(marks(&score)[1].as_deref(), Some("C"));
7384
7385        let sequence = RehearsalSequence::detect("Z", 1).expect("letters");
7386        assert_eq!(sequence.nth(1, 1), "AA");
7387        assert_eq!(sequence.nth(2, 1), "AB");
7388        let lower = RehearsalSequence::detect("y", 1).expect("lower letters");
7389        assert_eq!(lower.nth(2, 1), "aa");
7390        assert_eq!(
7391            RehearsalSequence::detect("7", 3)
7392                .expect("numbers")
7393                .nth(2, 9),
7394            "9"
7395        );
7396        assert_eq!(
7397            RehearsalSequence::detect("5", 5)
7398                .expect("measure numbers")
7399                .nth(3, 17),
7400            "17"
7401        );
7402        assert!(RehearsalSequence::detect("Intro", 1).is_none());
7403
7404        for_each_measure_at(&mut score, 0, |measure| {
7405            measure.rehearsal = Some("Intro".into())
7406        });
7407        assert!(matches!(
7408            apply_command(&command, &mut score),
7409            Err(Error::InvalidCommand(_))
7410        ));
7411    }
7412
7413    #[test]
7414    fn system_break_interval_replaces_line_breaks_only() {
7415        let mut score = Score::new("Breaks", 120, 4, 4, 0, 10);
7416        score.parts[0].staves[0].measures[1].system_break = true;
7417        score.parts[0].staves[0].measures[2].page_break = true;
7418        apply_command(
7419            &Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
7420                interval: 4,
7421                start_measure: None,
7422                end_measure: None,
7423            }),
7424            &mut score,
7425        )
7426        .expect("breaks every four measures");
7427        let breaks: Vec<usize> = score.parts[0].staves[0]
7428            .measures
7429            .iter()
7430            .enumerate()
7431            .filter(|(_, measure)| measure.system_break)
7432            .map(|(index, _)| index)
7433            .collect();
7434        assert_eq!(breaks, vec![3, 7]);
7435        assert!(score.parts[0].staves[0].measures[2].page_break);
7436
7437        let mut exact = Score::new("Exact", 120, 4, 4, 0, 8);
7438        apply_command(
7439            &Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
7440                interval: 4,
7441                start_measure: None,
7442                end_measure: None,
7443            }),
7444            &mut exact,
7445        )
7446        .expect("no break after the final measure");
7447        assert!(!exact.parts[0].staves[0].measures[7].system_break);
7448        assert!(exact.parts[0].staves[0].measures[3].system_break);
7449
7450        apply_command(
7451            &Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
7452                interval: 0,
7453                start_measure: Some(0),
7454                end_measure: Some(6),
7455            }),
7456            &mut score,
7457        )
7458        .expect("breaks removed in range");
7459        assert!(!score.parts[0].staves[0].measures[3].system_break);
7460        assert!(score.parts[0].staves[0].measures[7].system_break);
7461        assert!(matches!(
7462            apply_command(
7463                &Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
7464                    interval: 2,
7465                    start_measure: Some(4),
7466                    end_measure: Some(11),
7467                }),
7468                &mut score,
7469            ),
7470            Err(Error::InvalidCommand(_))
7471        ));
7472    }
7473
7474    #[test]
7475    fn remove_trailing_empty_measures_keeps_content_and_final_barline() {
7476        let mut score = Score::new("Trailing", 120, 4, 4, 0, 6);
7477        let mut stack = CommandStack::new(50);
7478        score.parts[0].staves[0].measures[1].voices[0] =
7479            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
7480        score.parts[0].staves[0].measures[5].barline_right = Barline::Final;
7481        let mut fermata_rest = Note::rest(Duration::Whole);
7482        fermata_rest.articulations = vec![Articulation::Fermata];
7483        score.parts[0].staves[0].measures[3].voices[0] = vec![fermata_rest];
7484        stack
7485            .execute(
7486                Command::RemoveTrailingEmptyMeasures(RemoveTrailingEmptyMeasuresCmd {}),
7487                &mut score,
7488            )
7489            .expect("trailing measures removed");
7490        let staff = &score.parts[0].staves[0];
7491        assert_eq!(staff.measures.len(), 4, "a rest with a fermata is content");
7492        assert!(matches!(staff.measures[3].barline_right, Barline::Final));
7493        assert_eq!(staff.measures[3].number, 4);
7494        stack.undo(&mut score).expect("removal undoes");
7495        assert_eq!(score.parts[0].staves[0].measures.len(), 6);
7496
7497        let mut empty = Score::new("Empty", 120, 4, 4, 0, 3);
7498        apply_command(
7499            &Command::RemoveTrailingEmptyMeasures(RemoveTrailingEmptyMeasuresCmd {}),
7500            &mut empty,
7501        )
7502        .expect("empty score keeps one measure");
7503        assert_eq!(empty.parts[0].staves[0].measures.len(), 1);
7504    }
7505
7506    #[test]
7507    fn typed_spanner_commands_are_atomic_and_undoable() {
7508        use crate::model::score::{NotationSpanner, NotationSpannerKind};
7509
7510        let mut score = default_engine_score();
7511        let address = NoteAddr {
7512            part: 0,
7513            staff: 0,
7514            measure: 0,
7515            voice: 0,
7516            note: 0,
7517        };
7518        let spanner = NotationSpanner {
7519            id: "slur-1".to_string(),
7520            kind: NotationSpannerKind::Slur,
7521            start: address.clone(),
7522            end: address,
7523            number: Some(1),
7524            line_type: Some("dashed".to_string()),
7525            text: None,
7526            placement: Some("above".to_string()),
7527            ottava_size: None,
7528            ottava_type: None,
7529        };
7530        let mut stack = CommandStack::new(8);
7531        stack
7532            .execute(Command::AddSpanner(AddSpannerCmd { spanner }), &mut score)
7533            .unwrap();
7534        assert_eq!(score.spanners.len(), 1);
7535        assert_eq!(stack.undo_key().as_deref(), Some("AddSpanner"));
7536
7537        let before_duplicate = score.clone();
7538        let duplicate = score.spanners[0].clone();
7539        assert!(
7540            stack
7541                .execute(
7542                    Command::AddSpanner(AddSpannerCmd { spanner: duplicate }),
7543                    &mut score,
7544                )
7545                .is_err()
7546        );
7547        assert_eq!(score.spanners, before_duplicate.spanners);
7548
7549        let mut updated = score.spanners[0].clone();
7550        updated.number = Some(2);
7551        stack
7552            .execute(
7553                Command::UpdateSpanner(UpdateSpannerCmd { spanner: updated }),
7554                &mut score,
7555            )
7556            .unwrap();
7557        assert_eq!(score.spanners[0].number, Some(2));
7558        stack.undo(&mut score).unwrap();
7559        assert_eq!(score.spanners[0].number, Some(1));
7560        stack.redo(&mut score).unwrap();
7561        assert_eq!(score.spanners[0].number, Some(2));
7562
7563        stack
7564            .execute(
7565                Command::RemoveSpanner(RemoveSpannerCmd {
7566                    id: "slur-1".to_string(),
7567                }),
7568                &mut score,
7569            )
7570            .unwrap();
7571        assert!(score.spanners.is_empty());
7572        stack.undo(&mut score).unwrap();
7573        assert_eq!(score.spanners.len(), 1);
7574    }
7575
7576    #[test]
7577    fn string_quartet_linked_views_roundtrip_and_undo_redo_as_one_contract() {
7578        let mut score = Score::template(ScoreTemplate::StringQuartet);
7579        let mut stack = CommandStack::new(16);
7580        for (part, id, name) in [
7581            (0, "violin-1", "Violin I"),
7582            (1, "violin-2", "Violin II"),
7583            (2, "viola", "Viola"),
7584            (3, "cello", "Cello"),
7585        ] {
7586            stack
7587                .execute(
7588                    Command::UpsertScoreView(UpsertScoreViewCmd {
7589                        view: ScoreView::linked_part(id, name, part),
7590                    }),
7591                    &mut score,
7592                )
7593                .expect("linked part view applies");
7594        }
7595        assert_eq!(score.parts.len(), 4);
7596        assert_eq!(score.views.len(), 4);
7597        for (part, view) in score.views.iter().enumerate() {
7598            assert_eq!(view.parts, vec![part]);
7599            assert_eq!(score.resolve_view(&view.id).unwrap().parts.len(), 1);
7600        }
7601
7602        let restored: Score =
7603            serde_json::from_str(&serde_json::to_string(&score).expect("quartet score serializes"))
7604                .expect("quartet score deserializes");
7605        assert_eq!(restored.views, score.views);
7606
7607        for _ in 0..4 {
7608            stack.undo(&mut score).expect("view undo applies");
7609        }
7610        assert!(score.views.is_empty());
7611        for _ in 0..4 {
7612            stack.redo(&mut score).expect("view redo applies");
7613        }
7614        assert_eq!(score.views, restored.views);
7615    }
7616
7617    #[test]
7618    fn linked_view_rejects_invalid_projection_and_keeps_the_source_intact() {
7619        use crate::model::score::{ViewStaffKindOverride, ViewStaffRef, ViewStyleProperty};
7620
7621        let mut score = Score::template(ScoreTemplate::StringQuartet);
7622        let original = score.clone();
7623        let original_parts = serde_json::to_value(&score.parts).unwrap();
7624        let base = ScoreView::linked_part("viola", "Viola", 2);
7625        let mut invalid = base.clone();
7626        invalid.parts.push(2);
7627        assert!(
7628            apply_command(
7629                &Command::UpsertScoreView(UpsertScoreViewCmd { view: invalid }),
7630                &mut score
7631            )
7632            .is_err()
7633        );
7634        let mut invalid = base.clone();
7635        invalid
7636            .layout
7637            .hidden_staves
7638            .push(ViewStaffRef { part: 1, staff: 0 });
7639        assert!(
7640            apply_command(
7641                &Command::UpsertScoreView(UpsertScoreViewCmd { view: invalid }),
7642                &mut score
7643            )
7644            .is_err()
7645        );
7646        let mut invalid = base.clone();
7647        invalid.staff_kind_overrides.push(ViewStaffKindOverride {
7648            staff: ViewStaffRef { part: 2, staff: 0 },
7649            kind: StaffKind::Tablature,
7650        });
7651        assert!(
7652            apply_command(
7653                &Command::UpsertScoreView(UpsertScoreViewCmd { view: invalid }),
7654                &mut score
7655            )
7656            .is_err()
7657        );
7658        let mut invalid = base.clone();
7659        invalid
7660            .layout
7661            .typed_style_overrides
7662            .push(ViewStyleOverride {
7663                property: ViewStyleProperty::StaffSpace,
7664                value: f32::NAN,
7665            });
7666        assert!(
7667            apply_command(
7668                &Command::UpsertScoreView(UpsertScoreViewCmd { view: invalid }),
7669                &mut score
7670            )
7671            .is_err()
7672        );
7673        assert_eq!(score.views, original.views);
7674        assert_eq!(serde_json::to_value(&score.parts).unwrap(), original_parts);
7675
7676        let mut view = base;
7677        view.parts = vec![2, 0];
7678        view.layout
7679            .hidden_staves
7680            .push(ViewStaffRef { part: 0, staff: 0 });
7681        view.layout.typed_style_overrides.push(ViewStyleOverride {
7682            property: ViewStyleProperty::StaffSpace,
7683            value: 1.25,
7684        });
7685        view.staff_kind_overrides.push(ViewStaffKindOverride {
7686            staff: ViewStaffRef { part: 2, staff: 0 },
7687            kind: StaffKind::Percussion,
7688        });
7689        let mut stack = CommandStack::new(4);
7690        stack
7691            .execute(
7692                Command::UpsertScoreView(UpsertScoreViewCmd { view }),
7693                &mut score,
7694            )
7695            .expect("valid linked projection applies");
7696        let projected = score.resolve_view("viola").expect("view resolves");
7697        assert_eq!(projected.parts.len(), 2);
7698        assert_eq!(projected.parts[0].name, original.parts[2].name);
7699        assert_eq!(projected.parts[1].name, original.parts[0].name);
7700        assert_eq!(
7701            projected.parts[0].staves[0].presentation.kind,
7702            StaffKind::Percussion
7703        );
7704        assert_eq!(projected.views[0].layout.hidden_staves[0].part, 1);
7705        assert_eq!(projected.views[0].layout.resolved_style().staff_space, 1.25);
7706        assert_eq!(serde_json::to_value(&score.parts).unwrap(), original_parts);
7707        stack.undo(&mut score).expect("view creation is undoable");
7708        assert!(score.views.is_empty());
7709    }
7710
7711    #[test]
7712    fn template_score_edit_session_keeps_global_and_measure_marks_in_sync() {
7713        let mut score = Score::default();
7714        apply_command(
7715            &Command::NewScore(NewScoreCmd {
7716                title: "Quartet rehearsal".into(),
7717                composer: "Composer".into(),
7718                tempo_bpm: 96,
7719                time_numerator: 3,
7720                time_denominator: 4,
7721                key_fifths: 2,
7722                measure_count: 2,
7723                template: Some(ScoreTemplate::StringQuartet),
7724            }),
7725            &mut score,
7726        )
7727        .unwrap();
7728        assert_eq!(score.parts.len(), 4);
7729        assert_eq!(score.parts[3].staves[0].measures.len(), 2);
7730        assert_eq!(score.metadata.composer, "Composer");
7731        for command in [
7732            Command::SetKeySignature(SetKeySignatureCmd { fifths: -1 }),
7733            Command::SetRehearsalMark(SetRehearsalMarkCmd {
7734                measure_index: 0,
7735                text: Some("A".into()),
7736            }),
7737            Command::SetNavigationMark(SetNavigationMarkCmd {
7738                measure_index: 1,
7739                mark: Some("Fine".into()),
7740            }),
7741            Command::SetSystemBreak(SetSystemBreakCmd {
7742                measure_index: 1,
7743                value: true,
7744            }),
7745            Command::SetPageBreak(SetPageBreakCmd {
7746                measure_index: 1,
7747                value: true,
7748            }),
7749        ] {
7750            apply_command(&command, &mut score).unwrap();
7751        }
7752        assert_eq!(score.settings.key_signature.fifths, -1);
7753        for part in &score.parts {
7754            let measures = &part.staves[0].measures;
7755            assert_eq!(measures[0].rehearsal.as_deref(), Some("A"));
7756            assert_eq!(measures[1].navigation.as_deref(), Some("Fine"));
7757            assert!(measures[1].system_break);
7758            assert!(measures[1].page_break);
7759        }
7760    }
7761
7762    #[test]
7763    fn section_break_is_atomic_undoable_and_synced_across_staves() {
7764        let mut score = Score::template(ScoreTemplate::Piano);
7765        let original = score.clone();
7766        let mut stack = CommandStack::new(4);
7767        stack
7768            .execute(
7769                Command::SetSectionBreak(SetSectionBreakCmd {
7770                    measure_index: 2,
7771                    value: true,
7772                }),
7773                &mut score,
7774            )
7775            .expect("section break applies");
7776        assert!(
7777            score
7778                .parts
7779                .iter()
7780                .flat_map(|part| part.staves.iter())
7781                .all(|staff| staff.measures[2].section_break)
7782        );
7783        stack.undo(&mut score).expect("section break undoes");
7784        assert_eq!(
7785            serde_json::to_value(&score).unwrap(),
7786            serde_json::to_value(&original).unwrap()
7787        );
7788
7789        assert!(
7790            stack
7791                .execute(
7792                    Command::SetSectionBreak(SetSectionBreakCmd {
7793                        measure_index: 99,
7794                        value: true,
7795                    }),
7796                    &mut score,
7797                )
7798                .is_err()
7799        );
7800        assert_eq!(
7801            serde_json::to_value(&score).unwrap(),
7802            serde_json::to_value(&original).unwrap()
7803        );
7804    }
7805
7806    #[test]
7807    fn notation_editing_session_keeps_score_valid_and_undoes_atomically() {
7808        let mut score = Score::new("Notation session", 120, 4, 4, 0, 1);
7809        score.parts[0].staves[0].measures[0].voices[0] = (0..4)
7810            .map(|_| Note::new(Pitch::new(Step::C, 4), Duration::Quarter))
7811            .collect();
7812        let original = serde_json::to_value(&score).unwrap();
7813        let mut stack = CommandStack::new(16);
7814        let edits = [
7815            (
7816                Command::AddPitch(AddPitchCmd {
7817                    part_index: 0,
7818                    staff_index: 0,
7819                    measure_index: 0,
7820                    voice: 0,
7821                    note_index: 0,
7822                    pitch: Pitch::new(Step::E, 4),
7823                }),
7824                "Add Pitch",
7825                "AddPitch",
7826            ),
7827            (
7828                Command::SetArpeggio(SetArpeggioCmd {
7829                    part_index: 0,
7830                    staff_index: 0,
7831                    measure_index: 0,
7832                    voice_index: 0,
7833                    note_index: 0,
7834                    direction: Some(true),
7835                }),
7836                "Set Arpeggio",
7837                "SetArpeggio",
7838            ),
7839            (
7840                Command::SetStem(SetStemCmd {
7841                    part_index: 0,
7842                    staff_index: 0,
7843                    measure_index: 0,
7844                    voice_index: 0,
7845                    note_index: 0,
7846                    stem_up: Some(true),
7847                }),
7848                "Set Stem",
7849                "SetStem",
7850            ),
7851            (
7852                Command::SetDynamic(SetDynamicCmd {
7853                    part_index: 0,
7854                    staff_index: 0,
7855                    measure_index: 0,
7856                    voice: 0,
7857                    note_index: 0,
7858                    dynamic: Some(Dynamic::Mf),
7859                }),
7860                "Set Dynamic",
7861                "SetDynamic",
7862            ),
7863            (
7864                Command::ToggleArticulation(ToggleArticulationCmd {
7865                    part_index: 0,
7866                    staff_index: 0,
7867                    measure_index: 0,
7868                    voice: 0,
7869                    note_index: 1,
7870                    articulation: Articulation::Staccato,
7871                }),
7872                "Toggle Articulation",
7873                "ToggleArticulation",
7874            ),
7875            (
7876                Command::ToggleTie(ToggleTieCmd {
7877                    part_index: 0,
7878                    staff_index: 0,
7879                    measure_index: 0,
7880                    voice: 0,
7881                    note_index: 1,
7882                }),
7883                "Toggle Tie",
7884                "ToggleTie",
7885            ),
7886            (
7887                Command::AddHairpin(AddHairpinCmd {
7888                    part_index: 0,
7889                    staff_index: 0,
7890                    measure_index: 0,
7891                    voice: 0,
7892                    start_note_idx: 0,
7893                    end_note_idx: 3,
7894                    kind: HairpinKind::Crescendo,
7895                }),
7896                "Add Hairpin",
7897                "AddHairpin",
7898            ),
7899            (
7900                Command::AddPedal(AddPedalCmd {
7901                    part_index: 0,
7902                    staff_index: 0,
7903                    measure_index: 0,
7904                    voice: 0,
7905                    start_note_idx: 0,
7906                    end_note_idx: 3,
7907                }),
7908                "Add Pedal",
7909                "AddPedal",
7910            ),
7911            (
7912                Command::SetBarline(SetBarlineCmd {
7913                    measure_index: 0,
7914                    side: "right".into(),
7915                    barline: Barline::Double,
7916                }),
7917                "Set Barline",
7918                "SetBarline",
7919            ),
7920        ];
7921        for (command, label, key) in edits {
7922            stack.execute(command, &mut score).unwrap();
7923            assert_eq!(stack.undo_label().as_deref(), Some(label));
7924            assert_eq!(stack.undo_key().as_deref(), Some(key));
7925        }
7926        assert!(validate(&score).is_valid());
7927        let voice = &score.parts[0].staves[0].measures[0].voices[0];
7928        assert_eq!(voice[0].pitches.len(), 2);
7929        assert_eq!(voice[0].arpeggiate, Some(true));
7930        assert_eq!(voice[0].hairpin_start, Some(HairpinKind::Crescendo));
7931        assert!(voice[0].pedal_start);
7932        assert!(voice[1].tie_start && voice[2].tie_end);
7933        assert!(voice[3].pedal_end && voice[3].hairpin_end);
7934        assert_eq!(
7935            score.parts[0].staves[0].measures[0].barline_right,
7936            Barline::Double
7937        );
7938        let edited = serde_json::to_value(&score).unwrap();
7939        for _ in 0..9 {
7940            stack.undo(&mut score).unwrap();
7941        }
7942        assert_eq!(serde_json::to_value(&score).unwrap(), original);
7943        for _ in 0..9 {
7944            stack.redo(&mut score).unwrap();
7945        }
7946        assert_eq!(serde_json::to_value(&score).unwrap(), edited);
7947    }
7948
7949    #[test]
7950    fn guitar_annotation_commands_keep_json_and_score_semantics() {
7951        let mut score = Score::new("Guitar annotations", 120, 4, 4, 0, 1);
7952        score.parts[0].staves[0].measures[0].voices[0] = (0..4)
7953            .map(|_| Note::new(Pitch::new(Step::C, 4), Duration::Quarter))
7954            .collect();
7955        let edits = [
7956            (
7957                serde_json::json!({"type":"set_tablature_config", "part_index":0,
7958                    "staff_index":0, "config":{"lines":6,"tuning_midi":[40,45,50,55,59,64],"capo":0}}),
7959                "SetTablatureConfig",
7960            ),
7961            (
7962                serde_json::json!({"type":"set_fingering", "part_index":0,"staff_index":0,
7963                    "measure_index":0,"voice":0,"note_index":0,"fingering":2}),
7964                "SetFingering",
7965            ),
7966            (
7967                serde_json::json!({"type":"set_fingerings", "part_index":0,"staff_index":0,
7968                    "measure_index":0,"voice":0,"note_index":0,"fingerings":[1,3]}),
7969                "SetFingerings",
7970            ),
7971            (
7972                serde_json::json!({"type":"set_string_number", "part_index":0,"staff_index":0,
7973                    "measure_index":0,"voice":0,"note_index":0,"string_number":2}),
7974                "SetStringNumber",
7975            ),
7976            (
7977                serde_json::json!({"type":"set_tab_position", "part_index":0,"staff_index":0,
7978                    "measure_index":0,"voice":0,"note_index":0,"position":{"string":2,"fret":1}}),
7979                "SetTabPosition",
7980            ),
7981            (
7982                serde_json::json!({"type":"set_guitar_technique", "part_index":0,"staff_index":0,
7983                    "measure_index":0,"voice":0,"note_index":0,"technique":"slide"}),
7984                "SetGuitarTechnique",
7985            ),
7986            (
7987                serde_json::json!({"type":"set_technique_text", "part_index":0,"staff_index":0,
7988                    "measure_index":0,"voice":0,"note_index":0,"text":"let ring"}),
7989                "SetTechniqueText",
7990            ),
7991            (
7992                serde_json::json!({"type":"set_note_head", "part_index":0,"staff_index":0,
7993                    "measure_index":0,"voice":0,"note_index":0,"note_head":"Diamond"}),
7994                "SetNoteHead",
7995            ),
7996            (
7997                serde_json::json!({"type":"set_hidden", "part_index":0,"staff_index":0,
7998                    "measure_index":0,"voice":0,"note_index":0,"hidden":true}),
7999                "SetHidden",
8000            ),
8001            (
8002                serde_json::json!({"type":"set_instrument_id", "part_index":0,"staff_index":0,
8003                    "measure_index":0,"voice":0,"note_index":0,"instrument_id":"guitar"}),
8004                "SetInstrumentId",
8005            ),
8006            (
8007                serde_json::json!({"type":"set_note_placement", "part_index":0,"staff_index":0,
8008                    "measure_index":0,"voice":0,"note_index":0,"offset_x":1.5,"offset_y":-2.0}),
8009                "SetNotePlacement",
8010            ),
8011            (
8012                serde_json::json!({"type":"set_expression_text", "measure_index":0,
8013                    "text":"dolce"}),
8014                "SetExpressionText",
8015            ),
8016        ];
8017        for (value, key) in edits {
8018            let command: Command = serde_json::from_value(value).unwrap();
8019            assert_eq!(command_key(&command), key);
8020            assert!(!command_label(&command).is_empty());
8021            let hint = command_hint(&command);
8022            match key {
8023                "SetTablatureConfig" => assert_eq!(hint.scope, ChangeScope::Part(0)),
8024                "SetExpressionText" => assert_eq!(hint.scope, ChangeScope::Global),
8025                _ => assert_eq!(
8026                    hint.scope,
8027                    ChangeScope::Measures {
8028                        part: 0,
8029                        staff: 0,
8030                        start: 0,
8031                        end: 1
8032                    }
8033                ),
8034            }
8035            apply_command(&command, &mut score).unwrap();
8036        }
8037        let note = &score.parts[0].staves[0].measures[0].voices[0][0];
8038        assert_eq!(note.fingerings, vec![1, 3]);
8039        assert_eq!(
8040            note.tab_position,
8041            Some(crate::TabPosition { string: 2, fret: 1 })
8042        );
8043        assert_eq!(note.guitar_technique, Some(GuitarTechnique::Slide));
8044        assert_eq!(note.technique_text.as_deref(), Some("let ring"));
8045        assert_eq!(note.note_head, NoteHead::Diamond);
8046        assert!(note.hidden);
8047        assert_eq!(note.instrument_id.as_deref(), Some("guitar"));
8048        assert_eq!(note.offset_x, Some(1.5));
8049        assert_eq!(
8050            score.parts[0].staves[0].measures[0]
8051                .expression_text
8052                .as_deref(),
8053            Some("dolce")
8054        );
8055        assert!(validate(&score).is_valid());
8056    }
8057
8058    #[test]
8059    fn note_edits_reject_invalid_values_without_changing_the_score() {
8060        let mut score = Score::new("Note edits", 120, 4, 4, 0, 1);
8061        score.parts[0].staves[0].measures[0].voices[0] = vec![
8062            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
8063            Note::rest(Duration::Quarter),
8064        ];
8065        let grace = |note_index| {
8066            Command::SetGrace(SetGraceCmd {
8067                part_index: 0,
8068                staff_index: 0,
8069                measure_index: 0,
8070                voice: 0,
8071                note_index,
8072                is_grace: true,
8073                slash: true,
8074            })
8075        };
8076        let before = serde_json::to_value(&score).unwrap();
8077        assert!(apply_command(&grace(1), &mut score).is_err());
8078        assert_eq!(serde_json::to_value(&score).unwrap(), before);
8079        apply_command(&grace(0), &mut score).unwrap();
8080        assert!(score.parts[0].staves[0].measures[0].voices[0][0].is_grace);
8081
8082        let placement = |offset_x| {
8083            Command::SetNotePlacement(SetNotePlacementCmd {
8084                part_index: 0,
8085                staff_index: 0,
8086                measure_index: 0,
8087                voice: 0,
8088                note_index: 0,
8089                offset_x,
8090                offset_y: None,
8091                relative_x: None,
8092                relative_y: None,
8093            })
8094        };
8095        let before = serde_json::to_value(&score).unwrap();
8096        assert!(apply_command(&placement(Some(f64::NAN)), &mut score).is_err());
8097        assert_eq!(serde_json::to_value(&score).unwrap(), before);
8098        apply_command(&placement(Some(1.5)), &mut score).unwrap();
8099        assert_eq!(
8100            score.parts[0].staves[0].measures[0].voices[0][0].offset_x,
8101            Some(1.5)
8102        );
8103        apply_command(&placement(None), &mut score).unwrap();
8104        assert_eq!(
8105            score.parts[0].staves[0].measures[0].voices[0][0].offset_x,
8106            None
8107        );
8108
8109        let cross_staff = |target_staff| {
8110            Command::SetCrossStaff(SetCrossStaffCmd {
8111                part_index: 0,
8112                staff_index: 0,
8113                measure_index: 0,
8114                voice: 0,
8115                note_index: 0,
8116                placement: Some(CrossStaff {
8117                    target_staff,
8118                    target_voice: None,
8119                }),
8120            })
8121        };
8122        let before = serde_json::to_value(&score).unwrap();
8123        assert!(apply_command(&cross_staff(0), &mut score).is_err());
8124        assert!(apply_command(&cross_staff(2), &mut score).is_err());
8125        assert_eq!(serde_json::to_value(&score).unwrap(), before);
8126    }
8127
8128    #[test]
8129    fn editor_command_metadata_survives_json_roundtrip() {
8130        let commands = [
8131            (
8132                serde_json::json!({"type":"set_tempo", "bpm":96}),
8133                "SetTempo",
8134            ),
8135            (
8136                serde_json::json!({"type":"add_measure", "after_index":0}),
8137                "AddMeasure",
8138            ),
8139            (
8140                serde_json::json!({"type":"delete_measure", "measure_index":0}),
8141                "DeleteMeasure",
8142            ),
8143            (
8144                serde_json::json!({"type":"set_key_signature", "fifths":2}),
8145                "SetKeySignature",
8146            ),
8147            (
8148                serde_json::json!({"type":"set_time_signature", "numerator":3,"denominator":4}),
8149                "SetTimeSignature",
8150            ),
8151            (
8152                serde_json::json!({"type":"set_rehearsal_mark", "measure_index":0,"text":"A"}),
8153                "SetRehearsalMark",
8154            ),
8155            (
8156                serde_json::json!({"type":"set_navigation_mark", "measure_index":0,"mark":"Coda"}),
8157                "SetNavigationMark",
8158            ),
8159            (
8160                serde_json::json!({"type":"set_figured_bass", "measure_index":0,"figures":[]}),
8161                "SetFiguredBass",
8162            ),
8163            (
8164                serde_json::json!({"type":"set_multi_rest", "measure_index":0,"count":2}),
8165                "SetMultiRest",
8166            ),
8167            (
8168                serde_json::json!({"type":"set_volta", "measure_index":0,"volta":null}),
8169                "SetVolta",
8170            ),
8171            (
8172                serde_json::json!({"type":"set_clef", "part_index":0,"staff_index":0,"clef":"Treble"}),
8173                "SetClef",
8174            ),
8175            (
8176                serde_json::json!({"type":"set_part_name", "part_index":0,"name":"Piano","short_name":"Pno."}),
8177                "SetPartName",
8178            ),
8179            (
8180                serde_json::json!({"type":"set_midi_instrument", "part_index":0,"midi_channel":0,"midi_program":1}),
8181                "SetMidiInstrument",
8182            ),
8183            (
8184                serde_json::json!({"type":"set_percussion_kit", "part_index":0,"instruments":[]}),
8185                "SetPercussionKit",
8186            ),
8187            (
8188                serde_json::json!({"type":"set_instrument_definition", "part_index":0,"definition":null}),
8189                "SetInstrumentDefinition",
8190            ),
8191            (
8192                serde_json::json!({"type":"set_measure_instrument_change", "part_index":0,"staff_index":0,"measure_index":0,"definition":null}),
8193                "SetMeasureInstrumentChange",
8194            ),
8195            (
8196                serde_json::json!({"type":"set_measure_tablature_change", "part_index":0,"staff_index":0,"measure_index":0,"config":null}),
8197                "SetMeasureTablatureChange",
8198            ),
8199            (
8200                serde_json::json!({"type":"set_mid_measure_clefs", "part_index":0,"staff_index":0,"measure_index":0,"clefs":[]}),
8201                "SetMidMeasureClefs",
8202            ),
8203            (
8204                serde_json::json!({"type":"remove_score_view", "id":"part"}),
8205                "RemoveScoreView",
8206            ),
8207            (
8208                serde_json::json!({"type":"set_transpose", "part_index":0,"staff_index":0,"semitones":-2}),
8209                "SetTranspose",
8210            ),
8211            (
8212                serde_json::json!({"type":"set_tempo_at_measure", "measure_index":0,"bpm":100}),
8213                "SetTempoAtMeasure",
8214            ),
8215            (
8216                serde_json::json!({"type":"set_tempo_ramp_at_measure", "measure_index":0,"target_bpm":120}),
8217                "SetTempoRampAtMeasure",
8218            ),
8219            (
8220                serde_json::json!({"type":"set_system_break", "measure_index":0,"value":true}),
8221                "SetSystemBreak",
8222            ),
8223            (
8224                serde_json::json!({"type":"set_page_break", "measure_index":0,"value":true}),
8225                "SetPageBreak",
8226            ),
8227            (
8228                serde_json::json!({"type":"set_section_break", "measure_index":0,"value":true}),
8229                "SetSectionBreak",
8230            ),
8231            (
8232                serde_json::json!({"type":"set_part_group", "group":null}),
8233                "SetPartGroup",
8234            ),
8235            (
8236                serde_json::json!({"type":"remove_spanner", "id":"s1"}),
8237                "RemoveSpanner",
8238            ),
8239            (
8240                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}),
8241                "SetGrace",
8242            ),
8243            (
8244                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}),
8245                "SetOttava",
8246            ),
8247            (
8248                serde_json::json!({"type":"set_lyric", "part_index":0,"staff_index":0,"measure_index":0,"voice":0,"note_index":0,"lyric":null}),
8249                "SetLyric",
8250            ),
8251            (
8252                serde_json::json!({"type":"set_chord_symbol", "part_index":0,"staff_index":0,"measure_index":0,"voice":0,"note_index":0,"chord":null}),
8253                "SetChordSymbol",
8254            ),
8255            (
8256                serde_json::json!({"type":"set_harmony_range", "part_index":0,"staff_index":0,"measure_index":0,"voice":0,"note_index":0,"end":null}),
8257                "SetHarmonyRange",
8258            ),
8259            (
8260                serde_json::json!({"type":"set_harp_pedal_diagrams", "part_index":0,"staff_index":0,"measure_index":0,"diagrams":[]}),
8261                "SetHarpPedalDiagrams",
8262            ),
8263            (
8264                serde_json::json!({"type":"paste_voice", "part_index":0,"staff_index":0,"measure_index":0,"voice_index":0,"notes":[]}),
8265                "PasteVoice",
8266            ),
8267            (
8268                serde_json::json!({"type":"paste_range", "part_index":0,"staff_index":0,"voice_index":0,"target_measure":0,"measures":[]}),
8269                "PasteRange",
8270            ),
8271            (
8272                serde_json::json!({"type":"exchange_voices", "part_index":0,"staff_index":0,"start_measure":0,"end_measure":0,"first_voice":0,"second_voice":1}),
8273                "ExchangeVoices",
8274            ),
8275            (
8276                serde_json::json!({"type":"split_measure", "measure_index":0,"split_at_beats":2.0}),
8277                "SplitMeasure",
8278            ),
8279            (
8280                serde_json::json!({"type":"join_measures", "measure_index":0}),
8281                "JoinMeasures",
8282            ),
8283            (
8284                serde_json::json!({"type":"implode_staves", "part_index":0,"source_staves":[0,1],"target_staff":0,"start_measure":0,"end_measure":0}),
8285                "ImplodeStaves",
8286            ),
8287            (
8288                serde_json::json!({"type":"explode_voices", "part_index":0,"source_staff":0,"target_staves":[0,1],"start_measure":0,"end_measure":0}),
8289                "ExplodeVoices",
8290            ),
8291            (
8292                serde_json::json!({"type":"explode_chord_pitches", "part_index":0,"source_staff":0,"target_staves":[0,1],"start_measure":0,"end_measure":0}),
8293                "ExplodeChordPitches",
8294            ),
8295            (
8296                serde_json::json!({"type":"scale_voice_range", "part_index":0,"staff_index":0,"voice":0,"start_measure":0,"end_measure":0,"scale":"half"}),
8297                "ScaleVoiceRange",
8298            ),
8299            (
8300                serde_json::json!({"type":"add_staff", "part_index":0,"clef":"Bass"}),
8301                "AddStaff",
8302            ),
8303            (
8304                serde_json::json!({"type":"delete_staff", "part_index":0,"staff_index":1}),
8305                "DeleteStaff",
8306            ),
8307            (
8308                serde_json::json!({"type":"set_tuplet", "part_index":0,"staff_index":0,"measure_index":0,"voice_index":0,"note_index":0,"tuplet":null}),
8309                "SetTuplet",
8310            ),
8311            (
8312                serde_json::json!({"type":"set_stem", "part_index":0,"staff_index":0,"measure_index":0,"voice_index":0,"note_index":0,"stem_up":true}),
8313                "SetStem",
8314            ),
8315        ];
8316        for (value, expected_key) in commands {
8317            let command: Command = serde_json::from_value(value).unwrap();
8318            let restored: Command =
8319                serde_json::from_slice(&serde_json::to_vec(&command).unwrap()).unwrap();
8320            assert_eq!(command_key(&restored), expected_key);
8321            assert!(!command_label(&restored).is_empty(), "{expected_key}");
8322            let before = command_hint(&command);
8323            let after = command_hint(&restored);
8324            assert_eq!(after.scope, before.scope);
8325            assert_eq!(after.layout_dirty, before.layout_dirty);
8326            assert_eq!(after.playback_dirty, before.playback_dirty);
8327        }
8328    }
8329}