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_end = false;
3147    }
3148    voice[cmd.start_note_idx].hairpin_start = Some(cmd.kind);
3149    voice[cmd.end_note_idx].hairpin_end = true;
3150    Ok(())
3151}
3152
3153fn apply_add_pedal(cmd: &AddPedalCmd, score: &mut Score) -> Result<(), Error> {
3154    let voice = score
3155        .parts
3156        .get_mut(cmd.part_index)
3157        .ok_or(Error::PartNotFound(cmd.part_index))?
3158        .staves
3159        .get_mut(cmd.staff_index)
3160        .ok_or(Error::StaffNotFound(cmd.staff_index))?
3161        .measures
3162        .get_mut(cmd.measure_index)
3163        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
3164        .voices
3165        .get_mut(cmd.voice)
3166        .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
3167    if cmd.start_note_idx >= voice.len() {
3168        return Err(Error::NoteNotFound(cmd.start_note_idx));
3169    }
3170    if cmd.end_note_idx >= voice.len() {
3171        return Err(Error::NoteNotFound(cmd.end_note_idx));
3172    }
3173    if cmd.start_note_idx >= cmd.end_note_idx {
3174        return Err(Error::InvalidCommand(
3175            "start_note_idx must be less than end_note_idx".into(),
3176        ));
3177    }
3178    for note in voice
3179        .iter_mut()
3180        .take(cmd.end_note_idx + 1)
3181        .skip(cmd.start_note_idx)
3182    {
3183        note.pedal_start = false;
3184        note.pedal_end = false;
3185    }
3186    voice[cmd.start_note_idx].pedal_start = true;
3187    voice[cmd.end_note_idx].pedal_end = true;
3188    Ok(())
3189}
3190
3191fn apply_toggle_tie(cmd: &ToggleTieCmd, score: &mut Score) -> Result<(), Error> {
3192    let current_tie_start = {
3193        let v = score
3194            .parts
3195            .get(cmd.part_index)
3196            .ok_or(Error::PartNotFound(cmd.part_index))?
3197            .staves
3198            .get(cmd.staff_index)
3199            .ok_or(Error::StaffNotFound(cmd.staff_index))?
3200            .measures
3201            .get(cmd.measure_index)
3202            .ok_or(Error::MeasureNotFound(cmd.measure_index))?
3203            .voices
3204            .get(cmd.voice)
3205            .ok_or(Error::VoiceOutOfRange(cmd.voice))?;
3206        v.get(cmd.note_index)
3207            .ok_or(Error::NoteNotFound(cmd.note_index))?
3208            .tie_start
3209    };
3210    let voice_len = score.parts[cmd.part_index].staves[cmd.staff_index].measures[cmd.measure_index]
3211        .voices[cmd.voice]
3212        .len();
3213    let total_measures = score.parts[cmd.part_index].staves[cmd.staff_index]
3214        .measures
3215        .len();
3216
3217    let new_tie = !current_tie_start;
3218    score.parts[cmd.part_index].staves[cmd.staff_index].measures[cmd.measure_index].voices
3219        [cmd.voice][cmd.note_index]
3220        .tie_start = new_tie;
3221
3222    if cmd.note_index + 1 < voice_len {
3223        score.parts[cmd.part_index].staves[cmd.staff_index].measures[cmd.measure_index].voices
3224            [cmd.voice][cmd.note_index + 1]
3225            .tie_end = new_tie;
3226    } else {
3227        let next_mi = cmd.measure_index + 1;
3228        if next_mi < total_measures {
3229            let next_voice = &mut score.parts[cmd.part_index].staves[cmd.staff_index].measures
3230                [next_mi]
3231                .voices[cmd.voice];
3232            if let Some(n) = next_voice.get_mut(0) {
3233                n.tie_end = new_tie;
3234            }
3235        }
3236    }
3237    Ok(())
3238}
3239
3240fn apply_set_dynamic(cmd: &SetDynamicCmd, score: &mut Score) -> Result<(), Error> {
3241    get_note_mut(
3242        score,
3243        cmd.part_index,
3244        cmd.staff_index,
3245        cmd.measure_index,
3246        cmd.voice,
3247        cmd.note_index,
3248    )?
3249    .dynamic = cmd.dynamic;
3250    Ok(())
3251}
3252
3253fn apply_toggle_articulation(cmd: &ToggleArticulationCmd, score: &mut Score) -> Result<(), Error> {
3254    let note = get_note_mut(
3255        score,
3256        cmd.part_index,
3257        cmd.staff_index,
3258        cmd.measure_index,
3259        cmd.voice,
3260        cmd.note_index,
3261    )?;
3262    if let Some(pos) = note
3263        .articulations
3264        .iter()
3265        .position(|a| a == &cmd.articulation)
3266    {
3267        note.articulations.remove(pos);
3268    } else {
3269        note.articulations.push(cmd.articulation.clone());
3270    }
3271    Ok(())
3272}
3273
3274fn apply_set_time_signature(cmd: &SetTimeSignatureCmd, score: &mut Score) -> Result<(), Error> {
3275    if cmd.numerator == 0 || cmd.denominator == 0 {
3276        return Err(Error::InvalidCommand(
3277            "time signature numerator and denominator must be > 0".into(),
3278        ));
3279    }
3280    if ![1u8, 2, 4, 8, 16, 32].contains(&cmd.denominator) {
3281        return Err(Error::InvalidCommand(format!(
3282            "invalid time signature denominator: {}",
3283            cmd.denominator
3284        )));
3285    }
3286    score.settings.time_signature = TimeSignature {
3287        numerator: cmd.numerator,
3288        denominator: cmd.denominator,
3289    };
3290    let max_beats = score.settings.time_signature.total_beats();
3291    for part in &mut score.parts {
3292        for staff in &mut part.staves {
3293            for measure in &mut staff.measures {
3294                // A pickup or irregular bar keeps its authored length.
3295                let beats = measure
3296                    .actual_length
3297                    .and_then(|length| length.beats())
3298                    .unwrap_or(max_beats);
3299                for voice in &mut measure.voices {
3300                    trim_voice_to_measure(voice, beats);
3301                    pad_voice_to_measure(voice, beats);
3302                }
3303            }
3304        }
3305    }
3306    Ok(())
3307}
3308
3309fn apply_set_barline(cmd: &SetBarlineCmd, score: &mut Score) -> Result<(), Error> {
3310    for part in &mut score.parts {
3311        for staff in &mut part.staves {
3312            let measure = staff
3313                .measures
3314                .get_mut(cmd.measure_index)
3315                .ok_or(Error::MeasureNotFound(cmd.measure_index))?;
3316            match cmd.side.as_str() {
3317                "left" => measure.barline_left = cmd.barline.clone(),
3318                "right" => measure.barline_right = cmd.barline.clone(),
3319                _ => {
3320                    return Err(Error::InvalidCommand(format!(
3321                        "invalid barline side: '{}'",
3322                        cmd.side
3323                    )));
3324                }
3325            }
3326        }
3327    }
3328    Ok(())
3329}
3330
3331fn apply_add_part(cmd: &AddPartCmd, score: &mut Score) -> Result<(), Error> {
3332    if cmd.clefs.is_empty() {
3333        return Err(Error::InvalidCommand(
3334            "AddPart requires at least one clef".into(),
3335        ));
3336    }
3337    let measure_count = score.measure_count();
3338    let ts = score.settings.time_signature.clone();
3339    let mut part = Part::new(&cmd.name, &cmd.short_name);
3340    part.midi_channel = cmd.midi_channel.min(15);
3341    part.midi_program = cmd.midi_program;
3342    for clef_str in &cmd.clefs {
3343        let clef = Clef::from_name(clef_str).unwrap_or(Clef::Treble);
3344        let mut staff = Staff::new(clef);
3345        for i in 0..measure_count {
3346            let mut m = Measure::empty(ts.numerator, ts.denominator);
3347            m.number = i as u32 + 1;
3348            staff.measures.push(m);
3349        }
3350        part.staves.push(staff);
3351    }
3352    score.parts.push(part);
3353    Ok(())
3354}
3355
3356fn apply_set_clef(cmd: &SetClefCmd, score: &mut Score) -> Result<(), Error> {
3357    score
3358        .parts
3359        .get_mut(cmd.part_index)
3360        .ok_or(Error::PartNotFound(cmd.part_index))?
3361        .staves
3362        .get_mut(cmd.staff_index)
3363        .ok_or(Error::StaffNotFound(cmd.staff_index))?
3364        .clef = cmd.clef.clone();
3365    Ok(())
3366}
3367
3368fn apply_set_part_name(cmd: &SetPartNameCmd, score: &mut Score) -> Result<(), Error> {
3369    let part = score
3370        .parts
3371        .get_mut(cmd.part_index)
3372        .ok_or(Error::PartNotFound(cmd.part_index))?;
3373    part.name = cmd.name.clone();
3374    part.short_name = cmd.short_name.clone();
3375    Ok(())
3376}
3377
3378fn apply_delete_part(cmd: &DeletePartCmd, score: &mut Score) -> Result<(), Error> {
3379    if cmd.part_index >= score.parts.len() {
3380        return Err(Error::PartNotFound(cmd.part_index));
3381    }
3382    score.parts.remove(cmd.part_index);
3383    remap_spanners(score, |address| {
3384        if address.part == cmd.part_index {
3385            None
3386        } else if address.part > cmd.part_index {
3387            let mut shifted = address.clone();
3388            shifted.part -= 1;
3389            Some(shifted)
3390        } else {
3391            Some(address.clone())
3392        }
3393    });
3394    Ok(())
3395}
3396
3397fn apply_reorder_parts(cmd: &ReorderPartsCmd, score: &mut Score) -> Result<(), Error> {
3398    let count = score.parts.len();
3399    if cmd.order.len() != count {
3400        return Err(Error::InvalidCommand(format!(
3401            "part order must contain exactly {count} entries"
3402        )));
3403    }
3404    let mut old_to_new = vec![usize::MAX; count];
3405    for (new_index, &old_index) in cmd.order.iter().enumerate() {
3406        if old_index >= count {
3407            return Err(Error::PartNotFound(old_index));
3408        }
3409        if std::mem::replace(&mut old_to_new[old_index], new_index) != usize::MAX {
3410            return Err(Error::InvalidCommand(format!(
3411                "part order contains duplicate index {old_index}"
3412            )));
3413        }
3414    }
3415    for view in &score.views {
3416        for &part in &view.parts {
3417            if part >= count {
3418                return Err(Error::PartNotFound(part));
3419            }
3420        }
3421        for override_ in &view.staff_kind_overrides {
3422            if override_.staff.part >= count {
3423                return Err(Error::PartNotFound(override_.staff.part));
3424            }
3425        }
3426    }
3427    for spanner in &score.spanners {
3428        for address in [&spanner.start, &spanner.end] {
3429            if address.part >= count {
3430                return Err(Error::PartNotFound(address.part));
3431            }
3432        }
3433    }
3434    for group in &score.part_groups {
3435        if group.first_part > group.last_part || group.last_part >= count {
3436            return Err(Error::InvalidCommand(
3437                "part group references an invalid part range".into(),
3438            ));
3439        }
3440        let mut remapped: Vec<_> = (group.first_part..=group.last_part)
3441            .map(|part| old_to_new[part])
3442            .collect();
3443        remapped.sort_unstable();
3444        if remapped
3445            .windows(2)
3446            .any(|pair| pair[1] != pair[0].saturating_add(1))
3447        {
3448            return Err(Error::InvalidCommand(
3449                "part order would split an existing part group".into(),
3450            ));
3451        }
3452    }
3453
3454    let existing = std::mem::take(&mut score.parts);
3455    score.parts = cmd
3456        .order
3457        .iter()
3458        .map(|&old_index| existing[old_index].clone())
3459        .collect();
3460    for view in &mut score.views {
3461        for part in &mut view.parts {
3462            *part = old_to_new[*part];
3463        }
3464        for override_ in &mut view.staff_kind_overrides {
3465            override_.staff.part = old_to_new[override_.staff.part];
3466        }
3467    }
3468    for group in &mut score.part_groups {
3469        group.first_part = old_to_new[group.first_part];
3470        group.last_part = old_to_new[group.last_part];
3471        if group.first_part > group.last_part {
3472            std::mem::swap(&mut group.first_part, &mut group.last_part);
3473        }
3474    }
3475    remap_spanners(score, |address| {
3476        let mut remapped = address.clone();
3477        remapped.part = old_to_new[address.part];
3478        Some(remapped)
3479    });
3480    Ok(())
3481}
3482
3483fn apply_set_metadata(cmd: &SetMetadataCmd, score: &mut Score) -> Result<(), Error> {
3484    if let Some(v) = &cmd.title {
3485        score.metadata.title = v.clone();
3486    }
3487    if let Some(v) = &cmd.composer {
3488        score.metadata.composer = v.clone();
3489    }
3490    if let Some(v) = &cmd.lyricist {
3491        score.metadata.lyricist = v.clone();
3492    }
3493    if let Some(v) = &cmd.copyright {
3494        score.metadata.copyright = v.clone();
3495    }
3496    if let Some(v) = &cmd.work_number {
3497        score.metadata.work_number = v.clone();
3498    }
3499    if let Some(v) = &cmd.movement_title {
3500        score.metadata.movement_title = v.clone();
3501    }
3502    Ok(())
3503}
3504
3505fn apply_set_midi_instrument(cmd: &SetMidiInstrumentCmd, score: &mut Score) -> Result<(), Error> {
3506    let part = score
3507        .parts
3508        .get_mut(cmd.part_index)
3509        .ok_or(Error::PartNotFound(cmd.part_index))?;
3510    part.midi_channel = cmd.midi_channel.min(15);
3511    part.midi_program = cmd.midi_program;
3512    Ok(())
3513}
3514
3515fn apply_set_percussion_kit(cmd: &SetPercussionKitCmd, score: &mut Score) -> Result<(), Error> {
3516    validate_percussion_kit(&cmd.instruments)?;
3517    let part = score
3518        .parts
3519        .get_mut(cmd.part_index)
3520        .ok_or(Error::PartNotFound(cmd.part_index))?;
3521    part.percussion_instruments = cmd.instruments.clone();
3522    Ok(())
3523}
3524
3525fn apply_set_instrument_definition(
3526    cmd: &SetInstrumentDefinitionCmd,
3527    score: &mut Score,
3528) -> Result<(), Error> {
3529    if let Some(definition) = &cmd.definition {
3530        validate_instrument_definition(definition)?;
3531    }
3532    let part = score
3533        .parts
3534        .get_mut(cmd.part_index)
3535        .ok_or(Error::PartNotFound(cmd.part_index))?;
3536    part.instrument = cmd.definition.clone();
3537    Ok(())
3538}
3539
3540fn apply_set_measure_instrument_change(
3541    cmd: &SetMeasureInstrumentChangeCmd,
3542    score: &mut Score,
3543) -> Result<(), Error> {
3544    if let Some(definition) = &cmd.definition {
3545        validate_instrument_definition(definition)?;
3546    }
3547    score
3548        .parts
3549        .get_mut(cmd.part_index)
3550        .ok_or(Error::PartNotFound(cmd.part_index))?
3551        .staves
3552        .get_mut(cmd.staff_index)
3553        .ok_or(Error::StaffNotFound(cmd.staff_index))?
3554        .measures
3555        .get_mut(cmd.measure_index)
3556        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
3557        .instrument_change = cmd.definition.clone();
3558    Ok(())
3559}
3560
3561fn apply_set_measure_tablature_change(
3562    cmd: &SetMeasureTablatureChangeCmd,
3563    score: &mut Score,
3564) -> Result<(), Error> {
3565    let staff = score
3566        .parts
3567        .get_mut(cmd.part_index)
3568        .ok_or(Error::PartNotFound(cmd.part_index))?
3569        .staves
3570        .get_mut(cmd.staff_index)
3571        .ok_or(Error::StaffNotFound(cmd.staff_index))?;
3572    let base = staff.tablature.as_ref().ok_or_else(|| {
3573        Error::InvalidCommand("tablature change requires a staff tablature configuration".into())
3574    })?;
3575    if let Some(config) = &cmd.config {
3576        validate_tablature_config(config)?;
3577        if config.lines != base.lines {
3578            return Err(Error::InvalidCommand(format!(
3579                "tablature change line count {} must match staff line count {}",
3580                config.lines, base.lines
3581            )));
3582        }
3583    }
3584    staff
3585        .measures
3586        .get_mut(cmd.measure_index)
3587        .ok_or(Error::MeasureNotFound(cmd.measure_index))?
3588        .tablature_change = cmd.config.clone();
3589    Ok(())
3590}
3591
3592fn apply_set_mid_measure_clefs(
3593    cmd: &SetMidMeasureClefsCmd,
3594    score: &mut Score,
3595) -> Result<(), Error> {
3596    let time_signature = score
3597        .parts
3598        .get(cmd.part_index)
3599        .and_then(|part| part.staves.get(cmd.staff_index))
3600        .map_or_else(
3601            || score.settings.time_signature.clone(),
3602            |staff| {
3603                staff
3604                    .meter_at(cmd.measure_index, &score.settings.time_signature)
3605                    .clone()
3606            },
3607        );
3608    let measure = score
3609        .parts
3610        .get_mut(cmd.part_index)
3611        .ok_or(Error::PartNotFound(cmd.part_index))?
3612        .staves
3613        .get_mut(cmd.staff_index)
3614        .ok_or(Error::StaffNotFound(cmd.staff_index))?
3615        .measures
3616        .get_mut(cmd.measure_index)
3617        .ok_or(Error::MeasureNotFound(cmd.measure_index))?;
3618    let length = measure.duration_beats(&time_signature);
3619    let mut previous = 0.0;
3620    for change in &cmd.clefs {
3621        match change.offset.beats() {
3622            Some(offset) if offset > previous + 1e-9 && offset < length - 1e-9 => {
3623                previous = offset;
3624            }
3625            _ => {
3626                return Err(Error::InvalidCommand(
3627                    "mid-measure clefs must lie strictly inside the measure in increasing order"
3628                        .into(),
3629                ));
3630            }
3631        }
3632    }
3633    measure.mid_clefs = cmd.clefs.clone();
3634    Ok(())
3635}
3636
3637fn validate_tablature_config(config: &TablatureConfig) -> Result<(), Error> {
3638    if !(1..=64).contains(&config.lines) {
3639        return Err(Error::InvalidCommand(format!(
3640            "tablature line count {} is outside 1..=64",
3641            config.lines
3642        )));
3643    }
3644    if config.tuning_midi.len() > usize::from(config.lines) {
3645        return Err(Error::InvalidCommand(format!(
3646            "tablature has {} tunings for {} lines",
3647            config.tuning_midi.len(),
3648            config.lines
3649        )));
3650    }
3651    if let Some(midi) = config
3652        .tuning_midi
3653        .iter()
3654        .copied()
3655        .find(|midi| !(0..=127).contains(midi))
3656    {
3657        return Err(Error::InvalidCommand(format!(
3658            "tablature tuning MIDI {midi} is outside 0..=127"
3659        )));
3660    }
3661    Ok(())
3662}
3663
3664fn validate_percussion_kit(instruments: &[PercussionInstrument]) -> Result<(), Error> {
3665    let mut ids = std::collections::HashSet::new();
3666    for instrument in instruments {
3667        if instrument.id.trim().is_empty() {
3668            return Err(Error::InvalidCommand(
3669                "percussion instrument id must not be empty".into(),
3670            ));
3671        }
3672        if !ids.insert(instrument.id.as_str()) {
3673            return Err(Error::InvalidCommand(format!(
3674                "percussion instrument id '{}' is duplicated",
3675                instrument.id
3676            )));
3677        }
3678        if let Some(position) = instrument.staff_position
3679            && !(-32..=32).contains(&position)
3680        {
3681            return Err(Error::InvalidCommand(format!(
3682                "percussion staff position {position} is outside -32..=32"
3683            )));
3684        }
3685        if let Some(voice) = instrument.preferred_voice
3686            && !(1..=4).contains(&voice)
3687        {
3688            return Err(Error::InvalidCommand(format!(
3689                "percussion preferred voice {voice} is outside 1..=4"
3690            )));
3691        }
3692        if instrument
3693            .techniques
3694            .iter()
3695            .any(|technique| technique.trim().is_empty() || technique.len() > 128)
3696        {
3697            return Err(Error::InvalidCommand(
3698                "percussion techniques must be non-empty and at most 128 bytes".into(),
3699            ));
3700        }
3701    }
3702    Ok(())
3703}
3704
3705fn validate_instrument_definition(definition: &InstrumentDefinition) -> Result<(), Error> {
3706    if definition.id.trim().is_empty() {
3707        return Err(Error::InvalidCommand(
3708            "instrument definition id must not be empty".into(),
3709        ));
3710    }
3711    if !(1..=64).contains(&definition.staff_count) {
3712        return Err(Error::InvalidCommand(format!(
3713            "instrument staff count {} is outside 1..=64",
3714            definition.staff_count
3715        )));
3716    }
3717    if definition.midi_channel > 15 {
3718        return Err(Error::InvalidCommand(format!(
3719            "instrument MIDI channel {} is outside 0..=15",
3720            definition.midi_channel
3721        )));
3722    }
3723    for (name, range) in [
3724        ("written", definition.written_range),
3725        ("sounding", definition.sounding_range),
3726    ] {
3727        if let Some(InstrumentRange { lowest, highest }) = range
3728            && lowest > highest
3729        {
3730            return Err(Error::InvalidCommand(format!(
3731                "instrument {name} range has lowest MIDI note {lowest} above highest {highest}"
3732            )));
3733        }
3734    }
3735    Ok(())
3736}
3737
3738fn apply_upsert_score_view(cmd: &UpsertScoreViewCmd, score: &mut Score) -> Result<(), Error> {
3739    validate_score_view(&cmd.view, score)?;
3740    if let Some(index) = score.views.iter().position(|view| view.id == cmd.view.id) {
3741        score.views[index] = cmd.view.clone();
3742    } else {
3743        score.views.push(cmd.view.clone());
3744    }
3745    Ok(())
3746}
3747
3748fn apply_remove_score_view(cmd: &RemoveScoreViewCmd, score: &mut Score) -> Result<(), Error> {
3749    let index = score
3750        .views
3751        .iter()
3752        .position(|view| view.id == cmd.id)
3753        .ok_or_else(|| Error::InvalidCommand(format!("score view '{}' does not exist", cmd.id)))?;
3754    score.views.remove(index);
3755    Ok(())
3756}
3757
3758fn validate_score_view(view: &ScoreView, score: &Score) -> Result<(), Error> {
3759    if view.id.trim().is_empty() || view.name.trim().is_empty() {
3760        return Err(Error::InvalidCommand(
3761            "score view id and name must not be empty".into(),
3762        ));
3763    }
3764    if view.parts.is_empty() {
3765        return Err(Error::InvalidCommand(
3766            "score view must select at least one part".into(),
3767        ));
3768    }
3769    let mut selected = vec![false; score.parts.len()];
3770    for &part in &view.parts {
3771        if part >= score.parts.len() {
3772            return Err(Error::PartNotFound(part));
3773        }
3774        if std::mem::replace(&mut selected[part], true) {
3775            return Err(Error::InvalidCommand(format!(
3776                "score view '{}' selects part {part} more than once",
3777                view.id
3778            )));
3779        }
3780    }
3781    if view.layout.measures_per_row.is_some_and(|value| value == 0) {
3782        return Err(Error::InvalidCommand(
3783            "score view measures per row must be greater than zero".into(),
3784        ));
3785    }
3786    if view
3787        .layout
3788        .typed_style_overrides
3789        .iter()
3790        .any(|override_| !override_.value.is_finite() || !(0.05..=64.0).contains(&override_.value))
3791    {
3792        return Err(Error::InvalidCommand(
3793            "score view typed style override values must be finite and within 0.05..=64".into(),
3794        ));
3795    }
3796    for reference in &view.layout.hidden_staves {
3797        let Some(part) = score.parts.get(reference.part) else {
3798            return Err(Error::PartNotFound(reference.part));
3799        };
3800        if reference.staff >= part.staves.len() {
3801            return Err(Error::StaffNotFound(reference.staff));
3802        }
3803        if !selected[reference.part] {
3804            return Err(Error::InvalidCommand(
3805                "score view cannot hide a staff outside its selected parts".into(),
3806            ));
3807        }
3808    }
3809    let mut overridden = std::collections::HashSet::new();
3810    for override_ in &view.staff_kind_overrides {
3811        let reference = override_.staff;
3812        let Some(part) = score.parts.get(reference.part) else {
3813            return Err(Error::PartNotFound(reference.part));
3814        };
3815        if reference.staff >= part.staves.len() {
3816            return Err(Error::StaffNotFound(reference.staff));
3817        }
3818        if !selected[reference.part] {
3819            return Err(Error::InvalidCommand(
3820                "score view cannot override a staff outside its selected parts".into(),
3821            ));
3822        }
3823        if !overridden.insert((reference.part, reference.staff)) {
3824            return Err(Error::InvalidCommand(
3825                "score view may override each staff kind at most once".into(),
3826            ));
3827        }
3828        if override_.kind == StaffKind::Tablature
3829            && part.staves[reference.staff].tablature.is_none()
3830        {
3831            return Err(Error::InvalidCommand(
3832                "tablature score view requires a tablature configuration".into(),
3833            ));
3834        }
3835    }
3836    let measure_count = score.measure_count();
3837    for &break_index in view
3838        .layout
3839        .system_breaks
3840        .iter()
3841        .chain(view.layout.page_breaks.iter())
3842    {
3843        if break_index >= measure_count {
3844            return Err(Error::InvalidCommand(format!(
3845                "score view break measure {break_index} is out of range"
3846            )));
3847        }
3848    }
3849    for (key, value) in &view.layout.style_overrides {
3850        if key.trim().is_empty() || value.len() > 4096 {
3851            return Err(Error::InvalidCommand(
3852                "score view style overrides need a non-empty key and a value of at most 4096 bytes"
3853                    .into(),
3854            ));
3855        }
3856    }
3857    Ok(())
3858}
3859
3860fn apply_set_transpose(cmd: &SetTransposeCmd, score: &mut Score) -> Result<(), Error> {
3861    score
3862        .parts
3863        .get_mut(cmd.part_index)
3864        .ok_or(Error::PartNotFound(cmd.part_index))?
3865        .staves
3866        .get_mut(cmd.staff_index)
3867        .ok_or(Error::StaffNotFound(cmd.staff_index))?
3868        .transpose_semitones = cmd.semitones;
3869    Ok(())
3870}
3871
3872fn apply_transpose_staff_region(
3873    cmd: &TransposeStaffRegionCmd,
3874    score: &mut Score,
3875) -> Result<(), Error> {
3876    *score = transpose_staff_region_checked(
3877        score,
3878        cmd.part_index,
3879        cmd.staff_index,
3880        cmd.start_measure,
3881        cmd.end_measure,
3882        cmd.semitones,
3883        cmd.target,
3884    )?;
3885    Ok(())
3886}
3887
3888fn apply_exchange_voices(cmd: &ExchangeVoicesCmd, score: &mut Score) -> Result<(), Error> {
3889    if cmd.first_voice >= 4 {
3890        return Err(Error::VoiceOutOfRange(cmd.first_voice));
3891    }
3892    if cmd.second_voice >= 4 {
3893        return Err(Error::VoiceOutOfRange(cmd.second_voice));
3894    }
3895    if cmd.first_voice == cmd.second_voice {
3896        return Err(Error::InvalidCommand(
3897            "exchange voices requires two distinct voices".into(),
3898        ));
3899    }
3900    let start = cmd.start_measure.min(cmd.end_measure);
3901    let end = cmd.start_measure.max(cmd.end_measure);
3902    let staff = score
3903        .parts
3904        .get_mut(cmd.part_index)
3905        .ok_or(Error::PartNotFound(cmd.part_index))?
3906        .staves
3907        .get_mut(cmd.staff_index)
3908        .ok_or(Error::StaffNotFound(cmd.staff_index))?;
3909    if end >= staff.measures.len() {
3910        return Err(Error::MeasureNotFound(end));
3911    }
3912    for measure in &mut staff.measures[start..=end] {
3913        measure.voices.swap(cmd.first_voice, cmd.second_voice);
3914        measure
3915            .source_voice_numbers
3916            .swap(cmd.first_voice, cmd.second_voice);
3917    }
3918    remap_spanners(score, |address| {
3919        if address.part != cmd.part_index
3920            || address.staff != cmd.staff_index
3921            || address.measure < start
3922            || address.measure > end
3923        {
3924            return Some(address.clone());
3925        }
3926        let mut remapped = address.clone();
3927        if address.voice == cmd.first_voice {
3928            remapped.voice = cmd.second_voice;
3929        } else if address.voice == cmd.second_voice {
3930            remapped.voice = cmd.first_voice;
3931        }
3932        Some(remapped)
3933    });
3934    Ok(())
3935}
3936
3937fn apply_move_or_copy_voice_range(
3938    cmd: &MoveOrCopyVoiceRangeCmd,
3939    score: &mut Score,
3940) -> Result<(), Error> {
3941    if cmd.source_start.part != cmd.source_end.part
3942        || cmd.source_start.staff != cmd.source_end.staff
3943        || cmd.source_start.voice != cmd.source_end.voice
3944    {
3945        return Err(Error::InvalidCommand(
3946            "move or copy source endpoints must share part, staff, and voice".into(),
3947        ));
3948    }
3949    if cmd.source_start.voice >= 4 {
3950        return Err(Error::VoiceOutOfRange(cmd.source_start.voice));
3951    }
3952    let source_from = cmd.source_start.measure.min(cmd.source_end.measure);
3953    let source_to = cmd.source_start.measure.max(cmd.source_end.measure);
3954    let source_count = source_to - source_from + 1;
3955    let target_end = cmd
3956        .target
3957        .measure
3958        .checked_add(source_count - 1)
3959        .ok_or_else(|| Error::InvalidCommand("voice range target overflows".into()))?;
3960    if cmd.move_source
3961        && cmd.target.part == cmd.source_start.part
3962        && cmd.target.staff == cmd.source_start.staff
3963        && cmd.target.voice == cmd.source_start.voice
3964        && cmd.target.measure <= source_to
3965        && target_end >= source_from
3966    {
3967        return Err(Error::InvalidCommand(
3968            "moving a voice range onto itself is not supported".into(),
3969        ));
3970    }
3971
3972    let fragment = extract_score_fragment(
3973        score,
3974        &[ScoreFragmentSelection {
3975            start: cmd.source_start.clone(),
3976            end: cmd.source_end.clone(),
3977        }],
3978    )?;
3979    apply_paste_score_fragment(
3980        &PasteScoreFragmentCmd {
3981            fragment,
3982            target: cmd.target.clone(),
3983            policy: ScoreFragmentPastePolicy::Replace,
3984        },
3985        score,
3986    )?;
3987    if !cmd.move_source {
3988        return Ok(());
3989    }
3990
3991    let settings_time_signature = score.settings.time_signature.clone();
3992    let staff = score
3993        .parts
3994        .get_mut(cmd.source_start.part)
3995        .ok_or(Error::PartNotFound(cmd.source_start.part))?
3996        .staves
3997        .get_mut(cmd.source_start.staff)
3998        .ok_or(Error::StaffNotFound(cmd.source_start.staff))?;
3999    if source_to >= staff.measures.len() {
4000        return Err(Error::MeasureNotFound(source_to));
4001    }
4002    for measure in &mut staff.measures[source_from..=source_to] {
4003        let signature = measure
4004            .time_sig
4005            .as_ref()
4006            .unwrap_or(&settings_time_signature);
4007        let mut replacement = Vec::new();
4008        pad_voice_to_measure(&mut replacement, signature.total_beats());
4009        measure.voices[cmd.source_start.voice] = replacement;
4010        measure.source_voice_numbers[cmd.source_start.voice] = None;
4011    }
4012    prune_orphaned_spanners(score);
4013    Ok(())
4014}
4015
4016fn effective_staff_measure_beats(
4017    staff: &Staff,
4018    default_time_signature: &TimeSignature,
4019    measure_index: usize,
4020) -> Result<f64, Error> {
4021    let mut time_signature = default_time_signature.clone();
4022    for measure in staff.measures.iter().take(measure_index.saturating_add(1)) {
4023        if let Some(signature) = &measure.time_sig {
4024            time_signature = signature.clone();
4025        }
4026    }
4027    if staff.measures.get(measure_index).is_none() {
4028        return Err(Error::MeasureNotFound(measure_index));
4029    }
4030    Ok(time_signature.total_beats())
4031}
4032
4033fn normalized_structural_measure_range(start: usize, end: usize) -> (usize, usize) {
4034    (start.min(end), start.max(end))
4035}
4036
4037fn apply_implode_staves(cmd: &ImplodeStavesCmd, score: &mut Score) -> Result<(), Error> {
4038    if !(2..=4).contains(&cmd.source_staves.len()) {
4039        return Err(Error::InvalidCommand(
4040            "implode requires two to four source staves".into(),
4041        ));
4042    }
4043    let mut unique_staves = BTreeSet::new();
4044    for &staff_index in &cmd.source_staves {
4045        if !unique_staves.insert(staff_index) {
4046            return Err(Error::InvalidCommand(
4047                "implode source staves must be distinct".into(),
4048            ));
4049        }
4050    }
4051    if !unique_staves.contains(&cmd.target_staff) {
4052        return Err(Error::InvalidCommand(
4053            "implode target staff must be included in source staves".into(),
4054        ));
4055    }
4056    let (start, end) = normalized_structural_measure_range(cmd.start_measure, cmd.end_measure);
4057    let part = score
4058        .parts
4059        .get(cmd.part_index)
4060        .ok_or(Error::PartNotFound(cmd.part_index))?;
4061    for &staff_index in &cmd.source_staves {
4062        if part.staves.get(staff_index).is_none() {
4063            return Err(Error::StaffNotFound(staff_index));
4064        }
4065    }
4066
4067    // Validate all ranges before mutation.  This is intentionally stricter than
4068    // layout validation: a source secondary voice or a cross-staff placement
4069    // would otherwise need a policy that risks silently changing semantics.
4070    for measure_index in start..=end {
4071        let mut expected_beats: Option<f64> = None;
4072        for &staff_index in &cmd.source_staves {
4073            let staff = &part.staves[staff_index];
4074            let measure = staff
4075                .measures
4076                .get(measure_index)
4077                .ok_or(Error::MeasureNotFound(measure_index))?;
4078            let beats = effective_staff_measure_beats(
4079                staff,
4080                &score.settings.time_signature,
4081                measure_index,
4082            )?;
4083            if let Some(expected) = expected_beats
4084                && (expected - beats).abs() > 1e-9
4085            {
4086                return Err(Error::InvalidCommand(
4087                    "implode source staves must have matching measure durations".into(),
4088                ));
4089            }
4090            expected_beats = Some(beats);
4091            if measure.voices[1..].iter().any(|voice| !voice.is_empty()) {
4092                return Err(Error::InvalidCommand(
4093                    "implode requires empty secondary source voices".into(),
4094                ));
4095            }
4096            if measure.voices[0]
4097                .iter()
4098                .any(|note| note.cross_staff.is_some())
4099            {
4100                return Err(Error::InvalidCommand(
4101                    "implode does not support cross-staff source notes".into(),
4102                ));
4103            }
4104            let total: f64 = measure.voices[0].iter().map(Note::beats).sum();
4105            if total > beats + 1e-9 {
4106                return Err(Error::InvalidCommand(
4107                    "implode source voice exceeds its measure duration".into(),
4108                ));
4109            }
4110        }
4111    }
4112
4113    for measure_index in start..=end {
4114        let transferred: Vec<(Vec<Note>, Option<u32>)> = cmd
4115            .source_staves
4116            .iter()
4117            .map(|&staff_index| {
4118                let measure =
4119                    &score.parts[cmd.part_index].staves[staff_index].measures[measure_index];
4120                (measure.voices[0].clone(), measure.source_voice_numbers[0])
4121            })
4122            .collect();
4123        {
4124            let target =
4125                &mut score.parts[cmd.part_index].staves[cmd.target_staff].measures[measure_index];
4126            target.voices = [Vec::new(), Vec::new(), Vec::new(), Vec::new()];
4127            target.source_voice_numbers = [None; 4];
4128            for (voice_index, (notes, source_voice_number)) in transferred.into_iter().enumerate() {
4129                target.voices[voice_index] = notes;
4130                target.source_voice_numbers[voice_index] = source_voice_number;
4131            }
4132        }
4133        for &staff_index in &cmd.source_staves {
4134            if staff_index == cmd.target_staff {
4135                continue;
4136            }
4137            let beats = effective_staff_measure_beats(
4138                &score.parts[cmd.part_index].staves[staff_index],
4139                &score.settings.time_signature,
4140                measure_index,
4141            )?;
4142            let mut rest_voice = Vec::new();
4143            pad_voice_to_measure(&mut rest_voice, beats);
4144            let measure =
4145                &mut score.parts[cmd.part_index].staves[staff_index].measures[measure_index];
4146            measure.voices = [rest_voice, Vec::new(), Vec::new(), Vec::new()];
4147            measure.source_voice_numbers = [None; 4];
4148        }
4149    }
4150    remap_spanners(score, |address| {
4151        if address.part != cmd.part_index || address.measure < start || address.measure > end {
4152            return Some(address.clone());
4153        }
4154        let voice = cmd
4155            .source_staves
4156            .iter()
4157            .position(|&staff| staff == address.staff);
4158        if address.voice != 0 {
4159            return Some(address.clone());
4160        }
4161        voice
4162            .map(|voice| NoteAddr {
4163                staff: cmd.target_staff,
4164                voice,
4165                ..address.clone()
4166            })
4167            .or_else(|| Some(address.clone()))
4168    });
4169    Ok(())
4170}
4171
4172fn apply_explode_voices(cmd: &ExplodeVoicesCmd, score: &mut Score) -> Result<(), Error> {
4173    if !(2..=4).contains(&cmd.target_staves.len()) {
4174        return Err(Error::InvalidCommand(
4175            "explode requires two to four target staves".into(),
4176        ));
4177    }
4178    if cmd.target_staves.first() != Some(&cmd.source_staff) {
4179        return Err(Error::InvalidCommand(
4180            "explode target staves must begin with the source staff".into(),
4181        ));
4182    }
4183    let mut unique_staves = BTreeSet::new();
4184    for &staff_index in &cmd.target_staves {
4185        if !unique_staves.insert(staff_index) {
4186            return Err(Error::InvalidCommand(
4187                "explode target staves must be distinct".into(),
4188            ));
4189        }
4190    }
4191    let (start, end) = normalized_structural_measure_range(cmd.start_measure, cmd.end_measure);
4192    let part = score
4193        .parts
4194        .get(cmd.part_index)
4195        .ok_or(Error::PartNotFound(cmd.part_index))?;
4196    let source = part
4197        .staves
4198        .get(cmd.source_staff)
4199        .ok_or(Error::StaffNotFound(cmd.source_staff))?;
4200    for &staff_index in &cmd.target_staves {
4201        if part.staves.get(staff_index).is_none() {
4202            return Err(Error::StaffNotFound(staff_index));
4203        }
4204    }
4205
4206    for measure_index in start..=end {
4207        let expected =
4208            effective_staff_measure_beats(source, &score.settings.time_signature, measure_index)?;
4209        let source_measure = source
4210            .measures
4211            .get(measure_index)
4212            .ok_or(Error::MeasureNotFound(measure_index))?;
4213        for voice_index in cmd.target_staves.len()..4 {
4214            if !source_measure.voices[voice_index].is_empty() {
4215                return Err(Error::InvalidCommand(
4216                    "explode would discard a source voice without a target staff".into(),
4217                ));
4218            }
4219        }
4220        for voice_index in 0..cmd.target_staves.len() {
4221            if source_measure.voices[voice_index]
4222                .iter()
4223                .any(|note| note.cross_staff.is_some())
4224            {
4225                return Err(Error::InvalidCommand(
4226                    "explode does not support cross-staff source notes".into(),
4227                ));
4228            }
4229            let total: f64 = source_measure.voices[voice_index]
4230                .iter()
4231                .map(Note::beats)
4232                .sum();
4233            if total > expected + 1e-9 {
4234                return Err(Error::InvalidCommand(
4235                    "explode source voice exceeds its measure duration".into(),
4236                ));
4237            }
4238        }
4239        for &staff_index in cmd.target_staves.iter().skip(1) {
4240            let target = &part.staves[staff_index];
4241            let target_beats = effective_staff_measure_beats(
4242                target,
4243                &score.settings.time_signature,
4244                measure_index,
4245            )?;
4246            if (target_beats - expected).abs() > 1e-9 {
4247                return Err(Error::InvalidCommand(
4248                    "explode target staves must have matching measure durations".into(),
4249                ));
4250            }
4251            let measure = target
4252                .measures
4253                .get(measure_index)
4254                .ok_or(Error::MeasureNotFound(measure_index))?;
4255            if measure.voices[0].iter().any(|note| !note.is_rest)
4256                || measure.voices[1..].iter().any(|voice| !voice.is_empty())
4257                || measure.source_voice_numbers.iter().any(Option::is_some)
4258            {
4259                return Err(Error::InvalidCommand(
4260                    "explode destination staff must contain only an unnumbered rest voice".into(),
4261                ));
4262            }
4263        }
4264    }
4265
4266    for measure_index in start..=end {
4267        let transferred: Vec<(Vec<Note>, Option<u32>)> = (0..cmd.target_staves.len())
4268            .map(|voice_index| {
4269                let measure =
4270                    &score.parts[cmd.part_index].staves[cmd.source_staff].measures[measure_index];
4271                (
4272                    measure.voices[voice_index].clone(),
4273                    measure.source_voice_numbers[voice_index],
4274                )
4275            })
4276            .collect();
4277        for (voice_index, &staff_index) in cmd.target_staves.iter().enumerate() {
4278            let (notes, source_voice_number) = &transferred[voice_index];
4279            let target =
4280                &mut score.parts[cmd.part_index].staves[staff_index].measures[measure_index];
4281            target.voices = [notes.clone(), Vec::new(), Vec::new(), Vec::new()];
4282            target.source_voice_numbers = [*source_voice_number, None, None, None];
4283        }
4284    }
4285    remap_spanners(score, |address| {
4286        if address.part != cmd.part_index
4287            || address.staff != cmd.source_staff
4288            || address.measure < start
4289            || address.measure > end
4290            || address.voice >= cmd.target_staves.len()
4291        {
4292            return Some(address.clone());
4293        }
4294        Some(NoteAddr {
4295            staff: cmd.target_staves[address.voice],
4296            voice: 0,
4297            ..address.clone()
4298        })
4299    });
4300    Ok(())
4301}
4302
4303fn clear_derived_chord_note_notation(note: &mut Note) {
4304    note.articulations.clear();
4305    note.dynamic = None;
4306    note.stem_up = None;
4307    note.hairpin_start = None;
4308    note.hairpin_end = false;
4309    note.chord_symbol = None;
4310    note.ottava_start = None;
4311    note.ottava_end = false;
4312    note.lyric = None;
4313    note.additional_lyrics.clear();
4314    note.pedal_start = false;
4315    note.pedal_end = false;
4316    note.slur_start = false;
4317    note.slur_end = false;
4318    note.arpeggiate = None;
4319    note.technique_text = None;
4320    note.glissando_start = false;
4321    note.glissando_end = false;
4322    note.cross_staff = None;
4323    note.fingering = None;
4324    note.fingerings.clear();
4325    note.string_number = None;
4326    note.trill_line_start = false;
4327    note.trill_line_end = false;
4328    note.guitar_technique = None;
4329    note.guitar_bend_alter_cents = None;
4330    note.guitar_bend_curve.clear();
4331}
4332
4333fn exploded_chord_note(source: &Note, pitch_index: usize) -> Note {
4334    if pitch_index == 0 {
4335        let mut retained = source.clone();
4336        if !retained.is_rest {
4337            retained.pitches = vec![source.pitches[0].clone()];
4338            retained.tab_positions = source.tab_positions.first().cloned().into_iter().collect();
4339            retained.tab_position = retained.tab_positions.first().cloned();
4340        }
4341        return retained;
4342    }
4343    let mut derived = source.clone();
4344    derived.id = Uuid::new_v4().to_string();
4345    clear_derived_chord_note_notation(&mut derived);
4346    if derived.is_rest || pitch_index >= source.pitches.len() {
4347        derived.is_rest = true;
4348        derived.is_unpitched = false;
4349        derived.pitches.clear();
4350        derived.tab_position = None;
4351        derived.tab_positions.clear();
4352    } else {
4353        derived.pitches = vec![source.pitches[pitch_index].clone()];
4354        derived.tab_positions = source
4355            .tab_positions
4356            .get(pitch_index)
4357            .cloned()
4358            .into_iter()
4359            .collect();
4360        derived.tab_position = derived.tab_positions.first().cloned();
4361    }
4362    derived
4363}
4364
4365fn apply_explode_chord_pitches(
4366    cmd: &ExplodeChordPitchesCmd,
4367    score: &mut Score,
4368) -> Result<(), Error> {
4369    if !(2..=4).contains(&cmd.target_staves.len()) {
4370        return Err(Error::InvalidCommand(
4371            "chord explode requires two to four target staves".into(),
4372        ));
4373    }
4374    if cmd.target_staves.first() != Some(&cmd.source_staff) {
4375        return Err(Error::InvalidCommand(
4376            "chord explode target staves must begin with the source staff".into(),
4377        ));
4378    }
4379    let mut unique_staves = BTreeSet::new();
4380    for &staff_index in &cmd.target_staves {
4381        if !unique_staves.insert(staff_index) {
4382            return Err(Error::InvalidCommand(
4383                "chord explode target staves must be distinct".into(),
4384            ));
4385        }
4386    }
4387    let (start, end) = normalized_structural_measure_range(cmd.start_measure, cmd.end_measure);
4388    let part = score
4389        .parts
4390        .get(cmd.part_index)
4391        .ok_or(Error::PartNotFound(cmd.part_index))?;
4392    let source = part
4393        .staves
4394        .get(cmd.source_staff)
4395        .ok_or(Error::StaffNotFound(cmd.source_staff))?;
4396    for &staff_index in &cmd.target_staves {
4397        if part.staves.get(staff_index).is_none() {
4398            return Err(Error::StaffNotFound(staff_index));
4399        }
4400    }
4401    for measure_index in start..=end {
4402        let expected =
4403            effective_staff_measure_beats(source, &score.settings.time_signature, measure_index)?;
4404        let source_measure = source
4405            .measures
4406            .get(measure_index)
4407            .ok_or(Error::MeasureNotFound(measure_index))?;
4408        for note in &source_measure.voices[0] {
4409            if note.tuplet.is_some() {
4410                return Err(Error::InvalidCommand(
4411                    "chord explode does not support tuplets".into(),
4412                ));
4413            }
4414            if note.cross_staff.is_some() {
4415                return Err(Error::InvalidCommand(
4416                    "chord explode does not support cross-staff source notes".into(),
4417                ));
4418            }
4419            if note.is_unpitched || note.is_grace || note.is_cue {
4420                return Err(Error::InvalidCommand(
4421                    "chord explode does not support unpitched, grace, or cue notes".into(),
4422                ));
4423            }
4424            if !note.is_rest
4425                && (note.pitches.is_empty() || note.pitches.len() > cmd.target_staves.len())
4426            {
4427                return Err(Error::InvalidCommand(
4428                    "chord explode pitch count must fit target staves".into(),
4429                ));
4430            }
4431        }
4432        let total: f64 = source_measure.voices[0].iter().map(Note::beats).sum();
4433        if total > expected + 1e-9 {
4434            return Err(Error::InvalidCommand(
4435                "chord explode source voice exceeds its measure duration".into(),
4436            ));
4437        }
4438        for &staff_index in cmd.target_staves.iter().skip(1) {
4439            let target = &part.staves[staff_index];
4440            let target_beats = effective_staff_measure_beats(
4441                target,
4442                &score.settings.time_signature,
4443                measure_index,
4444            )?;
4445            if (target_beats - expected).abs() > 1e-9 {
4446                return Err(Error::InvalidCommand(
4447                    "chord explode target staves must have matching measure durations".into(),
4448                ));
4449            }
4450            let measure = target
4451                .measures
4452                .get(measure_index)
4453                .ok_or(Error::MeasureNotFound(measure_index))?;
4454            if measure.voices[0].iter().any(|note| !note.is_rest)
4455                || measure.voices[1..].iter().any(|voice| !voice.is_empty())
4456                || measure.source_voice_numbers.iter().any(Option::is_some)
4457            {
4458                return Err(Error::InvalidCommand(
4459                    "chord explode destination staff must contain only an unnumbered rest voice"
4460                        .into(),
4461                ));
4462            }
4463        }
4464    }
4465    for measure_index in start..=end {
4466        let source_measure =
4467            &score.parts[cmd.part_index].staves[cmd.source_staff].measures[measure_index];
4468        let source_voice_number = source_measure.source_voice_numbers[0];
4469        let mut exploded = vec![Vec::new(); cmd.target_staves.len()];
4470        for note in &source_measure.voices[0] {
4471            for (pitch_index, voice) in exploded.iter_mut().enumerate() {
4472                voice.push(exploded_chord_note(note, pitch_index));
4473            }
4474        }
4475        for (index, &staff_index) in cmd.target_staves.iter().enumerate() {
4476            let target =
4477                &mut score.parts[cmd.part_index].staves[staff_index].measures[measure_index];
4478            target.voices = [exploded[index].clone(), Vec::new(), Vec::new(), Vec::new()];
4479            target.source_voice_numbers = [source_voice_number, None, None, None];
4480        }
4481    }
4482    Ok(())
4483}
4484
4485fn scaled_duration(duration: &Duration, scale: DurationScale) -> Option<Duration> {
4486    const ORDER: [Duration; 10] = [
4487        Duration::Longa,
4488        Duration::Breve,
4489        Duration::Whole,
4490        Duration::Half,
4491        Duration::Quarter,
4492        Duration::Eighth,
4493        Duration::Sixteenth,
4494        Duration::ThirtySecond,
4495        Duration::SixtyFourth,
4496        Duration::HundredTwentyEighth,
4497    ];
4498    let index = ORDER.iter().position(|value| value == duration)?;
4499    let scaled = match scale {
4500        DurationScale::Double => index.checked_sub(1)?,
4501        DurationScale::Half => index + 1,
4502    };
4503    ORDER.get(scaled).cloned()
4504}
4505
4506fn uniform_tuplet_ratio(voice: &[Note]) -> Result<Option<TupletInfo>, Error> {
4507    let mut ratio: Option<TupletInfo> = None;
4508    for note in voice {
4509        let Some(tuplet) = &note.tuplet else {
4510            continue;
4511        };
4512        if tuplet.actual_notes == 0 || tuplet.normal_notes == 0 {
4513            return Err(Error::InvalidCommand(
4514                "duration scaling requires a non-zero tuplet ratio".into(),
4515            ));
4516        }
4517        if let Some(existing) = &ratio {
4518            if existing != tuplet {
4519                return Err(Error::InvalidCommand(
4520                    "duration scaling requires one shared tuplet ratio per voice".into(),
4521                ));
4522            }
4523        } else {
4524            ratio = Some(tuplet.clone());
4525        }
4526    }
4527    Ok(ratio)
4528}
4529
4530fn pad_voice_to_measure_with_tuplet_ratio(
4531    voice: &mut Vec<Note>,
4532    max_beats: f64,
4533    ratio: &TupletInfo,
4534) -> Result<(), Error> {
4535    let mut used: f64 = voice.iter().map(Note::beats).sum();
4536    let ratio_scale = f64::from(ratio.normal_notes) / f64::from(ratio.actual_notes);
4537    while max_beats - used > 1e-9 {
4538        let remaining = max_beats - used;
4539        let duration = [
4540            Duration::Whole,
4541            Duration::Half,
4542            Duration::Quarter,
4543            Duration::Eighth,
4544            Duration::Sixteenth,
4545            Duration::ThirtySecond,
4546            Duration::SixtyFourth,
4547            Duration::HundredTwentyEighth,
4548        ]
4549        .into_iter()
4550        .find(|duration| duration.beats(0) * ratio_scale <= remaining + 1e-9)
4551        .ok_or_else(|| {
4552            Error::InvalidCommand(
4553                "duration scaling cannot represent the remaining tuplet duration".into(),
4554            )
4555        })?;
4556        let mut rest = Note::rest(duration);
4557        rest.tuplet = Some(ratio.clone());
4558        used += rest.beats();
4559        voice.push(rest);
4560    }
4561    Ok(())
4562}
4563
4564fn apply_scale_voice_range(cmd: &ScaleVoiceRangeCmd, score: &mut Score) -> Result<(), Error> {
4565    if cmd.voice >= 4 {
4566        return Err(Error::VoiceOutOfRange(cmd.voice));
4567    }
4568    let (start, end) = normalized_structural_measure_range(cmd.start_measure, cmd.end_measure);
4569    let staff = score
4570        .parts
4571        .get(cmd.part_index)
4572        .ok_or(Error::PartNotFound(cmd.part_index))?
4573        .staves
4574        .get(cmd.staff_index)
4575        .ok_or(Error::StaffNotFound(cmd.staff_index))?;
4576    for measure_index in start..=end {
4577        let measure = staff
4578            .measures
4579            .get(measure_index)
4580            .ok_or(Error::MeasureNotFound(measure_index))?;
4581        let expected =
4582            effective_staff_measure_beats(staff, &score.settings.time_signature, measure_index)?;
4583        let voice = &measure.voices[cmd.voice];
4584        match cmd.tuplet_policy {
4585            TupletScalePolicy::PreserveRatio => {
4586                uniform_tuplet_ratio(voice)?;
4587            }
4588        }
4589        let mut scaled_beats = 0.0;
4590        for note in voice {
4591            if scaled_duration(&note.duration, cmd.scale).is_none() {
4592                return Err(Error::InvalidCommand(
4593                    "duration scaling exceeds the portable duration range".into(),
4594                ));
4595            }
4596            if !note.is_grace && !note.is_cue {
4597                scaled_beats += match cmd.scale {
4598                    DurationScale::Half => note.beats() / 2.0,
4599                    DurationScale::Double => note.beats() * 2.0,
4600                };
4601            }
4602        }
4603        if scaled_beats > expected + 1e-9 {
4604            return Err(Error::InvalidCommand(format!(
4605                "duration scaling overflows measure {measure_index}: {scaled_beats} beats exceeds {expected}"
4606            )));
4607        }
4608    }
4609
4610    for measure_index in start..=end {
4611        let expected = effective_staff_measure_beats(
4612            &score.parts[cmd.part_index].staves[cmd.staff_index],
4613            &score.settings.time_signature,
4614            measure_index,
4615        )?;
4616        let voice = &mut score.parts[cmd.part_index].staves[cmd.staff_index].measures
4617            [measure_index]
4618            .voices[cmd.voice];
4619        if voice.is_empty() {
4620            continue;
4621        }
4622        let tuplet_ratio = match cmd.tuplet_policy {
4623            TupletScalePolicy::PreserveRatio => uniform_tuplet_ratio(voice)?,
4624        };
4625        for note in voice.iter_mut() {
4626            note.duration = scaled_duration(&note.duration, cmd.scale).ok_or_else(|| {
4627                Error::InvalidCommand("duration scaling exceeds the portable duration range".into())
4628            })?;
4629        }
4630        if let Some(ratio) = tuplet_ratio {
4631            pad_voice_to_measure_with_tuplet_ratio(voice, expected, &ratio)?;
4632        } else {
4633            pad_voice_to_measure(voice, expected);
4634        }
4635    }
4636    Ok(())
4637}
4638
4639fn apply_paste_score_fragment(cmd: &PasteScoreFragmentCmd, score: &mut Score) -> Result<(), Error> {
4640    if !(MIN_SUPPORTED_SCORE_FRAGMENT_CONTRACT_VERSION..=SCORE_FRAGMENT_CONTRACT_VERSION)
4641        .contains(&cmd.fragment.contract_version)
4642    {
4643        return Err(Error::InvalidCommand(format!(
4644            "unsupported score fragment contract version {}",
4645            cmd.fragment.contract_version
4646        )));
4647    }
4648    if cmd.fragment.voices.is_empty() {
4649        return Err(Error::InvalidCommand(
4650            "cannot paste an empty score fragment".into(),
4651        ));
4652    }
4653
4654    // Resolve every target before mutating the score. CommandStack then also
4655    // validates the complete candidate before adding history, preserving
4656    // score/history atomicity for invalid destinations.
4657    let mut lanes = BTreeSet::new();
4658    let mut replaced = BTreeSet::new();
4659    for lane in &cmd.fragment.voices {
4660        let part_index = cmd
4661            .target
4662            .part
4663            .checked_add(lane.relative_part)
4664            .ok_or_else(|| Error::InvalidCommand("fragment part target overflows".into()))?;
4665        let staff_index = cmd
4666            .target
4667            .staff
4668            .checked_add(lane.relative_staff)
4669            .ok_or_else(|| Error::InvalidCommand("fragment staff target overflows".into()))?;
4670        let voice_index = cmd
4671            .target
4672            .voice
4673            .checked_add(lane.relative_voice)
4674            .ok_or_else(|| Error::InvalidCommand("fragment voice target overflows".into()))?;
4675        if voice_index >= 4 {
4676            return Err(Error::VoiceOutOfRange(voice_index));
4677        }
4678        let staff = score
4679            .parts
4680            .get(part_index)
4681            .ok_or(Error::PartNotFound(part_index))?
4682            .staves
4683            .get(staff_index)
4684            .ok_or(Error::StaffNotFound(staff_index))?;
4685        if !lanes.insert((part_index, staff_index, voice_index)) {
4686            return Err(Error::InvalidCommand(
4687                "fragment contains duplicate destination voice lanes".into(),
4688            ));
4689        }
4690        for measure in &lane.measures {
4691            let measure_index = cmd
4692                .target
4693                .measure
4694                .checked_add(measure.relative_measure)
4695                .ok_or_else(|| Error::InvalidCommand("fragment measure target overflows".into()))?;
4696            if measure_index >= staff.measures.len() {
4697                return Err(Error::MeasureNotFound(measure_index));
4698            }
4699            if !measure.cross_staff_targets.is_empty()
4700                && measure.cross_staff_targets.len() != measure.notes.len()
4701            {
4702                return Err(Error::InvalidCommand(
4703                    "fragment cross-staff targets do not match note count".into(),
4704                ));
4705            }
4706            if cmd.policy == ScoreFragmentPastePolicy::Merge
4707                && !measure.notes.is_empty()
4708                && staff.measures[measure_index].voices[voice_index]
4709                    .iter()
4710                    .any(|note| !note.is_rest)
4711            {
4712                return Err(Error::InvalidCommand(
4713                    "fragment merge would overwrite a sounding destination lane".into(),
4714                ));
4715            }
4716            if cmd.policy == ScoreFragmentPastePolicy::Merge
4717                && measure.attributes.present
4718                && measure.attributes
4719                    != super::fragment::ScoreFragmentMeasureAttributes::from_measure(
4720                        &staff.measures[measure_index],
4721                    )
4722            {
4723                return Err(Error::InvalidCommand(
4724                    "fragment merge would overwrite destination measure attributes".into(),
4725                ));
4726            }
4727            for cross_staff in measure.cross_staff_targets.iter().flatten() {
4728                let target_staff = staff_index as i64 + cross_staff.staff_offset;
4729                if target_staff < 0
4730                    || score.parts[part_index]
4731                        .staves
4732                        .get(target_staff as usize)
4733                        .is_none()
4734                {
4735                    return Err(Error::InvalidCommand(
4736                        "fragment cross-staff target is outside destination part".into(),
4737                    ));
4738                }
4739                if cross_staff.target_voice.is_some_and(|voice| voice >= 4) {
4740                    return Err(Error::InvalidCommand(
4741                        "fragment cross-staff target voice is outside editable range".into(),
4742                    ));
4743                }
4744            }
4745            replaced.insert((part_index, staff_index, measure_index, voice_index));
4746        }
4747    }
4748
4749    // Replacing a lane invalidates its former typed-span endpoints. Remove
4750    // those spans explicitly rather than allowing legacy endpoint flags to
4751    // leave dangling canonical addresses.
4752    score.spanners.retain(|spanner| {
4753        !replaced.contains(&(
4754            spanner.start.part,
4755            spanner.start.staff,
4756            spanner.start.measure,
4757            spanner.start.voice,
4758        )) && !replaced.contains(&(
4759            spanner.end.part,
4760            spanner.end.staff,
4761            spanner.end.measure,
4762            spanner.end.voice,
4763        ))
4764    });
4765    for lane in &cmd.fragment.voices {
4766        let part_index = cmd.target.part + lane.relative_part;
4767        let staff_index = cmd.target.staff + lane.relative_staff;
4768        let voice_index = cmd.target.voice + lane.relative_voice;
4769        let staff = &mut score.parts[part_index].staves[staff_index];
4770        for measure in &lane.measures {
4771            let measure_index = cmd.target.measure + measure.relative_measure;
4772            let target = &mut staff.measures[measure_index];
4773            if cmd.policy == ScoreFragmentPastePolicy::Merge && measure.notes.is_empty() {
4774                continue;
4775            }
4776            let mut notes = measure.notes.clone();
4777            for note in &mut notes {
4778                note.id = Uuid::new_v4().to_string();
4779            }
4780            if !measure.cross_staff_targets.is_empty() {
4781                for (note, cross_staff) in notes.iter_mut().zip(&measure.cross_staff_targets) {
4782                    note.cross_staff = cross_staff.as_ref().map(|cross_staff| CrossStaff {
4783                        target_staff: (staff_index as i64 + cross_staff.staff_offset) as usize,
4784                        target_voice: cross_staff.target_voice,
4785                    });
4786                }
4787            }
4788            target.voices[voice_index] = notes;
4789            target.source_voice_numbers[voice_index] = measure.source_voice_number;
4790            if cmd.policy == ScoreFragmentPastePolicy::Replace {
4791                measure.attributes.apply_to_measure(target);
4792            }
4793        }
4794    }
4795
4796    let mut used_spanner_ids = score
4797        .spanners
4798        .iter()
4799        .map(|spanner| spanner.id.clone())
4800        .collect::<BTreeSet<_>>();
4801    for source in &cmd.fragment.spanners {
4802        let mut copied = source.clone();
4803        copied.start = fragment_destination_address(&cmd.target, &source.start)?;
4804        copied.end = fragment_destination_address(&cmd.target, &source.end)?;
4805        if note_at(score, &copied.start).is_none() {
4806            return Err(Error::NoteNotFound(copied.start.note));
4807        }
4808        if note_at(score, &copied.end).is_none() {
4809            return Err(Error::NoteNotFound(copied.end.note));
4810        }
4811        copied.id = unique_fragment_spanner_id(&used_spanner_ids, &source.id);
4812        used_spanner_ids.insert(copied.id.clone());
4813        score.spanners.push(copied);
4814    }
4815    Ok(())
4816}
4817
4818fn fragment_destination_address(target: &NoteAddr, relative: &NoteAddr) -> Result<NoteAddr, Error> {
4819    Ok(NoteAddr {
4820        part: target
4821            .part
4822            .checked_add(relative.part)
4823            .ok_or_else(|| Error::InvalidCommand("fragment part target overflows".into()))?,
4824        staff: target
4825            .staff
4826            .checked_add(relative.staff)
4827            .ok_or_else(|| Error::InvalidCommand("fragment staff target overflows".into()))?,
4828        measure: target
4829            .measure
4830            .checked_add(relative.measure)
4831            .ok_or_else(|| Error::InvalidCommand("fragment measure target overflows".into()))?,
4832        voice: target
4833            .voice
4834            .checked_add(relative.voice)
4835            .ok_or_else(|| Error::InvalidCommand("fragment voice target overflows".into()))?,
4836        note: relative.note,
4837    })
4838}
4839
4840fn unique_fragment_spanner_id(used: &BTreeSet<String>, source_id: &str) -> String {
4841    let base = format!("{source_id}-copy");
4842    if !used.contains(&base) {
4843        return base;
4844    }
4845    let mut suffix = 2usize;
4846    loop {
4847        let candidate = format!("{base}-{suffix}");
4848        if !used.contains(&candidate) {
4849            return candidate;
4850        }
4851        suffix += 1;
4852    }
4853}
4854
4855fn apply_toggle_slur(cmd: &ToggleSlurCmd, score: &mut Score) -> Result<(), Error> {
4856    let new_start = !{
4857        score
4858            .parts
4859            .get(cmd.start.part)
4860            .ok_or(Error::PartNotFound(cmd.start.part))?
4861            .staves
4862            .get(cmd.start.staff)
4863            .ok_or(Error::StaffNotFound(cmd.start.staff))?
4864            .measures
4865            .get(cmd.start.measure)
4866            .ok_or(Error::MeasureNotFound(cmd.start.measure))?
4867            .voices
4868            .get(cmd.start.voice)
4869            .ok_or(Error::VoiceOutOfRange(cmd.start.voice))?
4870            .get(cmd.start.note)
4871            .ok_or(Error::NoteNotFound(cmd.start.note))?
4872            .slur_start
4873    };
4874    let new_end = !{
4875        score
4876            .parts
4877            .get(cmd.end.part)
4878            .ok_or(Error::PartNotFound(cmd.end.part))?
4879            .staves
4880            .get(cmd.end.staff)
4881            .ok_or(Error::StaffNotFound(cmd.end.staff))?
4882            .measures
4883            .get(cmd.end.measure)
4884            .ok_or(Error::MeasureNotFound(cmd.end.measure))?
4885            .voices
4886            .get(cmd.end.voice)
4887            .ok_or(Error::VoiceOutOfRange(cmd.end.voice))?
4888            .get(cmd.end.note)
4889            .ok_or(Error::NoteNotFound(cmd.end.note))?
4890            .slur_end
4891    };
4892    score.parts[cmd.start.part].staves[cmd.start.staff].measures[cmd.start.measure].voices
4893        [cmd.start.voice][cmd.start.note]
4894        .slur_start = new_start;
4895    score.parts[cmd.end.part].staves[cmd.end.staff].measures[cmd.end.measure].voices
4896        [cmd.end.voice][cmd.end.note]
4897        .slur_end = new_end;
4898    Ok(())
4899}
4900
4901fn apply_toggle_trill_line(cmd: &ToggleTrillLineCmd, score: &mut Score) -> Result<(), Error> {
4902    let new_start = !{
4903        score
4904            .parts
4905            .get(cmd.start.part)
4906            .ok_or(Error::PartNotFound(cmd.start.part))?
4907            .staves
4908            .get(cmd.start.staff)
4909            .ok_or(Error::StaffNotFound(cmd.start.staff))?
4910            .measures
4911            .get(cmd.start.measure)
4912            .ok_or(Error::MeasureNotFound(cmd.start.measure))?
4913            .voices
4914            .get(cmd.start.voice)
4915            .ok_or(Error::VoiceOutOfRange(cmd.start.voice))?
4916            .get(cmd.start.note)
4917            .ok_or(Error::NoteNotFound(cmd.start.note))?
4918            .trill_line_start
4919    };
4920    let new_end = !{
4921        score
4922            .parts
4923            .get(cmd.end.part)
4924            .ok_or(Error::PartNotFound(cmd.end.part))?
4925            .staves
4926            .get(cmd.end.staff)
4927            .ok_or(Error::StaffNotFound(cmd.end.staff))?
4928            .measures
4929            .get(cmd.end.measure)
4930            .ok_or(Error::MeasureNotFound(cmd.end.measure))?
4931            .voices
4932            .get(cmd.end.voice)
4933            .ok_or(Error::VoiceOutOfRange(cmd.end.voice))?
4934            .get(cmd.end.note)
4935            .ok_or(Error::NoteNotFound(cmd.end.note))?
4936            .trill_line_end
4937    };
4938    score.parts[cmd.start.part].staves[cmd.start.staff].measures[cmd.start.measure].voices
4939        [cmd.start.voice][cmd.start.note]
4940        .trill_line_start = new_start;
4941    score.parts[cmd.end.part].staves[cmd.end.staff].measures[cmd.end.measure].voices
4942        [cmd.end.voice][cmd.end.note]
4943        .trill_line_end = new_end;
4944    Ok(())
4945}
4946
4947fn apply_add_staff(cmd: &AddStaffCmd, score: &mut Score) -> Result<(), Error> {
4948    let ts = score.settings.time_signature.clone();
4949    let measure_count = score
4950        .parts
4951        .get(cmd.part_index)
4952        .ok_or(Error::PartNotFound(cmd.part_index))?
4953        .staves
4954        .first()
4955        .map_or(0, |s| s.measures.len());
4956    let mut staff = Staff::new(cmd.clef.clone());
4957    for i in 0..measure_count {
4958        let mut m = Measure::empty(ts.numerator, ts.denominator);
4959        m.number = i as u32 + 1;
4960        staff.measures.push(m);
4961    }
4962    score.parts[cmd.part_index].staves.push(staff);
4963    Ok(())
4964}
4965
4966fn apply_delete_staff(cmd: &DeleteStaffCmd, score: &mut Score) -> Result<(), Error> {
4967    let part = score
4968        .parts
4969        .get_mut(cmd.part_index)
4970        .ok_or(Error::PartNotFound(cmd.part_index))?;
4971    if part.staves.len() <= 1 {
4972        return Err(Error::CannotDeleteLastStaff);
4973    }
4974    if cmd.staff_index >= part.staves.len() {
4975        return Err(Error::StaffNotFound(cmd.staff_index));
4976    }
4977    part.staves.remove(cmd.staff_index);
4978    remap_spanners(score, |address| {
4979        if address.part != cmd.part_index {
4980            Some(address.clone())
4981        } else if address.staff == cmd.staff_index {
4982            None
4983        } else if address.staff > cmd.staff_index {
4984            let mut shifted = address.clone();
4985            shifted.staff -= 1;
4986            Some(shifted)
4987        } else {
4988            Some(address.clone())
4989        }
4990    });
4991    Ok(())
4992}
4993
4994/// Returns true when an address refers to the given canonical voice.
4995fn same_voice(address: &NoteAddr, part: usize, staff: usize, measure: usize, voice: usize) -> bool {
4996    address.part == part
4997        && address.staff == staff
4998        && address.measure == measure
4999        && address.voice == voice
5000}
5001
5002fn trim_voice_to_measure(voice: &mut Vec<Note>, max_beats: f64) {
5003    // A lone plain whole rest is a measure rest and already fills any measure.
5004    if matches!(voice.as_slice(), [only] if only.is_plain_whole_rest()) {
5005        return;
5006    }
5007    let mut total = 0.0f64;
5008    let mut cutoff = voice.len();
5009    for (i, n) in voice.iter().enumerate() {
5010        total += n.beats();
5011        if total > max_beats + 1e-9 {
5012            cutoff = i;
5013            break;
5014        }
5015    }
5016    voice.truncate(cutoff);
5017    pad_voice_to_measure(voice, max_beats);
5018}
5019
5020fn pad_voice_to_measure(voice: &mut Vec<Note>, max_beats: f64) {
5021    let mut used = crate::voice_duration_beats(voice, max_beats);
5022    while max_beats - used > 1e-9 {
5023        let remaining = max_beats - used;
5024        let rest = Note::rest(Duration::whole_filling_beats(remaining));
5025        used += rest.beats();
5026        voice.push(rest);
5027    }
5028}
5029
5030#[cfg(test)]
5031mod tests {
5032    use super::*;
5033    use crate::ScoreEngine;
5034    use crate::model::pitch::Step;
5035
5036    fn default_engine_score() -> Score {
5037        let mut s = Score::default();
5038        for part in &mut s.parts {
5039            for staff in &mut part.staves {
5040                for (i, m) in staff.measures.iter_mut().enumerate() {
5041                    m.number = i as u32 + 1;
5042                }
5043            }
5044        }
5045        s
5046    }
5047
5048    #[test]
5049    fn legacy_scale_voice_range_command_defaults_to_preserve_tuplet_ratio() {
5050        let command: Command = serde_json::from_str(
5051            r#"{"type":"scale_voice_range","part_index":0,"staff_index":0,"voice":0,"start_measure":0,"end_measure":0,"scale":"half"}"#,
5052        )
5053        .expect("legacy scale command deserializes");
5054        let Command::ScaleVoiceRange(command) = command else {
5055            panic!("expected scale command");
5056        };
5057        assert_eq!(command.tuplet_policy, TupletScalePolicy::PreserveRatio);
5058    }
5059
5060    fn score_with_typed_spanner() -> Score {
5061        use crate::model::score::{NotationSpanner, NotationSpannerKind};
5062
5063        let mut score = Score::new("Spanner edits", 120, 4, 4, 0, 1);
5064        score.parts[0].staves[0].measures[0].voices[0] = (0..4)
5065            .map(|offset| Note::new(Pitch::new(Step::C, 4 + offset), Duration::Quarter))
5066            .collect();
5067        score.spanners.push(NotationSpanner {
5068            id: "typed-glissando".to_string(),
5069            kind: NotationSpannerKind::Glissando,
5070            start: NoteAddr {
5071                part: 0,
5072                staff: 0,
5073                measure: 0,
5074                voice: 0,
5075                note: 1,
5076            },
5077            end: NoteAddr {
5078                part: 0,
5079                staff: 0,
5080                measure: 0,
5081                voice: 0,
5082                note: 2,
5083            },
5084            number: Some(1),
5085            line_type: None,
5086            text: None,
5087            placement: None,
5088            ottava_size: None,
5089            ottava_type: None,
5090        });
5091        score
5092    }
5093
5094    fn insert_note_at(position: usize) -> Command {
5095        Command::AddNote(AddNoteCmd {
5096            part_index: 0,
5097            staff_index: 0,
5098            measure_index: 0,
5099            voice: 0,
5100            position,
5101            pitch: Some(Pitch::new(Step::D, 5)),
5102            duration: Duration::Quarter,
5103            dot_count: 0,
5104            is_rest: false,
5105            tuplet: None,
5106        })
5107    }
5108
5109    #[test]
5110    fn add_note_inserts_into_voice() {
5111        let mut score = default_engine_score();
5112        let cmd = Command::AddNote(AddNoteCmd {
5113            part_index: 0,
5114            staff_index: 0,
5115            measure_index: 0,
5116            voice: 0,
5117            position: 0,
5118            pitch: Some(Pitch::new(Step::C, 4)),
5119            duration: Duration::Quarter,
5120            dot_count: 0,
5121            is_rest: false,
5122            tuplet: None,
5123        });
5124        apply_command(&cmd, &mut score).unwrap();
5125        let first = &score.parts[0].staves[0].measures[0].voices[0][0];
5126        assert!(!first.is_rest);
5127        assert_eq!(first.pitches[0].step, Step::C);
5128    }
5129
5130    #[test]
5131    fn typed_spanners_follow_note_insertions_at_every_relative_position() {
5132        for (position, expected_start, expected_end) in [
5133            (0, 2, 3), // before the span
5134            (1, 2, 3), // at its start endpoint
5135            (2, 1, 3), // inside the span
5136            (4, 1, 2), // at the end of the voice
5137            (3, 1, 2), // after the span
5138        ] {
5139            let mut score = score_with_typed_spanner();
5140            apply_command(&insert_note_at(position), &mut score).unwrap();
5141            let span = score.spanners.first().expect("span remains attached");
5142            assert_eq!(span.start.note, expected_start, "position {position}");
5143            assert_eq!(span.end.note, expected_end, "position {position}");
5144        }
5145    }
5146
5147    #[test]
5148    fn deleting_a_typed_spanner_endpoint_removes_the_whole_span_and_undo_redo_is_atomic() {
5149        let mut score = score_with_typed_spanner();
5150        let endpoint_id = score.parts[0].staves[0].measures[0].voices[0][1].id.clone();
5151        let mut stack = CommandStack::new(8);
5152
5153        stack
5154            .execute(
5155                Command::DeleteNote(DeleteNoteCmd {
5156                    note_id: endpoint_id,
5157                    part_index: 0,
5158                    staff_index: 0,
5159                    measure_index: 0,
5160                    voice: 0,
5161                }),
5162                &mut score,
5163            )
5164            .unwrap();
5165        assert!(score.spanners.is_empty());
5166
5167        stack.undo(&mut score).unwrap();
5168        assert_eq!(score.spanners[0].start.note, 1);
5169        assert_eq!(score.spanners[0].end.note, 2);
5170
5171        stack.redo(&mut score).unwrap();
5172        assert!(score.spanners.is_empty());
5173    }
5174
5175    #[test]
5176    fn batched_structural_edits_remap_typed_spanners_and_restore_them_on_undo() {
5177        let mut score = score_with_typed_spanner();
5178        let mut stack = CommandStack::new(8);
5179        stack
5180            .batch_execute(
5181                vec![
5182                    insert_note_at(0),
5183                    Command::SetTempo(SetTempoCmd { bpm: 144 }),
5184                ],
5185                &mut score,
5186            )
5187            .unwrap();
5188        assert_eq!(score.spanners[0].start.note, 2);
5189        assert_eq!(score.spanners[0].end.note, 3);
5190
5191        stack.undo(&mut score).unwrap();
5192        assert_eq!(score.spanners[0].start.note, 1);
5193        assert_eq!(score.spanners[0].end.note, 2);
5194
5195        stack.redo(&mut score).unwrap();
5196        assert_eq!(score.spanners[0].start.note, 2);
5197        assert_eq!(score.spanners[0].end.note, 3);
5198    }
5199
5200    #[test]
5201    fn typed_spanners_remap_only_the_edited_endpoint_across_staff_and_voice() {
5202        use crate::model::score::{NotationSpanner, NotationSpannerKind, ScoreTemplate};
5203
5204        let mut score = Score::template(ScoreTemplate::Piano);
5205        for staff in &mut score.parts[0].staves {
5206            let notes: Vec<Note> = (0..4)
5207                .map(|offset| Note::new(Pitch::new(Step::C, 4 + offset), Duration::Quarter))
5208                .collect();
5209            staff.measures[0].voices[0] = notes.clone();
5210            staff.measures[0].voices[1] = notes;
5211        }
5212        score.spanners.push(NotationSpanner {
5213            id: "cross-staff".to_string(),
5214            kind: NotationSpannerKind::Slur,
5215            start: NoteAddr {
5216                part: 0,
5217                staff: 0,
5218                measure: 0,
5219                voice: 0,
5220                note: 1,
5221            },
5222            end: NoteAddr {
5223                part: 0,
5224                staff: 1,
5225                measure: 0,
5226                voice: 1,
5227                note: 2,
5228            },
5229            number: Some(2),
5230            line_type: None,
5231            text: None,
5232            placement: None,
5233            ottava_size: None,
5234            ottava_type: None,
5235        });
5236
5237        let mut stack = CommandStack::new(8);
5238        stack.execute(insert_note_at(0), &mut score).unwrap();
5239        let span = &score.spanners[0];
5240        assert_eq!(span.start.note, 2);
5241        assert_eq!(span.end.staff, 1);
5242        assert_eq!(span.end.voice, 1);
5243        assert_eq!(span.end.note, 2);
5244    }
5245
5246    #[test]
5247    fn set_fingerings_keeps_first_legacy_value_in_sync() {
5248        let mut score = default_engine_score();
5249        apply_command(
5250            &Command::AddNote(AddNoteCmd {
5251                part_index: 0,
5252                staff_index: 0,
5253                measure_index: 0,
5254                voice: 0,
5255                position: 0,
5256                pitch: Some(Pitch::new(Step::C, 4)),
5257                duration: Duration::Quarter,
5258                dot_count: 0,
5259                is_rest: false,
5260                tuplet: None,
5261            }),
5262            &mut score,
5263        )
5264        .unwrap();
5265        apply_command(
5266            &Command::SetFingerings(SetFingeringsCmd {
5267                part_index: 0,
5268                staff_index: 0,
5269                measure_index: 0,
5270                voice: 0,
5271                note_index: 0,
5272                fingerings: vec![1, 3, 4],
5273            }),
5274            &mut score,
5275        )
5276        .unwrap();
5277        let note = &score.parts[0].staves[0].measures[0].voices[0][0];
5278        assert_eq!(note.fingerings, vec![1, 3, 4]);
5279        assert_eq!(note.fingering, Some(1));
5280    }
5281
5282    #[test]
5283    fn set_figured_bass_replaces_measure_figures() {
5284        let mut score = default_engine_score();
5285        let figures = vec![FiguredBassFigure {
5286            number: "6".to_string(),
5287            alter: Some("-1".to_string()),
5288            prefix: Some("+".to_string()),
5289            suffix: None,
5290            extender: false,
5291        }];
5292        apply_command(
5293            &Command::SetFiguredBass(SetFiguredBassCmd {
5294                measure_index: 0,
5295                figures: figures.clone(),
5296            }),
5297            &mut score,
5298        )
5299        .unwrap();
5300        assert_eq!(score.parts[0].staves[0].measures[0].figured_bass, figures);
5301    }
5302
5303    #[test]
5304    fn set_measure_text_supports_append_replace_remove_and_undo_redo() {
5305        let mut engine = crate::ScoreEngine::new();
5306        let address = SetMeasureTextCmd {
5307            part_index: 0,
5308            staff_index: 0,
5309            measure_index: 0,
5310            text_index: 0,
5311            text: Some(StyledText {
5312                position_beats: None,
5313                style: crate::TextStyle::Technique,
5314                text: "dolce".to_string(),
5315                placement: None,
5316                offset_x: None,
5317                offset_y: None,
5318                relative_x: None,
5319                relative_y: None,
5320            }),
5321        };
5322        engine.apply(Command::SetMeasureText(address)).unwrap();
5323        assert_eq!(
5324            engine.score.parts[0].staves[0].measures[0].texts[0],
5325            StyledText {
5326                position_beats: None,
5327                style: crate::TextStyle::Technique,
5328                text: "dolce".to_string(),
5329                placement: None,
5330                offset_x: None,
5331                offset_y: None,
5332                relative_x: None,
5333                relative_y: None,
5334            }
5335        );
5336
5337        engine
5338            .apply(Command::SetMeasureText(SetMeasureTextCmd {
5339                text_index: 0,
5340                text: Some(StyledText {
5341                    position_beats: None,
5342                    style: crate::TextStyle::RehearsalMark,
5343                    text: "A".to_string(),
5344                    placement: None,
5345                    offset_x: None,
5346                    offset_y: None,
5347                    relative_x: None,
5348                    relative_y: None,
5349                }),
5350                ..SetMeasureTextCmd {
5351                    part_index: 0,
5352                    staff_index: 0,
5353                    measure_index: 0,
5354                    text_index: 0,
5355                    text: None,
5356                }
5357            }))
5358            .unwrap();
5359        assert_eq!(
5360            engine.score.parts[0].staves[0].measures[0].texts[0].style,
5361            crate::TextStyle::RehearsalMark
5362        );
5363
5364        engine
5365            .apply(Command::SetMeasureText(SetMeasureTextCmd {
5366                text_index: 0,
5367                text: None,
5368                ..SetMeasureTextCmd {
5369                    part_index: 0,
5370                    staff_index: 0,
5371                    measure_index: 0,
5372                    text_index: 0,
5373                    text: None,
5374                }
5375            }))
5376            .unwrap();
5377        assert!(engine.score.parts[0].staves[0].measures[0].texts.is_empty());
5378        engine.undo().unwrap();
5379        assert_eq!(engine.score.parts[0].staves[0].measures[0].texts.len(), 1);
5380        engine.redo().unwrap();
5381        assert!(engine.score.parts[0].staves[0].measures[0].texts.is_empty());
5382    }
5383
5384    #[test]
5385    fn set_measure_text_rejects_invalid_index_atomically_and_round_trips_json() {
5386        let mut score = default_engine_score();
5387        let before = score.clone();
5388        let command = Command::SetMeasureText(SetMeasureTextCmd {
5389            part_index: 0,
5390            staff_index: 0,
5391            measure_index: 0,
5392            text_index: 2,
5393            text: Some(StyledText {
5394                position_beats: None,
5395                style: crate::TextStyle::Expression,
5396                text: "espressivo".to_string(),
5397                placement: None,
5398                offset_x: None,
5399                offset_y: None,
5400                relative_x: None,
5401                relative_y: None,
5402            }),
5403        });
5404        let json = serde_json::to_string(&command).unwrap();
5405        let restored: Command = serde_json::from_str(&json).unwrap();
5406        assert_eq!(
5407            serde_json::to_value(&restored).unwrap(),
5408            serde_json::to_value(&command).unwrap()
5409        );
5410        assert!(apply_command(&restored, &mut score).is_err());
5411        assert_eq!(
5412            serde_json::to_value(&score).unwrap(),
5413            serde_json::to_value(&before).unwrap()
5414        );
5415    }
5416
5417    #[test]
5418    fn set_score_text_supports_append_replace_remove_and_undo_redo() {
5419        let mut engine = crate::ScoreEngine::new();
5420        let text = StyledText {
5421            position_beats: None,
5422            style: crate::TextStyle::Expression,
5423            text: "Title".to_string(),
5424            placement: None,
5425            offset_x: None,
5426            offset_y: None,
5427            relative_x: None,
5428            relative_y: None,
5429        };
5430        engine
5431            .apply(Command::SetScoreText(SetScoreTextCmd {
5432                text_index: 0,
5433                text: Some(text.clone()),
5434            }))
5435            .unwrap();
5436        assert_eq!(engine.score.texts, vec![text.clone()]);
5437
5438        let mut replacement = text.clone();
5439        replacement.text = "Subtitle".to_string();
5440        engine
5441            .apply(Command::SetScoreText(SetScoreTextCmd {
5442                text_index: 0,
5443                text: Some(replacement.clone()),
5444            }))
5445            .unwrap();
5446        assert_eq!(engine.score.texts, vec![replacement]);
5447
5448        engine
5449            .apply(Command::SetScoreText(SetScoreTextCmd {
5450                text_index: 0,
5451                text: None,
5452            }))
5453            .unwrap();
5454        assert!(engine.score.texts.is_empty());
5455        engine.undo().unwrap();
5456        assert_eq!(engine.score.texts.len(), 1);
5457        engine.redo().unwrap();
5458        assert!(engine.score.texts.is_empty());
5459    }
5460
5461    #[test]
5462    fn set_score_style_overrides_is_undoable_and_rejects_invalid_values() {
5463        let mut engine = crate::ScoreEngine::new();
5464        let overrides = vec![ViewStyleOverride {
5465            property: super::super::score::ViewStyleProperty::SystemGap,
5466            value: 2.5,
5467        }];
5468        engine
5469            .apply(Command::SetScoreStyleOverrides(SetScoreStyleOverridesCmd {
5470                overrides: overrides.clone(),
5471            }))
5472            .unwrap();
5473        assert_eq!(engine.score.style_overrides, overrides);
5474        engine.undo().unwrap();
5475        assert!(engine.score.style_overrides.is_empty());
5476        engine.redo().unwrap();
5477        assert_eq!(engine.score.style_overrides, overrides);
5478
5479        let before = engine.score.clone();
5480        assert!(
5481            engine
5482                .apply(Command::SetScoreStyleOverrides(SetScoreStyleOverridesCmd {
5483                    overrides: vec![ViewStyleOverride {
5484                        property: super::super::score::ViewStyleProperty::TextScale,
5485                        value: f32::NAN,
5486                    }],
5487                }))
5488                .is_err()
5489        );
5490        assert_eq!(
5491            serde_json::to_value(&engine.score).unwrap(),
5492            serde_json::to_value(&before).unwrap()
5493        );
5494    }
5495
5496    #[test]
5497    fn set_object_style_overrides_is_undoable_and_requires_existing_target() {
5498        let mut engine = crate::ScoreEngine::new();
5499        engine.score.texts.push(StyledText {
5500            position_beats: None,
5501            style: crate::TextStyle::Expression,
5502            text: "Allegro".into(),
5503            placement: None,
5504            offset_x: None,
5505            offset_y: None,
5506            relative_x: None,
5507            relative_y: None,
5508        });
5509        let overrides = vec![ObjectStyleOverride {
5510            target: super::super::score::ObjectStyleTarget::ScoreText { text_index: 0 },
5511            property: super::super::score::ViewStyleProperty::TextScale,
5512            value: 1.2,
5513            provenance: None,
5514        }];
5515        engine
5516            .apply(Command::SetObjectStyleOverrides(
5517                SetObjectStyleOverridesCmd {
5518                    overrides: overrides.clone(),
5519                },
5520            ))
5521            .unwrap();
5522        assert_eq!(engine.score.object_style_overrides, overrides);
5523        engine.undo().unwrap();
5524        assert!(engine.score.object_style_overrides.is_empty());
5525
5526        assert!(
5527            engine
5528                .apply(Command::SetObjectStyleOverrides(
5529                    SetObjectStyleOverridesCmd {
5530                        overrides: vec![ObjectStyleOverride {
5531                            target: super::super::score::ObjectStyleTarget::ScoreText {
5532                                text_index: 1,
5533                            },
5534                            property: super::super::score::ViewStyleProperty::TextScale,
5535                            value: 1.2,
5536                            provenance: None,
5537                        }],
5538                    }
5539                ))
5540                .is_err()
5541        );
5542    }
5543
5544    #[test]
5545    fn set_harmony_range_is_undoable_and_json_compatible() {
5546        let mut score = default_engine_score();
5547        score.parts[0].staves[0].measures[0].voices[0] =
5548            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
5549        score.parts[0].staves[0].measures[0].voices[0][0].chord_symbol = Some(ChordSymbol {
5550            root: "C".to_owned(),
5551            kind: "major".to_owned(),
5552            bass: None,
5553            placement: None,
5554            extender: true,
5555            harmonic_degree: None,
5556            harmony_function: None,
5557            harmony_type: None,
5558            chord_ref: None,
5559            range_end: None,
5560            degrees: Vec::new(),
5561        });
5562        let mut engine = ScoreEngine::new();
5563        engine.replace_score(score);
5564        let command = Command::SetHarmonyRange(SetHarmonyRangeCmd {
5565            part_index: 0,
5566            staff_index: 0,
5567            measure_index: 0,
5568            voice: 0,
5569            note_index: 0,
5570            end: Some(NoteAddr {
5571                part: 0,
5572                staff: 0,
5573                measure: 0,
5574                voice: 0,
5575                note: 0,
5576            }),
5577        });
5578        let json = serde_json::to_string(&command).unwrap();
5579        let restored: Command = serde_json::from_str(&json).unwrap();
5580        engine.apply(restored).unwrap();
5581        assert!(
5582            engine.score.parts[0].staves[0].measures[0].voices[0][0]
5583                .chord_symbol
5584                .as_ref()
5585                .and_then(|chord| chord.range_end.as_ref())
5586                .is_some()
5587        );
5588        engine.undo().unwrap();
5589        assert!(
5590            engine.score.parts[0].staves[0].measures[0].voices[0][0]
5591                .chord_symbol
5592                .as_ref()
5593                .is_some_and(|chord| chord.range_end.is_none())
5594        );
5595    }
5596
5597    #[test]
5598    fn set_harp_pedal_diagrams_is_undoable_and_json_compatible() {
5599        let mut engine = ScoreEngine::new();
5600        let mut diagram = HarpPedalDiagram::default();
5601        diagram.positions[0] = super::super::score::HarpPedalPosition::Flat;
5602        diagram.positions[6] = super::super::score::HarpPedalPosition::Sharp;
5603        let command = Command::SetHarpPedalDiagrams(SetHarpPedalDiagramsCmd {
5604            part_index: 0,
5605            staff_index: 0,
5606            measure_index: 0,
5607            diagrams: vec![diagram.clone()],
5608        });
5609        let restored: Command =
5610            serde_json::from_str(&serde_json::to_string(&command).unwrap()).unwrap();
5611        engine.apply(restored).unwrap();
5612        assert_eq!(
5613            engine.score.parts[0].staves[0].measures[0].harp_pedal_diagrams,
5614            vec![diagram]
5615        );
5616        engine.undo().unwrap();
5617        assert!(
5618            engine.score.parts[0].staves[0].measures[0]
5619                .harp_pedal_diagrams
5620                .is_empty()
5621        );
5622    }
5623
5624    #[test]
5625    fn set_note_placement_is_undoable_and_rejects_non_finite_values() {
5626        let mut score = default_engine_score();
5627        score.parts[0].staves[0].measures[0].voices[0] =
5628            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5629        let mut engine = ScoreEngine::new();
5630        engine.replace_score(score);
5631        let command = Command::SetNotePlacement(SetNotePlacementCmd {
5632            part_index: 0,
5633            staff_index: 0,
5634            measure_index: 0,
5635            voice: 0,
5636            note_index: 0,
5637            offset_x: Some(12.5),
5638            offset_y: Some(-3.0),
5639            relative_x: Some(1.25),
5640            relative_y: Some(-0.5),
5641        });
5642        engine.apply(command).unwrap();
5643        let note = &engine.score.parts[0].staves[0].measures[0].voices[0][0];
5644        assert_eq!(note.offset_x, Some(12.5));
5645        assert_eq!(note.relative_y, Some(-0.5));
5646        engine.undo().unwrap();
5647        assert_eq!(
5648            engine.score.parts[0].staves[0].measures[0].voices[0][0].offset_x,
5649            None
5650        );
5651        let invalid = Command::SetNotePlacement(SetNotePlacementCmd {
5652            part_index: 0,
5653            staff_index: 0,
5654            measure_index: 0,
5655            voice: 0,
5656            note_index: 0,
5657            offset_x: Some(f64::NAN),
5658            offset_y: None,
5659            relative_x: None,
5660            relative_y: None,
5661        });
5662        assert!(engine.apply(invalid).is_err());
5663        assert_eq!(
5664            engine.score.parts[0].staves[0].measures[0].voices[0][0].offset_x,
5665            None
5666        );
5667    }
5668
5669    #[test]
5670    fn set_guitar_bend_alter_updates_note_and_can_clear() {
5671        let mut score = default_engine_score();
5672        apply_command(
5673            &Command::AddNote(AddNoteCmd {
5674                part_index: 0,
5675                staff_index: 0,
5676                measure_index: 0,
5677                voice: 0,
5678                position: 0,
5679                pitch: Some(Pitch::new(Step::G, 4)),
5680                duration: Duration::Quarter,
5681                dot_count: 0,
5682                is_rest: false,
5683                tuplet: None,
5684            }),
5685            &mut score,
5686        )
5687        .unwrap();
5688        let location = SetGuitarBendAlterCmd {
5689            part_index: 0,
5690            staff_index: 0,
5691            measure_index: 0,
5692            voice: 0,
5693            note_index: 0,
5694            alter_cents: Some(200),
5695        };
5696        apply_command(&Command::SetGuitarBendAlter(location.clone()), &mut score).unwrap();
5697        assert_eq!(
5698            score.parts[0].staves[0].measures[0].voices[0][0].guitar_bend_alter_cents,
5699            Some(200)
5700        );
5701        let mut cleared = location;
5702        cleared.alter_cents = None;
5703        apply_command(&Command::SetGuitarBendAlter(cleared), &mut score).unwrap();
5704        assert_eq!(
5705            score.parts[0].staves[0].measures[0].voices[0][0].guitar_bend_alter_cents,
5706            None
5707        );
5708    }
5709
5710    #[test]
5711    fn set_guitar_bend_curve_is_undoable_and_json_compatible() {
5712        let mut score = Score::new("T", 120, 4, 4, 0, 1);
5713        let command = Command::SetGuitarBendCurve(SetGuitarBendCurveCmd {
5714            part_index: 0,
5715            staff_index: 0,
5716            measure_index: 0,
5717            voice: 0,
5718            note_index: 0,
5719            points: vec![
5720                crate::GuitarBendPoint {
5721                    position_per_mille: 0,
5722                    alter_cents: 0,
5723                },
5724                crate::GuitarBendPoint {
5725                    position_per_mille: 500,
5726                    alter_cents: 200,
5727                },
5728                crate::GuitarBendPoint {
5729                    position_per_mille: 1000,
5730                    alter_cents: 0,
5731                },
5732            ],
5733        });
5734        let decoded: Command =
5735            serde_json::from_str(&serde_json::to_string(&command).unwrap()).unwrap();
5736        let mut stack = CommandStack::new(16);
5737        stack.execute(decoded, &mut score).unwrap();
5738        assert_eq!(
5739            score.parts[0].staves[0].measures[0].voices[0][0]
5740                .guitar_bend_curve
5741                .len(),
5742            3
5743        );
5744        stack.undo(&mut score).unwrap();
5745        assert!(
5746            score.parts[0].staves[0].measures[0].voices[0][0]
5747                .guitar_bend_curve
5748                .is_empty()
5749        );
5750    }
5751
5752    #[test]
5753    fn set_duration_updates_note_and_preserves_measure_capacity() {
5754        let mut score = default_engine_score();
5755        apply_command(
5756            &Command::AddNote(AddNoteCmd {
5757                part_index: 0,
5758                staff_index: 0,
5759                measure_index: 0,
5760                voice: 0,
5761                position: 0,
5762                pitch: Some(Pitch::new(Step::C, 4)),
5763                duration: Duration::Quarter,
5764                dot_count: 0,
5765                is_rest: false,
5766                tuplet: None,
5767            }),
5768            &mut score,
5769        )
5770        .unwrap();
5771        apply_command(
5772            &Command::SetDuration(SetDurationCmd {
5773                part_index: 0,
5774                staff_index: 0,
5775                measure_index: 0,
5776                voice: 0,
5777                note_index: 0,
5778                duration: Duration::Half,
5779                dot_count: 1,
5780            }),
5781            &mut score,
5782        )
5783        .unwrap();
5784        let voice = &score.parts[0].staves[0].measures[0].voices[0];
5785        assert_eq!(voice[0].duration, Duration::Half);
5786        assert_eq!(voice[0].dot_count, 1);
5787        assert!((voice.iter().map(|note| note.beats()).sum::<f64>() - 4.0).abs() < 1e-9);
5788    }
5789
5790    #[test]
5791    fn set_tempo_updates_score() {
5792        let mut score = default_engine_score();
5793        let cmd = Command::SetTempo(SetTempoCmd { bpm: 160 });
5794        apply_command(&cmd, &mut score).unwrap();
5795        assert_eq!(score.settings.tempo_bpm, 160);
5796    }
5797
5798    #[test]
5799    fn add_measure_increases_count() {
5800        let mut score = default_engine_score();
5801        let before = score.measure_count();
5802        apply_command(
5803            &Command::AddMeasure(AddMeasureCmd { after_index: 0 }),
5804            &mut score,
5805        )
5806        .unwrap();
5807        assert_eq!(score.measure_count(), before + 1);
5808    }
5809
5810    #[test]
5811    fn delete_measure_decreases_count() {
5812        let mut score = default_engine_score();
5813        let before = score.measure_count();
5814        apply_command(
5815            &Command::DeleteMeasure(DeleteMeasureCmd { measure_index: 0 }),
5816            &mut score,
5817        )
5818        .unwrap();
5819        assert_eq!(score.measure_count(), before - 1);
5820    }
5821
5822    #[test]
5823    fn undo_restores_score() {
5824        let mut stack = CommandStack::new(50);
5825        let mut score = default_engine_score();
5826        let before = score.settings.tempo_bpm;
5827        stack
5828            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
5829            .unwrap();
5830        assert_eq!(score.settings.tempo_bpm, 200);
5831        stack.undo(&mut score).unwrap();
5832        assert_eq!(score.settings.tempo_bpm, before);
5833    }
5834
5835    #[test]
5836    fn redo_reapplies_command() {
5837        let mut stack = CommandStack::new(50);
5838        let mut score = default_engine_score();
5839        stack
5840            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
5841            .unwrap();
5842        stack.undo(&mut score).unwrap();
5843        stack.redo(&mut score).unwrap();
5844        assert_eq!(score.settings.tempo_bpm, 200);
5845    }
5846
5847    #[test]
5848    fn undo_nothing_returns_error() {
5849        let mut stack = CommandStack::new(50);
5850        let mut score = default_engine_score();
5851        assert!(stack.undo(&mut score).is_err());
5852    }
5853
5854    #[test]
5855    fn add_part_appends_part() {
5856        let mut score = default_engine_score();
5857        let before = score.parts.len();
5858        apply_command(
5859            &Command::AddPart(AddPartCmd {
5860                name: "Violin".into(),
5861                short_name: "Vln.".into(),
5862                clefs: vec!["Treble".into()],
5863                midi_channel: 0,
5864                midi_program: 0,
5865            }),
5866            &mut score,
5867        )
5868        .unwrap();
5869        assert_eq!(score.parts.len(), before + 1);
5870    }
5871
5872    #[test]
5873    fn delete_part_removes_part() {
5874        let mut score = default_engine_score();
5875        apply_command(
5876            &Command::AddPart(AddPartCmd {
5877                name: "Violin".into(),
5878                short_name: "V.".into(),
5879                clefs: vec!["Treble".into()],
5880                midi_channel: 0,
5881                midi_program: 0,
5882            }),
5883            &mut score,
5884        )
5885        .unwrap();
5886        let before = score.parts.len();
5887        apply_command(
5888            &Command::DeletePart(DeletePartCmd { part_index: 0 }),
5889            &mut score,
5890        )
5891        .unwrap();
5892        assert_eq!(score.parts.len(), before - 1);
5893    }
5894
5895    #[test]
5896    fn delete_part_out_of_range_returns_err() {
5897        let mut score = default_engine_score();
5898        assert!(
5899            apply_command(
5900                &Command::DeletePart(DeletePartCmd { part_index: 99 }),
5901                &mut score
5902            )
5903            .is_err()
5904        );
5905    }
5906
5907    #[test]
5908    fn delete_part_undo_restores_part() {
5909        let mut stack = CommandStack::new(50);
5910        let mut score = default_engine_score();
5911        apply_command(
5912            &Command::AddPart(AddPartCmd {
5913                name: "Violin".into(),
5914                short_name: "V.".into(),
5915                clefs: vec!["Treble".into()],
5916                midi_channel: 0,
5917                midi_program: 0,
5918            }),
5919            &mut score,
5920        )
5921        .unwrap();
5922        let before = score.parts.len();
5923        stack
5924            .execute(
5925                Command::DeletePart(DeletePartCmd { part_index: 0 }),
5926                &mut score,
5927            )
5928            .unwrap();
5929        assert_eq!(score.parts.len(), before - 1);
5930        stack.undo(&mut score).unwrap();
5931        assert_eq!(score.parts.len(), before);
5932    }
5933
5934    #[test]
5935    fn reorder_parts_remaps_linked_views_and_preserves_undo() {
5936        let mut engine = crate::ScoreEngine::new();
5937        engine
5938            .apply(Command::AddPart(AddPartCmd {
5939                name: "Flute".into(),
5940                short_name: "Fl.".into(),
5941                clefs: vec!["Treble".into()],
5942                midi_channel: 1,
5943                midi_program: 73,
5944            }))
5945            .unwrap();
5946        engine
5947            .score
5948            .views
5949            .push(ScoreView::linked_part("flute", "Flute", 1));
5950        engine.score.part_groups.push(PartGroup {
5951            first_part: 0,
5952            last_part: 1,
5953            symbol: super::super::score::PartGroupSymbol::Bracket,
5954            barlines_connect: false,
5955        });
5956
5957        engine
5958            .apply(Command::ReorderParts(ReorderPartsCmd { order: vec![1, 0] }))
5959            .unwrap();
5960        assert_eq!(engine.score.parts[0].name, "Flute");
5961        assert_eq!(engine.score.views[0].parts, vec![0]);
5962        engine.undo().unwrap();
5963        assert_eq!(engine.score.parts[0].name, "Piano");
5964        assert_eq!(engine.score.views[0].parts, vec![1]);
5965    }
5966
5967    #[test]
5968    fn reorder_parts_rejects_splitting_a_part_group_without_mutation() {
5969        let mut score = Score::template(ScoreTemplate::StringQuartet);
5970        score.part_groups.push(PartGroup {
5971            first_part: 0,
5972            last_part: 1,
5973            symbol: super::super::score::PartGroupSymbol::Bracket,
5974            barlines_connect: false,
5975        });
5976        let before = serde_json::to_value(&score).unwrap();
5977
5978        assert!(
5979            apply_command(
5980                &Command::ReorderParts(ReorderPartsCmd {
5981                    order: vec![0, 2, 1, 3],
5982                }),
5983                &mut score,
5984            )
5985            .is_err()
5986        );
5987        assert_eq!(serde_json::to_value(&score).unwrap(), before);
5988    }
5989
5990    #[test]
5991    fn set_metadata_updates_title() {
5992        let mut score = default_engine_score();
5993        apply_command(
5994            &Command::SetMetadata(SetMetadataCmd {
5995                title: Some("New Title".into()),
5996                ..Default::default()
5997            }),
5998            &mut score,
5999        )
6000        .unwrap();
6001        assert_eq!(score.metadata.title, "New Title");
6002    }
6003
6004    #[test]
6005    fn set_metadata_none_fields_skipped() {
6006        let mut score = default_engine_score();
6007        let original_composer = score.metadata.composer.clone();
6008        apply_command(
6009            &Command::SetMetadata(SetMetadataCmd {
6010                title: Some("X".into()),
6011                ..Default::default()
6012            }),
6013            &mut score,
6014        )
6015        .unwrap();
6016        assert_eq!(score.metadata.composer, original_composer);
6017    }
6018
6019    #[test]
6020    fn set_volta_sets_bracket() {
6021        use crate::model::score::VoltaBracket;
6022        let mut score = default_engine_score();
6023        let volta = VoltaBracket {
6024            number: 1,
6025            kind: "begin_end".into(),
6026            numbers: Vec::new(),
6027        };
6028        apply_command(
6029            &Command::SetVolta(SetVoltaCmd {
6030                measure_index: 0,
6031                volta: Some(volta.clone()),
6032            }),
6033            &mut score,
6034        )
6035        .unwrap();
6036        assert!(score.parts[0].staves[0].measures[0].volta.is_some());
6037    }
6038
6039    #[test]
6040    fn set_volta_none_clears_bracket() {
6041        use crate::model::score::VoltaBracket;
6042        let mut score = default_engine_score();
6043        score.parts[0].staves[0].measures[0].volta = Some(VoltaBracket {
6044            number: 1,
6045            kind: "begin_end".into(),
6046            numbers: Vec::new(),
6047        });
6048        apply_command(
6049            &Command::SetVolta(SetVoltaCmd {
6050                measure_index: 0,
6051                volta: None,
6052            }),
6053            &mut score,
6054        )
6055        .unwrap();
6056        assert!(score.parts[0].staves[0].measures[0].volta.is_none());
6057    }
6058
6059    #[test]
6060    fn set_volta_undo_restores_old() {
6061        use crate::model::score::VoltaBracket;
6062        let mut stack = CommandStack::new(50);
6063        let mut score = default_engine_score();
6064        stack
6065            .execute(
6066                Command::SetVolta(SetVoltaCmd {
6067                    measure_index: 0,
6068                    volta: Some(VoltaBracket {
6069                        number: 1,
6070                        kind: "begin_end".into(),
6071                        numbers: Vec::new(),
6072                    }),
6073                }),
6074                &mut score,
6075            )
6076            .unwrap();
6077        stack.undo(&mut score).unwrap();
6078        assert!(score.parts[0].staves[0].measures[0].volta.is_none());
6079    }
6080
6081    #[test]
6082    fn set_clef_updates_staff_clef() {
6083        use crate::model::notation::Clef;
6084        let mut score = default_engine_score();
6085        apply_command(
6086            &Command::SetClef(SetClefCmd {
6087                part_index: 0,
6088                staff_index: 0,
6089                clef: Clef::Bass,
6090            }),
6091            &mut score,
6092        )
6093        .unwrap();
6094        assert_eq!(score.parts[0].staves[0].clef, Clef::Bass);
6095    }
6096
6097    #[test]
6098    fn set_clef_out_of_range_returns_err() {
6099        use crate::model::notation::Clef;
6100        let mut score = default_engine_score();
6101        assert!(
6102            apply_command(
6103                &Command::SetClef(SetClefCmd {
6104                    part_index: 99,
6105                    staff_index: 0,
6106                    clef: Clef::Bass,
6107                }),
6108                &mut score
6109            )
6110            .is_err()
6111        );
6112    }
6113
6114    #[test]
6115    fn set_part_name_updates_name() {
6116        let mut score = default_engine_score();
6117        apply_command(
6118            &Command::SetPartName(SetPartNameCmd {
6119                part_index: 0,
6120                name: "Violin".into(),
6121                short_name: "Vln.".into(),
6122            }),
6123            &mut score,
6124        )
6125        .unwrap();
6126        assert_eq!(score.parts[0].name, "Violin");
6127        assert_eq!(score.parts[0].short_name, "Vln.");
6128    }
6129
6130    #[test]
6131    fn set_part_name_undo_restores_old() {
6132        let mut stack = CommandStack::new(50);
6133        let mut score = default_engine_score();
6134        let original = score.parts[0].name.clone();
6135        stack
6136            .execute(
6137                Command::SetPartName(SetPartNameCmd {
6138                    part_index: 0,
6139                    name: "Flute".into(),
6140                    short_name: "Fl.".into(),
6141                }),
6142                &mut score,
6143            )
6144            .unwrap();
6145        stack.undo(&mut score).unwrap();
6146        assert_eq!(score.parts[0].name, original);
6147    }
6148
6149    #[test]
6150    fn set_metadata_undo_restores_old_title() {
6151        let mut stack = CommandStack::new(50);
6152        let mut score = default_engine_score();
6153        let original = score.metadata.title.clone();
6154        stack
6155            .execute(
6156                Command::SetMetadata(SetMetadataCmd {
6157                    title: Some("Changed".into()),
6158                    ..Default::default()
6159                }),
6160                &mut score,
6161            )
6162            .unwrap();
6163        assert_ne!(score.metadata.title, original);
6164        stack.undo(&mut score).unwrap();
6165        assert_eq!(score.metadata.title, original);
6166    }
6167
6168    #[test]
6169    fn set_midi_instrument_updates_channel_and_program() {
6170        let mut score = default_engine_score();
6171        apply_command(
6172            &Command::SetMidiInstrument(SetMidiInstrumentCmd {
6173                part_index: 0,
6174                midi_channel: 2,
6175                midi_program: 40,
6176            }),
6177            &mut score,
6178        )
6179        .unwrap();
6180        assert_eq!(score.parts[0].midi_channel, 2);
6181        assert_eq!(score.parts[0].midi_program, 40);
6182    }
6183
6184    #[test]
6185    fn set_midi_instrument_clamps_channel_to_15() {
6186        let mut score = default_engine_score();
6187        apply_command(
6188            &Command::SetMidiInstrument(SetMidiInstrumentCmd {
6189                part_index: 0,
6190                midi_channel: 20,
6191                midi_program: 0,
6192            }),
6193            &mut score,
6194        )
6195        .unwrap();
6196        assert_eq!(score.parts[0].midi_channel, 15);
6197    }
6198
6199    #[test]
6200    fn set_midi_instrument_undo_restores_old() {
6201        let mut stack = CommandStack::new(50);
6202        let mut score = default_engine_score();
6203        score.parts[0].midi_channel = 3;
6204        score.parts[0].midi_program = 10;
6205        stack
6206            .execute(
6207                Command::SetMidiInstrument(SetMidiInstrumentCmd {
6208                    part_index: 0,
6209                    midi_channel: 9,
6210                    midi_program: 114,
6211                }),
6212                &mut score,
6213            )
6214            .unwrap();
6215        stack.undo(&mut score).unwrap();
6216        assert_eq!(score.parts[0].midi_channel, 3);
6217        assert_eq!(score.parts[0].midi_program, 10);
6218    }
6219
6220    #[test]
6221    fn set_transpose_updates_staff() {
6222        let mut score = default_engine_score();
6223        apply_command(
6224            &Command::SetTranspose(SetTransposeCmd {
6225                part_index: 0,
6226                staff_index: 0,
6227                semitones: -2,
6228            }),
6229            &mut score,
6230        )
6231        .unwrap();
6232        assert_eq!(score.parts[0].staves[0].transpose_semitones, -2);
6233    }
6234
6235    #[test]
6236    fn set_transpose_out_of_range_returns_err() {
6237        let mut score = default_engine_score();
6238        assert!(
6239            apply_command(
6240                &Command::SetTranspose(SetTransposeCmd {
6241                    part_index: 99,
6242                    staff_index: 0,
6243                    semitones: -2,
6244                }),
6245                &mut score
6246            )
6247            .is_err()
6248        );
6249    }
6250
6251    #[test]
6252    fn transpose_staff_region_is_undoable_and_json_compatible() {
6253        let mut stack = CommandStack::new(50);
6254        let mut score = Score::new("Region", 120, 4, 4, 0, 2);
6255        for measure in &mut score.parts[0].staves[0].measures {
6256            measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
6257        }
6258        let command = Command::TransposeStaffRegion(TransposeStaffRegionCmd {
6259            part_index: 0,
6260            staff_index: 0,
6261            start_measure: 1,
6262            end_measure: 2,
6263            semitones: 2,
6264            target: RegionalTranspositionTarget::Written,
6265        });
6266        let json = serde_json::to_string(&command).expect("command serializes");
6267        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
6268        assert_eq!(command_key(&decoded), "TransposeStaffRegion");
6269        stack.execute(decoded, &mut score).expect("region applies");
6270        assert_eq!(
6271            score.parts[0].staves[0].measures[0].voices[0][0].pitches[0].to_midi(),
6272            60
6273        );
6274        assert_eq!(
6275            score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].to_midi(),
6276            62
6277        );
6278        stack.undo(&mut score).expect("region undo applies");
6279        assert_eq!(
6280            score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].to_midi(),
6281            60
6282        );
6283    }
6284
6285    #[test]
6286    fn set_tempo_at_measure_sets_tempo() {
6287        let mut score = default_engine_score();
6288        apply_command(
6289            &Command::SetTempoAtMeasure(SetTempoAtMeasureCmd {
6290                measure_index: 0,
6291                bpm: Some(80),
6292            }),
6293            &mut score,
6294        )
6295        .unwrap();
6296        assert_eq!(score.parts[0].staves[0].measures[0].tempo, Some(80));
6297    }
6298
6299    #[test]
6300    fn set_tempo_at_measure_none_clears_tempo() {
6301        let mut score = default_engine_score();
6302        score.parts[0].staves[0].measures[0].tempo = Some(120);
6303        apply_command(
6304            &Command::SetTempoAtMeasure(SetTempoAtMeasureCmd {
6305                measure_index: 0,
6306                bpm: None,
6307            }),
6308            &mut score,
6309        )
6310        .unwrap();
6311        assert!(score.parts[0].staves[0].measures[0].tempo.is_none());
6312    }
6313
6314    #[test]
6315    fn set_tempo_ramp_at_measure_applies() {
6316        let mut score = default_engine_score();
6317        apply_command(
6318            &Command::SetTempoRampAtMeasure(SetTempoRampAtMeasureCmd {
6319                measure_index: 0,
6320                target_bpm: Some(72),
6321            }),
6322            &mut score,
6323        )
6324        .expect("tempo ramp applies");
6325        assert_eq!(score.parts[0].staves[0].measures[0].tempo_ramp_to, Some(72));
6326    }
6327
6328    // ── batch_execute ─────────────────────────────────────────────────────────
6329
6330    #[test]
6331    fn batch_execute_two_commands_single_undo() {
6332        let mut stack = CommandStack::new(50);
6333        let mut score = default_engine_score();
6334        let original_bpm = score.settings.tempo_bpm;
6335        stack
6336            .batch_execute(
6337                vec![
6338                    Command::SetTempo(SetTempoCmd { bpm: 160 }),
6339                    Command::SetTempo(SetTempoCmd { bpm: 180 }),
6340                ],
6341                &mut score,
6342            )
6343            .unwrap();
6344        assert_eq!(score.settings.tempo_bpm, 180);
6345        stack.undo(&mut score).unwrap();
6346        assert_eq!(score.settings.tempo_bpm, original_bpm);
6347    }
6348
6349    #[test]
6350    fn batch_execute_partial_failure_rollback() {
6351        let mut stack = CommandStack::new(50);
6352        let mut score = default_engine_score();
6353        let original_bpm = score.settings.tempo_bpm;
6354        let result = stack.batch_execute(
6355            vec![
6356                Command::SetTempo(SetTempoCmd { bpm: 160 }),
6357                Command::DeleteNote(DeleteNoteCmd {
6358                    note_id: "nonexistent".into(),
6359                    part_index: 99,
6360                    staff_index: 0,
6361                    measure_index: 0,
6362                    voice: 0,
6363                }),
6364            ],
6365            &mut score,
6366        );
6367        assert!(result.is_err());
6368        assert_eq!(score.settings.tempo_bpm, original_bpm);
6369    }
6370
6371    #[test]
6372    fn execute_rejects_invalid_candidate_without_mutating_or_recording_history() {
6373        let mut stack = CommandStack::new(50);
6374        let mut score = default_engine_score();
6375        score.parts[0].staves[0].tablature = Some(crate::TablatureConfig {
6376            lines: 6,
6377            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6378            capo: 0,
6379        });
6380        stack
6381            .execute(
6382                Command::AddNote(AddNoteCmd {
6383                    part_index: 0,
6384                    staff_index: 0,
6385                    measure_index: 0,
6386                    voice: 0,
6387                    position: 0,
6388                    pitch: Some(Pitch::new(Step::C, 4)),
6389                    duration: Duration::Quarter,
6390                    dot_count: 0,
6391                    is_rest: false,
6392                    tuplet: None,
6393                }),
6394                &mut score,
6395            )
6396            .expect("valid pitched note should be accepted");
6397        let original = score.clone();
6398        let result = stack.execute(
6399            Command::SetTabPosition(SetTabPositionCmd {
6400                part_index: 0,
6401                staff_index: 0,
6402                measure_index: 0,
6403                voice: 0,
6404                note_index: 0,
6405                position: Some(crate::TabPosition { string: 7, fret: 0 }),
6406            }),
6407            &mut score,
6408        );
6409        assert!(matches!(result, Err(Error::InvalidScore)));
6410        let note = &score.parts[0].staves[0].measures[0].voices[0][0];
6411        let original_note = &original.parts[0].staves[0].measures[0].voices[0][0];
6412        assert_eq!(note.pitches, original_note.pitches);
6413        assert_eq!(note.duration, original_note.duration);
6414        assert_eq!(note.tab_position, original_note.tab_position);
6415        assert_eq!(note.tab_positions, original_note.tab_positions);
6416        assert!(stack.can_undo());
6417    }
6418
6419    #[test]
6420    fn set_tablature_config_is_undoable_and_json_compatible() {
6421        let mut stack = CommandStack::new(50);
6422        let mut score = default_engine_score();
6423        let config = crate::TablatureConfig {
6424            lines: 6,
6425            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6426            capo: 2,
6427        };
6428        let command = Command::SetTablatureConfig(SetTablatureConfigCmd {
6429            part_index: 0,
6430            staff_index: 0,
6431            config: Some(config.clone()),
6432        });
6433        let json = serde_json::to_string(&command).expect("command should serialize");
6434        let decoded: Command = serde_json::from_str(&json).expect("command should deserialize");
6435        assert_eq!(command_key(&decoded), "SetTablatureConfig");
6436        stack
6437            .execute(decoded, &mut score)
6438            .expect("config should apply");
6439        assert_eq!(score.parts[0].staves[0].tablature, Some(config));
6440        assert_eq!(
6441            score.parts[0].staves[0].presentation.kind,
6442            StaffKind::Tablature
6443        );
6444        stack.undo(&mut score).expect("config undo should apply");
6445        assert!(score.parts[0].staves[0].tablature.is_none());
6446        assert_eq!(
6447            score.parts[0].staves[0].presentation.kind,
6448            StaffKind::Standard
6449        );
6450        stack.redo(&mut score).expect("config redo should apply");
6451        assert_eq!(
6452            score.parts[0].staves[0]
6453                .tablature
6454                .as_ref()
6455                .map(|tab| tab.capo),
6456            Some(2)
6457        );
6458    }
6459
6460    #[test]
6461    fn set_staff_presentation_is_undoable_and_requires_tablature_config() {
6462        let mut stack = CommandStack::new(50);
6463        let mut score = default_engine_score();
6464        let tab = StaffPresentation {
6465            kind: StaffKind::Tablature,
6466            lines: 6,
6467            line_distance: 1.25,
6468            small: true,
6469            cutaway: false,
6470            visible: true,
6471            notehead_scheme: super::super::score::StaffNoteheadScheme::Standard,
6472            tablature_rhythm_display: super::super::score::TablatureRhythmDisplay::FretOnly,
6473            tablature_fret_mark_style: super::super::score::TablatureFretMarkStyle::Arabic,
6474        };
6475        let rejected = Command::SetStaffPresentation(SetStaffPresentationCmd {
6476            part_index: 0,
6477            staff_index: 0,
6478            presentation: tab.clone(),
6479        });
6480        let before = score.clone();
6481        assert!(stack.execute(rejected, &mut score).is_err());
6482        assert_eq!(
6483            score.parts[0].staves[0].presentation,
6484            before.parts[0].staves[0].presentation
6485        );
6486        assert!(!stack.can_undo());
6487
6488        stack
6489            .execute(
6490                Command::SetTablatureConfig(SetTablatureConfigCmd {
6491                    part_index: 0,
6492                    staff_index: 0,
6493                    config: Some(crate::TablatureConfig {
6494                        lines: 6,
6495                        tuning_midi: vec![40, 45, 50, 55, 59, 64],
6496                        capo: 0,
6497                    }),
6498                }),
6499                &mut score,
6500            )
6501            .expect("tablature configuration should apply");
6502        stack
6503            .execute(
6504                Command::SetStaffPresentation(SetStaffPresentationCmd {
6505                    part_index: 0,
6506                    staff_index: 0,
6507                    presentation: tab.clone(),
6508                }),
6509                &mut score,
6510            )
6511            .expect("staff presentation should apply");
6512        assert_eq!(stack.undo_key().as_deref(), Some("SetStaffPresentation"));
6513        assert_eq!(score.parts[0].staves[0].presentation, tab);
6514        stack
6515            .undo(&mut score)
6516            .expect("presentation undo should apply");
6517        assert_eq!(
6518            score.parts[0].staves[0].presentation.kind,
6519            StaffKind::Tablature
6520        );
6521        stack
6522            .redo(&mut score)
6523            .expect("presentation redo should apply");
6524        assert_eq!(
6525            score.parts[0].staves[0].presentation.kind,
6526            StaffKind::Tablature
6527        );
6528    }
6529
6530    #[test]
6531    fn set_instrument_definition_is_undoable_and_rejects_invalid_ranges() {
6532        let mut stack = CommandStack::new(50);
6533        let mut score = default_engine_score();
6534        let mut invalid = InstrumentDefinition::new("violin", "Violin");
6535        invalid.written_range = Some(InstrumentRange {
6536            lowest: 100,
6537            highest: 55,
6538        });
6539        let before = score.clone();
6540        assert!(
6541            stack
6542                .execute(
6543                    Command::SetInstrumentDefinition(SetInstrumentDefinitionCmd {
6544                        part_index: 0,
6545                        definition: Some(invalid),
6546                    }),
6547                    &mut score,
6548                )
6549                .is_err()
6550        );
6551        assert_eq!(score.parts[0].instrument, before.parts[0].instrument);
6552
6553        let mut definition = InstrumentDefinition::new("violin", "Violin");
6554        definition.short_name = "Vln.".to_string();
6555        definition.family = Some("strings".to_string());
6556        definition.written_range = Some(InstrumentRange {
6557            lowest: 55,
6558            highest: 103,
6559        });
6560        definition.sounding_range = definition.written_range;
6561        definition.default_clefs = vec![Clef::Treble];
6562        definition.midi_program = 40;
6563        let command = Command::SetInstrumentDefinition(SetInstrumentDefinitionCmd {
6564            part_index: 0,
6565            definition: Some(definition.clone()),
6566        });
6567        let json = serde_json::to_string(&command).expect("command should serialize");
6568        let decoded: Command = serde_json::from_str(&json).expect("command should deserialize");
6569        assert_eq!(command_key(&decoded), "SetInstrumentDefinition");
6570        stack
6571            .execute(decoded, &mut score)
6572            .expect("definition applies");
6573        assert_eq!(score.parts[0].instrument, Some(definition));
6574        stack.undo(&mut score).expect("definition undo applies");
6575        assert!(score.parts[0].instrument.is_none());
6576        stack.redo(&mut score).expect("definition redo applies");
6577        assert_eq!(
6578            score.parts[0]
6579                .instrument
6580                .as_ref()
6581                .map(|value| value.id.as_str()),
6582            Some("violin")
6583        );
6584    }
6585
6586    #[test]
6587    fn set_percussion_kit_is_undoable_and_rejects_invalid_entries() {
6588        let mut stack = CommandStack::new(50);
6589        let mut score = default_engine_score();
6590        let invalid = Command::SetPercussionKit(SetPercussionKitCmd {
6591            part_index: 0,
6592            instruments: vec![PercussionInstrument {
6593                id: "snare".to_string(),
6594                name: None,
6595                midi_unpitched: Some(38),
6596                staff_position: Some(33),
6597                notehead: None,
6598                preferred_voice: None,
6599                techniques: Vec::new(),
6600            }],
6601        });
6602        assert!(stack.execute(invalid, &mut score).is_err());
6603        assert!(score.parts[0].percussion_instruments.is_empty());
6604
6605        let kit = vec![PercussionInstrument {
6606            id: "snare".to_string(),
6607            name: Some("Acoustic Snare".to_string()),
6608            midi_unpitched: Some(38),
6609            staff_position: Some(0),
6610            notehead: Some(NoteHead::Cross),
6611            preferred_voice: Some(1),
6612            techniques: vec!["rim-shot".to_string()],
6613        }];
6614        let command = Command::SetPercussionKit(SetPercussionKitCmd {
6615            part_index: 0,
6616            instruments: kit.clone(),
6617        });
6618        let json = serde_json::to_string(&command).expect("command serializes");
6619        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
6620        assert_eq!(command_key(&decoded), "SetPercussionKit");
6621        stack.execute(decoded, &mut score).expect("kit applies");
6622        assert_eq!(score.parts[0].percussion_instruments, kit);
6623        stack.undo(&mut score).expect("kit undo applies");
6624        assert!(score.parts[0].percussion_instruments.is_empty());
6625        stack.redo(&mut score).expect("kit redo applies");
6626        assert_eq!(score.parts[0].percussion_instruments, kit);
6627    }
6628
6629    #[test]
6630    fn set_measure_instrument_change_is_undoable_and_json_compatible() {
6631        let mut stack = CommandStack::new(50);
6632        let mut score = default_engine_score();
6633        let mut definition = InstrumentDefinition::new("clarinet-bb", "B-flat Clarinet");
6634        definition.transpose_semitones = -2;
6635        definition.midi_program = 71;
6636        let command = Command::SetMeasureInstrumentChange(SetMeasureInstrumentChangeCmd {
6637            part_index: 0,
6638            staff_index: 0,
6639            measure_index: 2,
6640            definition: Some(definition.clone()),
6641        });
6642        let json = serde_json::to_string(&command).expect("command should serialize");
6643        let decoded: Command = serde_json::from_str(&json).expect("command should deserialize");
6644        assert_eq!(command_key(&decoded), "SetMeasureInstrumentChange");
6645        stack.execute(decoded, &mut score).expect("change applies");
6646        assert_eq!(
6647            score.parts[0].staves[0].measures[2].instrument_change,
6648            Some(definition)
6649        );
6650        stack.undo(&mut score).expect("undo applies");
6651        assert!(
6652            score.parts[0].staves[0].measures[2]
6653                .instrument_change
6654                .is_none()
6655        );
6656    }
6657
6658    #[test]
6659    fn set_measure_tablature_change_is_undoable_and_uses_base_line_count() {
6660        let mut stack = CommandStack::new(50);
6661        let mut score = default_engine_score();
6662        let base = crate::TablatureConfig {
6663            lines: 6,
6664            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6665            capo: 0,
6666        };
6667        score.parts[0].staves[0].tablature = Some(base.clone());
6668        let mut changed = base;
6669        changed.tuning_midi[0] = 38;
6670        changed.capo = 2;
6671        let command = Command::SetMeasureTablatureChange(SetMeasureTablatureChangeCmd {
6672            part_index: 0,
6673            staff_index: 0,
6674            measure_index: 2,
6675            config: Some(changed.clone()),
6676        });
6677        let json = serde_json::to_string(&command).expect("command serializes");
6678        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
6679        assert_eq!(command_key(&decoded), "SetMeasureTablatureChange");
6680        stack.execute(decoded, &mut score).expect("change applies");
6681        assert_eq!(
6682            score.parts[0].staves[0].tablature_at(1),
6683            score.parts[0].staves[0].tablature
6684        );
6685        assert_eq!(score.parts[0].staves[0].tablature_at(2), Some(changed));
6686        stack.undo(&mut score).expect("change undo applies");
6687        assert!(
6688            score.parts[0].staves[0].measures[2]
6689                .tablature_change
6690                .is_none()
6691        );
6692
6693        let invalid = Command::SetMeasureTablatureChange(SetMeasureTablatureChangeCmd {
6694            part_index: 0,
6695            staff_index: 0,
6696            measure_index: 1,
6697            config: Some(crate::TablatureConfig {
6698                lines: 7,
6699                tuning_midi: vec![40, 45, 50, 55, 59, 64, 69],
6700                capo: 0,
6701            }),
6702        });
6703        assert!(stack.execute(invalid, &mut score).is_err());
6704    }
6705
6706    #[test]
6707    fn batch_execute_empty_is_noop() {
6708        let mut stack = CommandStack::new(50);
6709        let mut score = default_engine_score();
6710        stack.batch_execute(vec![], &mut score).unwrap();
6711        assert!(!stack.can_undo());
6712    }
6713
6714    // ── BatchCmd.label ────────────────────────────────────────────────────────
6715
6716    #[test]
6717    fn batch_label_used_as_command_key() {
6718        let cmd = Command::Batch(BatchCmd {
6719            commands: vec![],
6720            label: Some("ApplyAI".to_string()),
6721        });
6722        assert_eq!(command_key(&cmd), "ApplyAI");
6723    }
6724
6725    #[test]
6726    fn batch_no_label_key_is_batch() {
6727        let cmd = Command::Batch(BatchCmd {
6728            commands: vec![],
6729            label: None,
6730        });
6731        assert_eq!(command_key(&cmd), "Batch");
6732    }
6733
6734    #[test]
6735    fn batch_label_survives_json_roundtrip() {
6736        let cmd = Command::Batch(BatchCmd {
6737            commands: vec![Command::SetTempo(SetTempoCmd { bpm: 120 })],
6738            label: Some("PasteSelection".to_string()),
6739        });
6740        let json = serde_json::to_string(&cmd).unwrap();
6741        let cmd2: Command = serde_json::from_str(&json).unwrap();
6742        assert_eq!(command_key(&cmd2), "PasteSelection");
6743    }
6744
6745    #[test]
6746    fn batch_label_in_undo_key() {
6747        let mut stack = CommandStack::new(50);
6748        let mut score = default_engine_score();
6749        let cmd = Command::Batch(BatchCmd {
6750            commands: vec![Command::SetTempo(SetTempoCmd { bpm: 140 })],
6751            label: Some("ApplyAI".to_string()),
6752        });
6753        stack.execute(cmd, &mut score).unwrap();
6754        assert_eq!(stack.undo_key(), Some("ApplyAI".to_string()));
6755    }
6756
6757    #[test]
6758    fn undo_returns_change_hint() {
6759        use crate::model::change_hint::ChangeScope;
6760        let mut stack = CommandStack::new(50);
6761        let mut score = default_engine_score();
6762        stack
6763            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
6764            .unwrap();
6765        let hint = stack.undo(&mut score).unwrap();
6766        assert_eq!(hint.scope, ChangeScope::Global);
6767        assert!(hint.playback_dirty);
6768    }
6769
6770    #[test]
6771    fn redo_returns_change_hint() {
6772        use crate::model::change_hint::ChangeScope;
6773        let mut stack = CommandStack::new(50);
6774        let mut score = default_engine_score();
6775        stack
6776            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
6777            .unwrap();
6778        stack.undo(&mut score).unwrap();
6779        let hint = stack.redo(&mut score).unwrap();
6780        assert_eq!(hint.scope, ChangeScope::Global);
6781        assert!(hint.playback_dirty);
6782    }
6783
6784    // ── Feature A: ToggleSlur ─────────────────────────────────────────────
6785
6786    #[test]
6787    fn toggle_slur_sets_start_and_end() {
6788        let mut score = default_engine_score();
6789        let cmd = Command::AddNote(AddNoteCmd {
6790            part_index: 0,
6791            staff_index: 0,
6792            measure_index: 0,
6793            voice: 0,
6794            position: 0,
6795            pitch: Some(Pitch::new(Step::C, 4)),
6796            duration: Duration::Quarter,
6797            dot_count: 0,
6798            is_rest: false,
6799            tuplet: None,
6800        });
6801        apply_command(&cmd, &mut score).unwrap();
6802        apply_command(
6803            &Command::AddNote(AddNoteCmd {
6804                part_index: 0,
6805                staff_index: 0,
6806                measure_index: 0,
6807                voice: 0,
6808                position: 1,
6809                pitch: Some(Pitch::new(Step::D, 4)),
6810                duration: Duration::Quarter,
6811                dot_count: 0,
6812                is_rest: false,
6813                tuplet: None,
6814            }),
6815            &mut score,
6816        )
6817        .unwrap();
6818        let start = NoteAddr {
6819            part: 0,
6820            staff: 0,
6821            measure: 0,
6822            voice: 0,
6823            note: 0,
6824        };
6825        let end = NoteAddr {
6826            part: 0,
6827            staff: 0,
6828            measure: 0,
6829            voice: 0,
6830            note: 1,
6831        };
6832        apply_command(
6833            &Command::ToggleSlur(ToggleSlurCmd {
6834                start: start.clone(),
6835                end: end.clone(),
6836            }),
6837            &mut score,
6838        )
6839        .unwrap();
6840        assert!(score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
6841        assert!(score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
6842        // toggle off
6843        apply_command(
6844            &Command::ToggleSlur(ToggleSlurCmd { start, end }),
6845            &mut score,
6846        )
6847        .unwrap();
6848        assert!(!score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
6849        assert!(!score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
6850    }
6851
6852    // ── Feature B: AddStaff / DeleteStaff ────────────────────────────────
6853
6854    #[test]
6855    fn add_staff_appends_staff_with_correct_measure_count() {
6856        let mut score = default_engine_score();
6857        let before = score.parts[0].staves.len();
6858        let measure_count = score.parts[0].staves[0].measures.len();
6859        apply_command(
6860            &Command::AddStaff(AddStaffCmd {
6861                part_index: 0,
6862                clef: Clef::Bass,
6863            }),
6864            &mut score,
6865        )
6866        .unwrap();
6867        assert_eq!(score.parts[0].staves.len(), before + 1);
6868        let new_staff = score.parts[0].staves.last().unwrap();
6869        assert_eq!(new_staff.measures.len(), measure_count);
6870    }
6871
6872    #[test]
6873    fn add_staff_out_of_range_returns_err() {
6874        let mut score = default_engine_score();
6875        let result = apply_command(
6876            &Command::AddStaff(AddStaffCmd {
6877                part_index: 99,
6878                clef: Clef::Treble,
6879            }),
6880            &mut score,
6881        );
6882        assert!(result.is_err());
6883    }
6884
6885    #[test]
6886    fn delete_staff_removes_extra_staff() {
6887        let mut score = default_engine_score();
6888        apply_command(
6889            &Command::AddStaff(AddStaffCmd {
6890                part_index: 0,
6891                clef: Clef::Bass,
6892            }),
6893            &mut score,
6894        )
6895        .unwrap();
6896        assert_eq!(score.parts[0].staves.len(), 2);
6897        apply_command(
6898            &Command::DeleteStaff(DeleteStaffCmd {
6899                part_index: 0,
6900                staff_index: 1,
6901            }),
6902            &mut score,
6903        )
6904        .unwrap();
6905        assert_eq!(score.parts[0].staves.len(), 1);
6906    }
6907
6908    #[test]
6909    fn delete_last_staff_returns_err() {
6910        let mut score = default_engine_score();
6911        assert_eq!(score.parts[0].staves.len(), 1);
6912        let result = apply_command(
6913            &Command::DeleteStaff(DeleteStaffCmd {
6914                part_index: 0,
6915                staff_index: 0,
6916            }),
6917            &mut score,
6918        );
6919        assert!(result.is_err());
6920    }
6921
6922    // ── Feature D: SetTuplet ──────────────────────────────────────────────
6923
6924    #[test]
6925    fn set_tuplet_assigns_and_clears() {
6926        use crate::model::notation::TupletInfo;
6927        let mut score = default_engine_score();
6928        apply_command(
6929            &Command::AddNote(AddNoteCmd {
6930                part_index: 0,
6931                staff_index: 0,
6932                measure_index: 0,
6933                voice: 0,
6934                position: 0,
6935                pitch: Some(Pitch::new(Step::C, 4)),
6936                duration: Duration::Quarter,
6937                dot_count: 0,
6938                is_rest: false,
6939                tuplet: None,
6940            }),
6941            &mut score,
6942        )
6943        .unwrap();
6944        let ti = TupletInfo {
6945            actual_notes: 3,
6946            normal_notes: 2,
6947        };
6948        apply_command(
6949            &Command::SetTuplet(SetTupletCmd {
6950                part_index: 0,
6951                staff_index: 0,
6952                measure_index: 0,
6953                voice_index: 0,
6954                note_index: 0,
6955                tuplet: Some(ti.clone()),
6956            }),
6957            &mut score,
6958        )
6959        .unwrap();
6960        assert_eq!(
6961            score.parts[0].staves[0].measures[0].voices[0][0].tuplet,
6962            Some(ti)
6963        );
6964        apply_command(
6965            &Command::SetTuplet(SetTupletCmd {
6966                part_index: 0,
6967                staff_index: 0,
6968                measure_index: 0,
6969                voice_index: 0,
6970                note_index: 0,
6971                tuplet: None,
6972            }),
6973            &mut score,
6974        )
6975        .unwrap();
6976        assert!(
6977            score.parts[0].staves[0].measures[0].voices[0][0]
6978                .tuplet
6979                .is_none()
6980        );
6981    }
6982
6983    // ── Feature E: RespellScore ───────────────────────────────────────────
6984
6985    #[test]
6986    fn respell_score_cmd_changes_all_pitches() {
6987        use crate::model::pitch::Step;
6988        let mut score = default_engine_score();
6989        apply_command(
6990            &Command::AddNote(AddNoteCmd {
6991                part_index: 0,
6992                staff_index: 0,
6993                measure_index: 0,
6994                voice: 0,
6995                position: 0,
6996                pitch: Some(Pitch::with_alter(Step::C, 4, 1)), // C#4
6997                duration: Duration::Quarter,
6998                dot_count: 0,
6999                is_rest: false,
7000                tuplet: None,
7001            }),
7002            &mut score,
7003        )
7004        .unwrap();
7005        apply_command(
7006            &Command::RespellScore(RespellScoreCmd { prefer_flat: true }),
7007            &mut score,
7008        )
7009        .unwrap();
7010        let pitch = &score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
7011        assert_eq!(pitch.step, Step::D);
7012        assert_eq!(pitch.alter, -1); // Db4
7013    }
7014
7015    fn respell_fixture(measures: u32) -> Score {
7016        use crate::model::pitch::Step;
7017        let mut score = Score::new("Respell", 120, 4, 4, 0, measures);
7018        for measure in &mut score.parts[0].staves[0].measures {
7019            measure.voices[0] = vec![Note::new(Pitch::with_alter(Step::C, 4, 1), Duration::Whole)];
7020        }
7021        score
7022    }
7023
7024    fn spelled(score: &Score, measure: usize) -> (crate::model::pitch::Step, i8) {
7025        let pitch = &score.parts[0].staves[0].measures[measure].voices[0][0].pitches[0];
7026        (pitch.step.clone(), pitch.alter)
7027    }
7028
7029    #[test]
7030    fn respell_staff_region_changes_only_the_selection_and_is_undoable() {
7031        use crate::model::pitch::Step;
7032        let mut score = respell_fixture(4);
7033        let mut stack = CommandStack::new(50);
7034        let command = Command::RespellStaffRegion(RespellStaffRegionCmd {
7035            part_index: 0,
7036            staff_index: 0,
7037            start_measure: 1,
7038            end_measure: 3,
7039            policy: RespellPolicy::Flat,
7040        });
7041        let json = serde_json::to_string(&command).expect("command serializes");
7042        assert!(json.contains("\"policy\":\"flat\""));
7043        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
7044        assert_eq!(command_key(&decoded), "RespellStaffRegion");
7045        assert_eq!(command_label(&decoded), "Respell Pitches (flat)");
7046        stack.execute(decoded, &mut score).expect("region respells");
7047        assert_eq!(spelled(&score, 0), (Step::C, 1));
7048        assert_eq!(spelled(&score, 1), (Step::D, -1));
7049        assert_eq!(spelled(&score, 2), (Step::D, -1));
7050        assert_eq!(spelled(&score, 3), (Step::C, 1));
7051        stack.undo(&mut score).expect("region respell undoes");
7052        assert!((0..4).all(|measure| spelled(&score, measure) == (Step::C, 1)));
7053        stack.redo(&mut score).expect("region respell redoes");
7054        assert_eq!(spelled(&score, 2), (Step::D, -1));
7055    }
7056
7057    #[test]
7058    fn respell_staff_region_keeps_ties_across_the_boundary_in_one_spelling() {
7059        use crate::model::pitch::Step;
7060        let mut score = respell_fixture(4);
7061        {
7062            let measures = &mut score.parts[0].staves[0].measures;
7063            measures[0].voices[0][0].tie_start = true;
7064            measures[1].voices[0][0].tie_end = true;
7065            measures[1].voices[0][0].tie_start = true;
7066            measures[2].voices[0][0].tie_end = true;
7067        }
7068        let changed = respell_staff_region(&mut score, 0, 0, 1, 2, RespellPolicy::Flat)
7069            .expect("tied region respells");
7070        assert_eq!(changed, (0, 2));
7071        assert_eq!(spelled(&score, 0), (Step::D, -1));
7072        assert_eq!(spelled(&score, 1), (Step::D, -1));
7073        assert_eq!(spelled(&score, 2), (Step::D, -1));
7074        assert_eq!(spelled(&score, 3), (Step::C, 1));
7075    }
7076
7077    #[test]
7078    fn respell_staff_region_follows_local_keys_and_skips_unpitched_notes() {
7079        use crate::model::notation::KeySignature;
7080        use crate::model::pitch::Step;
7081        let mut score = respell_fixture(4);
7082        for measure in &mut score.parts[0].staves[0].measures {
7083            measure.voices[0][0].pitches[0] = Pitch::with_alter(Step::D, 4, -1);
7084        }
7085        score.parts[0].staves[0].measures[2].key_sig = Some(KeySignature {
7086            fifths: -3,
7087            mode: "major".into(),
7088        });
7089        score.parts[0].staves[0].measures[1].voices[0][0].is_unpitched = true;
7090        respell_staff_region(&mut score, 0, 0, 0, 4, RespellPolicy::Key).expect("key respell");
7091        assert_eq!(spelled(&score, 0), (Step::C, 1));
7092        assert_eq!(
7093            spelled(&score, 1),
7094            (Step::D, -1),
7095            "unpitched staff position is kept"
7096        );
7097        assert_eq!(spelled(&score, 2), (Step::D, -1));
7098        assert_eq!(spelled(&score, 3), (Step::D, -1));
7099
7100        assert!(matches!(
7101            respell_staff_region(&mut score, 0, 0, 2, 2, RespellPolicy::Sharp),
7102            Err(Error::InvalidCommand(_))
7103        ));
7104        assert!(matches!(
7105            respell_staff_region(&mut score, 0, 5, 0, 1, RespellPolicy::Sharp),
7106            Err(Error::StaffNotFound(5))
7107        ));
7108        assert!(matches!(
7109            respell_staff_region(&mut score, 0, 0, 0, 5, RespellPolicy::Sharp),
7110            Err(Error::InvalidCommand(_))
7111        ));
7112    }
7113
7114    #[test]
7115    fn pickup_measure_length_limits_editing_and_survives_time_signature_changes() {
7116        use crate::model::score::MeasureLength;
7117        let mut score = Score::new("Pickup", 120, 4, 4, 0, 2);
7118        score.parts[0].staves[0].measures[0].actual_length = Some(MeasureLength {
7119            numerator: 1,
7120            denominator: 4,
7121        });
7122        score.parts[0].staves[0].measures[0].voices[0] = vec![Note::rest(Duration::Quarter)];
7123        assert!(validate(&score).is_valid());
7124        assert_eq!(
7125            crate::measure_beats_remaining(&score, 0, 0, 0, 0).expect("capacity"),
7126            0.0
7127        );
7128        let mut stack = CommandStack::new(50);
7129        stack
7130            .execute(
7131                Command::AddNote(AddNoteCmd {
7132                    part_index: 0,
7133                    staff_index: 0,
7134                    measure_index: 0,
7135                    voice: 0,
7136                    position: 0,
7137                    pitch: Some(Pitch::new(Step::G, 4)),
7138                    duration: Duration::Quarter,
7139                    dot_count: 0,
7140                    is_rest: false,
7141                    tuplet: None,
7142                }),
7143                &mut score,
7144            )
7145            .expect("note fits the pickup after trimming");
7146        let pickup = &score.parts[0].staves[0].measures[0].voices[0];
7147        assert_eq!(pickup.len(), 1);
7148        assert!(!pickup[0].is_rest);
7149
7150        stack
7151            .execute(
7152                Command::SetTimeSignature(SetTimeSignatureCmd {
7153                    numerator: 3,
7154                    denominator: 4,
7155                }),
7156                &mut score,
7157            )
7158            .expect("time signature changes");
7159        let beats: f64 = score.parts[0].staves[0].measures[0].voices[0]
7160            .iter()
7161            .map(|note| note.beats())
7162            .sum();
7163        assert!((beats - 1.0).abs() < 1e-9, "the pickup keeps one beat");
7164
7165        score.parts[0].staves[0].measures[1].actual_length = Some(MeasureLength {
7166            numerator: 0,
7167            denominator: 4,
7168        });
7169        assert!(validate(&score).errors.iter().any(|error| matches!(
7170            error,
7171            crate::ValidationError::InvalidMeasureLength { measure: 1, .. }
7172        )));
7173    }
7174
7175    #[test]
7176    fn set_lyric_addresses_verses_without_touching_verse_one() {
7177        use crate::model::notation::VerseLyric;
7178        let mut score = Score::new("Verses", 120, 4, 4, 0, 1);
7179        score.parts[0].staves[0].measures[0].voices[0] =
7180            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
7181        let lyric = |text: &str| Lyric {
7182            text: text.into(),
7183            syllabic: "single".into(),
7184            extend: false,
7185        };
7186        let set = |verse: Option<u8>, text: Option<&str>| {
7187            Command::SetLyric(SetLyricCmd {
7188                part_index: 0,
7189                staff_index: 0,
7190                measure_index: 0,
7191                voice: 0,
7192                note_index: 0,
7193                lyric: text.map(lyric),
7194                verse,
7195            })
7196        };
7197        let mut stack = CommandStack::new(50);
7198        stack
7199            .execute(set(None, Some("one")), &mut score)
7200            .expect("verse 1");
7201        stack
7202            .execute(set(Some(3), Some("three")), &mut score)
7203            .expect("verse 3");
7204        stack
7205            .execute(set(Some(2), Some("two")), &mut score)
7206            .expect("verse 2");
7207        stack
7208            .execute(set(Some(2), Some("TWO")), &mut score)
7209            .expect("replace verse 2");
7210        let note = |score: &Score| score.parts[0].staves[0].measures[0].voices[0][0].clone();
7211        assert_eq!(note(&score).lyric, Some(lyric("one")));
7212        assert_eq!(
7213            note(&score).additional_lyrics,
7214            vec![
7215                VerseLyric {
7216                    verse: 2,
7217                    lyric: lyric("TWO")
7218                },
7219                VerseLyric {
7220                    verse: 3,
7221                    lyric: lyric("three")
7222                }
7223            ]
7224        );
7225        stack
7226            .execute(set(Some(3), None), &mut score)
7227            .expect("clear verse 3");
7228        assert_eq!(note(&score).additional_lyrics.len(), 1);
7229        stack.undo(&mut score).expect("clear undoes");
7230        assert_eq!(note(&score).additional_lyrics.len(), 2);
7231
7232        // Legacy JSON without `verse` still targets verse 1.
7233        let current = serde_json::to_string(&set(None, None)).expect("SetLyric serializes");
7234        assert!(current.contains("\"verse\":null"));
7235        let legacy: Command = serde_json::from_str(&current.replace(",\"verse\":null", ""))
7236            .expect("legacy SetLyric JSON");
7237        stack
7238            .execute(legacy, &mut score)
7239            .expect("legacy clears verse 1");
7240        assert_eq!(note(&score).lyric, None);
7241        assert_eq!(note(&score).additional_lyrics.len(), 2);
7242
7243        assert!(matches!(
7244            apply_command(&set(Some(0), Some("zero")), &mut score),
7245            Err(Error::InvalidCommand(_))
7246        ));
7247        assert!(matches!(
7248            apply_command(&set(Some(33), Some("too far")), &mut score),
7249            Err(Error::InvalidCommand(_))
7250        ));
7251        score.parts[0].staves[0].measures[0].voices[0][0]
7252            .additional_lyrics
7253            .push(VerseLyric {
7254                verse: 2,
7255                lyric: lyric("duplicate"),
7256            });
7257        assert!(validate(&score).errors.iter().any(|error| matches!(
7258            error,
7259            crate::ValidationError::InvalidLyricVerse { verse: 2, .. }
7260        )));
7261    }
7262
7263    #[test]
7264    fn cycle_enharmonic_spelling_targets_one_chord_member_and_undoes() {
7265        let mut score = Score::new("Enharmonic", 120, 4, 4, 0, 1);
7266        let mut chord = Note::new(Pitch::with_alter(Step::C, 4, 1), Duration::Whole);
7267        chord.pitches.push(Pitch::with_alter(Step::G, 4, 1));
7268        score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
7269        let mut stack = CommandStack::new(50);
7270        let command = Command::CycleEnharmonicSpelling(CycleEnharmonicSpellingCmd {
7271            part_index: 0,
7272            staff_index: 0,
7273            measure_index: 0,
7274            voice: 0,
7275            note_index: 0,
7276            pitch_index: Some(1),
7277        });
7278        let json = serde_json::to_string(&command).expect("command serializes");
7279        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
7280        assert_eq!(command_key(&decoded), "CycleEnharmonicSpelling");
7281        stack.execute(decoded, &mut score).expect("spelling cycles");
7282        let pitches = |score: &Score| {
7283            score.parts[0].staves[0].measures[0].voices[0][0]
7284                .pitches
7285                .clone()
7286        };
7287        assert_eq!(
7288            pitches(&score),
7289            vec![
7290                Pitch::with_alter(Step::C, 4, 1),
7291                Pitch::with_alter(Step::A, 4, -1)
7292            ]
7293        );
7294        stack.undo(&mut score).expect("spelling undoes");
7295        assert_eq!(pitches(&score)[1], Pitch::with_alter(Step::G, 4, 1));
7296
7297        let mut rest_score = Score::new("Rest", 120, 4, 4, 0, 1);
7298        rest_score.parts[0].staves[0].measures[0].voices[0] = vec![Note::rest(Duration::Whole)];
7299        assert!(matches!(
7300            apply_command(&command, &mut rest_score),
7301            Err(Error::InvalidCommand(_))
7302        ));
7303    }
7304
7305    #[test]
7306    fn resequence_rehearsal_marks_continues_the_first_sequence() {
7307        let mut score = Score::new("Marks", 120, 4, 4, 0, 30);
7308        let mut stack = CommandStack::new(50);
7309        for (index, text) in [(0, "A"), (4, "C"), (9, "C"), (12, "Q")] {
7310            for_each_measure_at(&mut score, index, |measure| {
7311                measure.rehearsal = Some(text.into())
7312            });
7313        }
7314        let command = Command::ResequenceRehearsalMarks(ResequenceRehearsalMarksCmd::default());
7315        let json = serde_json::to_string(&command).expect("command serializes");
7316        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
7317        assert_eq!(command_key(&decoded), "ResequenceRehearsalMarks");
7318        stack
7319            .execute(decoded, &mut score)
7320            .expect("marks resequence");
7321        let marks = |score: &Score| -> Vec<Option<String>> {
7322            [0, 4, 9, 12]
7323                .iter()
7324                .map(|&index| score.parts[0].staves[0].measures[index].rehearsal.clone())
7325                .collect()
7326        };
7327        assert_eq!(
7328            marks(&score),
7329            ["A", "B", "C", "D"].map(|text| Some(text.to_string()))
7330        );
7331        stack.undo(&mut score).expect("resequence undoes");
7332        assert_eq!(marks(&score)[1].as_deref(), Some("C"));
7333
7334        let sequence = RehearsalSequence::detect("Z", 1).expect("letters");
7335        assert_eq!(sequence.nth(1, 1), "AA");
7336        assert_eq!(sequence.nth(2, 1), "AB");
7337        let lower = RehearsalSequence::detect("y", 1).expect("lower letters");
7338        assert_eq!(lower.nth(2, 1), "aa");
7339        assert_eq!(
7340            RehearsalSequence::detect("7", 3)
7341                .expect("numbers")
7342                .nth(2, 9),
7343            "9"
7344        );
7345        assert_eq!(
7346            RehearsalSequence::detect("5", 5)
7347                .expect("measure numbers")
7348                .nth(3, 17),
7349            "17"
7350        );
7351        assert!(RehearsalSequence::detect("Intro", 1).is_none());
7352
7353        for_each_measure_at(&mut score, 0, |measure| {
7354            measure.rehearsal = Some("Intro".into())
7355        });
7356        assert!(matches!(
7357            apply_command(&command, &mut score),
7358            Err(Error::InvalidCommand(_))
7359        ));
7360    }
7361
7362    #[test]
7363    fn system_break_interval_replaces_line_breaks_only() {
7364        let mut score = Score::new("Breaks", 120, 4, 4, 0, 10);
7365        score.parts[0].staves[0].measures[1].system_break = true;
7366        score.parts[0].staves[0].measures[2].page_break = true;
7367        apply_command(
7368            &Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
7369                interval: 4,
7370                start_measure: None,
7371                end_measure: None,
7372            }),
7373            &mut score,
7374        )
7375        .expect("breaks every four measures");
7376        let breaks: Vec<usize> = score.parts[0].staves[0]
7377            .measures
7378            .iter()
7379            .enumerate()
7380            .filter(|(_, measure)| measure.system_break)
7381            .map(|(index, _)| index)
7382            .collect();
7383        assert_eq!(breaks, vec![3, 7]);
7384        assert!(score.parts[0].staves[0].measures[2].page_break);
7385
7386        let mut exact = Score::new("Exact", 120, 4, 4, 0, 8);
7387        apply_command(
7388            &Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
7389                interval: 4,
7390                start_measure: None,
7391                end_measure: None,
7392            }),
7393            &mut exact,
7394        )
7395        .expect("no break after the final measure");
7396        assert!(!exact.parts[0].staves[0].measures[7].system_break);
7397        assert!(exact.parts[0].staves[0].measures[3].system_break);
7398
7399        apply_command(
7400            &Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
7401                interval: 0,
7402                start_measure: Some(0),
7403                end_measure: Some(6),
7404            }),
7405            &mut score,
7406        )
7407        .expect("breaks removed in range");
7408        assert!(!score.parts[0].staves[0].measures[3].system_break);
7409        assert!(score.parts[0].staves[0].measures[7].system_break);
7410        assert!(matches!(
7411            apply_command(
7412                &Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
7413                    interval: 2,
7414                    start_measure: Some(4),
7415                    end_measure: Some(11),
7416                }),
7417                &mut score,
7418            ),
7419            Err(Error::InvalidCommand(_))
7420        ));
7421    }
7422
7423    #[test]
7424    fn remove_trailing_empty_measures_keeps_content_and_final_barline() {
7425        let mut score = Score::new("Trailing", 120, 4, 4, 0, 6);
7426        let mut stack = CommandStack::new(50);
7427        score.parts[0].staves[0].measures[1].voices[0] =
7428            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
7429        score.parts[0].staves[0].measures[5].barline_right = Barline::Final;
7430        let mut fermata_rest = Note::rest(Duration::Whole);
7431        fermata_rest.articulations = vec![Articulation::Fermata];
7432        score.parts[0].staves[0].measures[3].voices[0] = vec![fermata_rest];
7433        stack
7434            .execute(
7435                Command::RemoveTrailingEmptyMeasures(RemoveTrailingEmptyMeasuresCmd {}),
7436                &mut score,
7437            )
7438            .expect("trailing measures removed");
7439        let staff = &score.parts[0].staves[0];
7440        assert_eq!(staff.measures.len(), 4, "a rest with a fermata is content");
7441        assert!(matches!(staff.measures[3].barline_right, Barline::Final));
7442        assert_eq!(staff.measures[3].number, 4);
7443        stack.undo(&mut score).expect("removal undoes");
7444        assert_eq!(score.parts[0].staves[0].measures.len(), 6);
7445
7446        let mut empty = Score::new("Empty", 120, 4, 4, 0, 3);
7447        apply_command(
7448            &Command::RemoveTrailingEmptyMeasures(RemoveTrailingEmptyMeasuresCmd {}),
7449            &mut empty,
7450        )
7451        .expect("empty score keeps one measure");
7452        assert_eq!(empty.parts[0].staves[0].measures.len(), 1);
7453    }
7454
7455    #[test]
7456    fn typed_spanner_commands_are_atomic_and_undoable() {
7457        use crate::model::score::{NotationSpanner, NotationSpannerKind};
7458
7459        let mut score = default_engine_score();
7460        let address = NoteAddr {
7461            part: 0,
7462            staff: 0,
7463            measure: 0,
7464            voice: 0,
7465            note: 0,
7466        };
7467        let spanner = NotationSpanner {
7468            id: "slur-1".to_string(),
7469            kind: NotationSpannerKind::Slur,
7470            start: address.clone(),
7471            end: address,
7472            number: Some(1),
7473            line_type: Some("dashed".to_string()),
7474            text: None,
7475            placement: Some("above".to_string()),
7476            ottava_size: None,
7477            ottava_type: None,
7478        };
7479        let mut stack = CommandStack::new(8);
7480        stack
7481            .execute(Command::AddSpanner(AddSpannerCmd { spanner }), &mut score)
7482            .unwrap();
7483        assert_eq!(score.spanners.len(), 1);
7484        assert_eq!(stack.undo_key().as_deref(), Some("AddSpanner"));
7485
7486        let before_duplicate = score.clone();
7487        let duplicate = score.spanners[0].clone();
7488        assert!(
7489            stack
7490                .execute(
7491                    Command::AddSpanner(AddSpannerCmd { spanner: duplicate }),
7492                    &mut score,
7493                )
7494                .is_err()
7495        );
7496        assert_eq!(score.spanners, before_duplicate.spanners);
7497
7498        let mut updated = score.spanners[0].clone();
7499        updated.number = Some(2);
7500        stack
7501            .execute(
7502                Command::UpdateSpanner(UpdateSpannerCmd { spanner: updated }),
7503                &mut score,
7504            )
7505            .unwrap();
7506        assert_eq!(score.spanners[0].number, Some(2));
7507        stack.undo(&mut score).unwrap();
7508        assert_eq!(score.spanners[0].number, Some(1));
7509        stack.redo(&mut score).unwrap();
7510        assert_eq!(score.spanners[0].number, Some(2));
7511
7512        stack
7513            .execute(
7514                Command::RemoveSpanner(RemoveSpannerCmd {
7515                    id: "slur-1".to_string(),
7516                }),
7517                &mut score,
7518            )
7519            .unwrap();
7520        assert!(score.spanners.is_empty());
7521        stack.undo(&mut score).unwrap();
7522        assert_eq!(score.spanners.len(), 1);
7523    }
7524
7525    #[test]
7526    fn string_quartet_linked_views_roundtrip_and_undo_redo_as_one_contract() {
7527        let mut score = Score::template(ScoreTemplate::StringQuartet);
7528        let mut stack = CommandStack::new(16);
7529        for (part, id, name) in [
7530            (0, "violin-1", "Violin I"),
7531            (1, "violin-2", "Violin II"),
7532            (2, "viola", "Viola"),
7533            (3, "cello", "Cello"),
7534        ] {
7535            stack
7536                .execute(
7537                    Command::UpsertScoreView(UpsertScoreViewCmd {
7538                        view: ScoreView::linked_part(id, name, part),
7539                    }),
7540                    &mut score,
7541                )
7542                .expect("linked part view applies");
7543        }
7544        assert_eq!(score.parts.len(), 4);
7545        assert_eq!(score.views.len(), 4);
7546        for (part, view) in score.views.iter().enumerate() {
7547            assert_eq!(view.parts, vec![part]);
7548            assert_eq!(score.resolve_view(&view.id).unwrap().parts.len(), 1);
7549        }
7550
7551        let restored: Score =
7552            serde_json::from_str(&serde_json::to_string(&score).expect("quartet score serializes"))
7553                .expect("quartet score deserializes");
7554        assert_eq!(restored.views, score.views);
7555
7556        for _ in 0..4 {
7557            stack.undo(&mut score).expect("view undo applies");
7558        }
7559        assert!(score.views.is_empty());
7560        for _ in 0..4 {
7561            stack.redo(&mut score).expect("view redo applies");
7562        }
7563        assert_eq!(score.views, restored.views);
7564    }
7565
7566    #[test]
7567    fn linked_view_rejects_invalid_projection_and_keeps_the_source_intact() {
7568        use crate::model::score::{ViewStaffKindOverride, ViewStaffRef, ViewStyleProperty};
7569
7570        let mut score = Score::template(ScoreTemplate::StringQuartet);
7571        let original = score.clone();
7572        let original_parts = serde_json::to_value(&score.parts).unwrap();
7573        let base = ScoreView::linked_part("viola", "Viola", 2);
7574        let mut invalid = base.clone();
7575        invalid.parts.push(2);
7576        assert!(
7577            apply_command(
7578                &Command::UpsertScoreView(UpsertScoreViewCmd { view: invalid }),
7579                &mut score
7580            )
7581            .is_err()
7582        );
7583        let mut invalid = base.clone();
7584        invalid
7585            .layout
7586            .hidden_staves
7587            .push(ViewStaffRef { part: 1, staff: 0 });
7588        assert!(
7589            apply_command(
7590                &Command::UpsertScoreView(UpsertScoreViewCmd { view: invalid }),
7591                &mut score
7592            )
7593            .is_err()
7594        );
7595        let mut invalid = base.clone();
7596        invalid.staff_kind_overrides.push(ViewStaffKindOverride {
7597            staff: ViewStaffRef { part: 2, staff: 0 },
7598            kind: StaffKind::Tablature,
7599        });
7600        assert!(
7601            apply_command(
7602                &Command::UpsertScoreView(UpsertScoreViewCmd { view: invalid }),
7603                &mut score
7604            )
7605            .is_err()
7606        );
7607        let mut invalid = base.clone();
7608        invalid
7609            .layout
7610            .typed_style_overrides
7611            .push(ViewStyleOverride {
7612                property: ViewStyleProperty::StaffSpace,
7613                value: f32::NAN,
7614            });
7615        assert!(
7616            apply_command(
7617                &Command::UpsertScoreView(UpsertScoreViewCmd { view: invalid }),
7618                &mut score
7619            )
7620            .is_err()
7621        );
7622        assert_eq!(score.views, original.views);
7623        assert_eq!(serde_json::to_value(&score.parts).unwrap(), original_parts);
7624
7625        let mut view = base;
7626        view.parts = vec![2, 0];
7627        view.layout
7628            .hidden_staves
7629            .push(ViewStaffRef { part: 0, staff: 0 });
7630        view.layout.typed_style_overrides.push(ViewStyleOverride {
7631            property: ViewStyleProperty::StaffSpace,
7632            value: 1.25,
7633        });
7634        view.staff_kind_overrides.push(ViewStaffKindOverride {
7635            staff: ViewStaffRef { part: 2, staff: 0 },
7636            kind: StaffKind::Percussion,
7637        });
7638        let mut stack = CommandStack::new(4);
7639        stack
7640            .execute(
7641                Command::UpsertScoreView(UpsertScoreViewCmd { view }),
7642                &mut score,
7643            )
7644            .expect("valid linked projection applies");
7645        let projected = score.resolve_view("viola").expect("view resolves");
7646        assert_eq!(projected.parts.len(), 2);
7647        assert_eq!(projected.parts[0].name, original.parts[2].name);
7648        assert_eq!(projected.parts[1].name, original.parts[0].name);
7649        assert_eq!(
7650            projected.parts[0].staves[0].presentation.kind,
7651            StaffKind::Percussion
7652        );
7653        assert_eq!(projected.views[0].layout.hidden_staves[0].part, 1);
7654        assert_eq!(projected.views[0].layout.resolved_style().staff_space, 1.25);
7655        assert_eq!(serde_json::to_value(&score.parts).unwrap(), original_parts);
7656        stack.undo(&mut score).expect("view creation is undoable");
7657        assert!(score.views.is_empty());
7658    }
7659
7660    #[test]
7661    fn template_score_edit_session_keeps_global_and_measure_marks_in_sync() {
7662        let mut score = Score::default();
7663        apply_command(
7664            &Command::NewScore(NewScoreCmd {
7665                title: "Quartet rehearsal".into(),
7666                composer: "Composer".into(),
7667                tempo_bpm: 96,
7668                time_numerator: 3,
7669                time_denominator: 4,
7670                key_fifths: 2,
7671                measure_count: 2,
7672                template: Some(ScoreTemplate::StringQuartet),
7673            }),
7674            &mut score,
7675        )
7676        .unwrap();
7677        assert_eq!(score.parts.len(), 4);
7678        assert_eq!(score.parts[3].staves[0].measures.len(), 2);
7679        assert_eq!(score.metadata.composer, "Composer");
7680        for command in [
7681            Command::SetKeySignature(SetKeySignatureCmd { fifths: -1 }),
7682            Command::SetRehearsalMark(SetRehearsalMarkCmd {
7683                measure_index: 0,
7684                text: Some("A".into()),
7685            }),
7686            Command::SetNavigationMark(SetNavigationMarkCmd {
7687                measure_index: 1,
7688                mark: Some("Fine".into()),
7689            }),
7690            Command::SetSystemBreak(SetSystemBreakCmd {
7691                measure_index: 1,
7692                value: true,
7693            }),
7694            Command::SetPageBreak(SetPageBreakCmd {
7695                measure_index: 1,
7696                value: true,
7697            }),
7698        ] {
7699            apply_command(&command, &mut score).unwrap();
7700        }
7701        assert_eq!(score.settings.key_signature.fifths, -1);
7702        for part in &score.parts {
7703            let measures = &part.staves[0].measures;
7704            assert_eq!(measures[0].rehearsal.as_deref(), Some("A"));
7705            assert_eq!(measures[1].navigation.as_deref(), Some("Fine"));
7706            assert!(measures[1].system_break);
7707            assert!(measures[1].page_break);
7708        }
7709    }
7710
7711    #[test]
7712    fn section_break_is_atomic_undoable_and_synced_across_staves() {
7713        let mut score = Score::template(ScoreTemplate::Piano);
7714        let original = score.clone();
7715        let mut stack = CommandStack::new(4);
7716        stack
7717            .execute(
7718                Command::SetSectionBreak(SetSectionBreakCmd {
7719                    measure_index: 2,
7720                    value: true,
7721                }),
7722                &mut score,
7723            )
7724            .expect("section break applies");
7725        assert!(
7726            score
7727                .parts
7728                .iter()
7729                .flat_map(|part| part.staves.iter())
7730                .all(|staff| staff.measures[2].section_break)
7731        );
7732        stack.undo(&mut score).expect("section break undoes");
7733        assert_eq!(
7734            serde_json::to_value(&score).unwrap(),
7735            serde_json::to_value(&original).unwrap()
7736        );
7737
7738        assert!(
7739            stack
7740                .execute(
7741                    Command::SetSectionBreak(SetSectionBreakCmd {
7742                        measure_index: 99,
7743                        value: true,
7744                    }),
7745                    &mut score,
7746                )
7747                .is_err()
7748        );
7749        assert_eq!(
7750            serde_json::to_value(&score).unwrap(),
7751            serde_json::to_value(&original).unwrap()
7752        );
7753    }
7754
7755    #[test]
7756    fn notation_editing_session_keeps_score_valid_and_undoes_atomically() {
7757        let mut score = Score::new("Notation session", 120, 4, 4, 0, 1);
7758        score.parts[0].staves[0].measures[0].voices[0] = (0..4)
7759            .map(|_| Note::new(Pitch::new(Step::C, 4), Duration::Quarter))
7760            .collect();
7761        let original = serde_json::to_value(&score).unwrap();
7762        let mut stack = CommandStack::new(16);
7763        let edits = [
7764            (
7765                Command::AddPitch(AddPitchCmd {
7766                    part_index: 0,
7767                    staff_index: 0,
7768                    measure_index: 0,
7769                    voice: 0,
7770                    note_index: 0,
7771                    pitch: Pitch::new(Step::E, 4),
7772                }),
7773                "Add Pitch",
7774                "AddPitch",
7775            ),
7776            (
7777                Command::SetArpeggio(SetArpeggioCmd {
7778                    part_index: 0,
7779                    staff_index: 0,
7780                    measure_index: 0,
7781                    voice_index: 0,
7782                    note_index: 0,
7783                    direction: Some(true),
7784                }),
7785                "Set Arpeggio",
7786                "SetArpeggio",
7787            ),
7788            (
7789                Command::SetStem(SetStemCmd {
7790                    part_index: 0,
7791                    staff_index: 0,
7792                    measure_index: 0,
7793                    voice_index: 0,
7794                    note_index: 0,
7795                    stem_up: Some(true),
7796                }),
7797                "Set Stem",
7798                "SetStem",
7799            ),
7800            (
7801                Command::SetDynamic(SetDynamicCmd {
7802                    part_index: 0,
7803                    staff_index: 0,
7804                    measure_index: 0,
7805                    voice: 0,
7806                    note_index: 0,
7807                    dynamic: Some(Dynamic::Mf),
7808                }),
7809                "Set Dynamic",
7810                "SetDynamic",
7811            ),
7812            (
7813                Command::ToggleArticulation(ToggleArticulationCmd {
7814                    part_index: 0,
7815                    staff_index: 0,
7816                    measure_index: 0,
7817                    voice: 0,
7818                    note_index: 1,
7819                    articulation: Articulation::Staccato,
7820                }),
7821                "Toggle Articulation",
7822                "ToggleArticulation",
7823            ),
7824            (
7825                Command::ToggleTie(ToggleTieCmd {
7826                    part_index: 0,
7827                    staff_index: 0,
7828                    measure_index: 0,
7829                    voice: 0,
7830                    note_index: 1,
7831                }),
7832                "Toggle Tie",
7833                "ToggleTie",
7834            ),
7835            (
7836                Command::AddHairpin(AddHairpinCmd {
7837                    part_index: 0,
7838                    staff_index: 0,
7839                    measure_index: 0,
7840                    voice: 0,
7841                    start_note_idx: 0,
7842                    end_note_idx: 3,
7843                    kind: HairpinKind::Crescendo,
7844                }),
7845                "Add Hairpin",
7846                "AddHairpin",
7847            ),
7848            (
7849                Command::AddPedal(AddPedalCmd {
7850                    part_index: 0,
7851                    staff_index: 0,
7852                    measure_index: 0,
7853                    voice: 0,
7854                    start_note_idx: 0,
7855                    end_note_idx: 3,
7856                }),
7857                "Add Pedal",
7858                "AddPedal",
7859            ),
7860            (
7861                Command::SetBarline(SetBarlineCmd {
7862                    measure_index: 0,
7863                    side: "right".into(),
7864                    barline: Barline::Double,
7865                }),
7866                "Set Barline",
7867                "SetBarline",
7868            ),
7869        ];
7870        for (command, label, key) in edits {
7871            stack.execute(command, &mut score).unwrap();
7872            assert_eq!(stack.undo_label().as_deref(), Some(label));
7873            assert_eq!(stack.undo_key().as_deref(), Some(key));
7874        }
7875        assert!(validate(&score).is_valid());
7876        let voice = &score.parts[0].staves[0].measures[0].voices[0];
7877        assert_eq!(voice[0].pitches.len(), 2);
7878        assert_eq!(voice[0].arpeggiate, Some(true));
7879        assert_eq!(voice[0].hairpin_start, Some(HairpinKind::Crescendo));
7880        assert!(voice[0].pedal_start);
7881        assert!(voice[1].tie_start && voice[2].tie_end);
7882        assert!(voice[3].pedal_end && voice[3].hairpin_end);
7883        assert_eq!(
7884            score.parts[0].staves[0].measures[0].barline_right,
7885            Barline::Double
7886        );
7887        let edited = serde_json::to_value(&score).unwrap();
7888        for _ in 0..9 {
7889            stack.undo(&mut score).unwrap();
7890        }
7891        assert_eq!(serde_json::to_value(&score).unwrap(), original);
7892        for _ in 0..9 {
7893            stack.redo(&mut score).unwrap();
7894        }
7895        assert_eq!(serde_json::to_value(&score).unwrap(), edited);
7896    }
7897
7898    #[test]
7899    fn guitar_annotation_commands_keep_json_and_score_semantics() {
7900        let mut score = Score::new("Guitar annotations", 120, 4, 4, 0, 1);
7901        score.parts[0].staves[0].measures[0].voices[0] = (0..4)
7902            .map(|_| Note::new(Pitch::new(Step::C, 4), Duration::Quarter))
7903            .collect();
7904        let edits = [
7905            (
7906                serde_json::json!({"type":"set_tablature_config", "part_index":0,
7907                    "staff_index":0, "config":{"lines":6,"tuning_midi":[40,45,50,55,59,64],"capo":0}}),
7908                "SetTablatureConfig",
7909            ),
7910            (
7911                serde_json::json!({"type":"set_fingering", "part_index":0,"staff_index":0,
7912                    "measure_index":0,"voice":0,"note_index":0,"fingering":2}),
7913                "SetFingering",
7914            ),
7915            (
7916                serde_json::json!({"type":"set_fingerings", "part_index":0,"staff_index":0,
7917                    "measure_index":0,"voice":0,"note_index":0,"fingerings":[1,3]}),
7918                "SetFingerings",
7919            ),
7920            (
7921                serde_json::json!({"type":"set_string_number", "part_index":0,"staff_index":0,
7922                    "measure_index":0,"voice":0,"note_index":0,"string_number":2}),
7923                "SetStringNumber",
7924            ),
7925            (
7926                serde_json::json!({"type":"set_tab_position", "part_index":0,"staff_index":0,
7927                    "measure_index":0,"voice":0,"note_index":0,"position":{"string":2,"fret":1}}),
7928                "SetTabPosition",
7929            ),
7930            (
7931                serde_json::json!({"type":"set_guitar_technique", "part_index":0,"staff_index":0,
7932                    "measure_index":0,"voice":0,"note_index":0,"technique":"slide"}),
7933                "SetGuitarTechnique",
7934            ),
7935            (
7936                serde_json::json!({"type":"set_technique_text", "part_index":0,"staff_index":0,
7937                    "measure_index":0,"voice":0,"note_index":0,"text":"let ring"}),
7938                "SetTechniqueText",
7939            ),
7940            (
7941                serde_json::json!({"type":"set_note_head", "part_index":0,"staff_index":0,
7942                    "measure_index":0,"voice":0,"note_index":0,"note_head":"Diamond"}),
7943                "SetNoteHead",
7944            ),
7945            (
7946                serde_json::json!({"type":"set_hidden", "part_index":0,"staff_index":0,
7947                    "measure_index":0,"voice":0,"note_index":0,"hidden":true}),
7948                "SetHidden",
7949            ),
7950            (
7951                serde_json::json!({"type":"set_instrument_id", "part_index":0,"staff_index":0,
7952                    "measure_index":0,"voice":0,"note_index":0,"instrument_id":"guitar"}),
7953                "SetInstrumentId",
7954            ),
7955            (
7956                serde_json::json!({"type":"set_note_placement", "part_index":0,"staff_index":0,
7957                    "measure_index":0,"voice":0,"note_index":0,"offset_x":1.5,"offset_y":-2.0}),
7958                "SetNotePlacement",
7959            ),
7960            (
7961                serde_json::json!({"type":"set_expression_text", "measure_index":0,
7962                    "text":"dolce"}),
7963                "SetExpressionText",
7964            ),
7965        ];
7966        for (value, key) in edits {
7967            let command: Command = serde_json::from_value(value).unwrap();
7968            assert_eq!(command_key(&command), key);
7969            assert!(!command_label(&command).is_empty());
7970            apply_command(&command, &mut score).unwrap();
7971        }
7972        let note = &score.parts[0].staves[0].measures[0].voices[0][0];
7973        assert_eq!(note.fingerings, vec![1, 3]);
7974        assert_eq!(
7975            note.tab_position,
7976            Some(crate::TabPosition { string: 2, fret: 1 })
7977        );
7978        assert_eq!(note.guitar_technique, Some(GuitarTechnique::Slide));
7979        assert_eq!(note.technique_text.as_deref(), Some("let ring"));
7980        assert_eq!(note.note_head, NoteHead::Diamond);
7981        assert!(note.hidden);
7982        assert_eq!(note.instrument_id.as_deref(), Some("guitar"));
7983        assert_eq!(note.offset_x, Some(1.5));
7984        assert_eq!(
7985            score.parts[0].staves[0].measures[0]
7986                .expression_text
7987                .as_deref(),
7988            Some("dolce")
7989        );
7990        assert!(validate(&score).is_valid());
7991    }
7992}