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; 9] = [
4487        Duration::Breve,
4488        Duration::Whole,
4489        Duration::Half,
4490        Duration::Quarter,
4491        Duration::Eighth,
4492        Duration::Sixteenth,
4493        Duration::ThirtySecond,
4494        Duration::SixtyFourth,
4495        Duration::HundredTwentyEighth,
4496    ];
4497    let index = ORDER.iter().position(|value| value == duration)?;
4498    let scaled = match scale {
4499        DurationScale::Double => index.checked_sub(1)?,
4500        DurationScale::Half => index + 1,
4501    };
4502    ORDER.get(scaled).cloned()
4503}
4504
4505fn uniform_tuplet_ratio(voice: &[Note]) -> Result<Option<TupletInfo>, Error> {
4506    let mut ratio: Option<TupletInfo> = None;
4507    for note in voice {
4508        let Some(tuplet) = &note.tuplet else {
4509            continue;
4510        };
4511        if tuplet.actual_notes == 0 || tuplet.normal_notes == 0 {
4512            return Err(Error::InvalidCommand(
4513                "duration scaling requires a non-zero tuplet ratio".into(),
4514            ));
4515        }
4516        if let Some(existing) = &ratio {
4517            if existing != tuplet {
4518                return Err(Error::InvalidCommand(
4519                    "duration scaling requires one shared tuplet ratio per voice".into(),
4520                ));
4521            }
4522        } else {
4523            ratio = Some(tuplet.clone());
4524        }
4525    }
4526    Ok(ratio)
4527}
4528
4529fn pad_voice_to_measure_with_tuplet_ratio(
4530    voice: &mut Vec<Note>,
4531    max_beats: f64,
4532    ratio: &TupletInfo,
4533) -> Result<(), Error> {
4534    let mut used: f64 = voice.iter().map(Note::beats).sum();
4535    let ratio_scale = f64::from(ratio.normal_notes) / f64::from(ratio.actual_notes);
4536    while max_beats - used > 1e-9 {
4537        let remaining = max_beats - used;
4538        let duration = [
4539            Duration::Whole,
4540            Duration::Half,
4541            Duration::Quarter,
4542            Duration::Eighth,
4543            Duration::Sixteenth,
4544            Duration::ThirtySecond,
4545            Duration::SixtyFourth,
4546            Duration::HundredTwentyEighth,
4547        ]
4548        .into_iter()
4549        .find(|duration| duration.beats(0) * ratio_scale <= remaining + 1e-9)
4550        .ok_or_else(|| {
4551            Error::InvalidCommand(
4552                "duration scaling cannot represent the remaining tuplet duration".into(),
4553            )
4554        })?;
4555        let mut rest = Note::rest(duration);
4556        rest.tuplet = Some(ratio.clone());
4557        used += rest.beats();
4558        voice.push(rest);
4559    }
4560    Ok(())
4561}
4562
4563fn apply_scale_voice_range(cmd: &ScaleVoiceRangeCmd, score: &mut Score) -> Result<(), Error> {
4564    if cmd.voice >= 4 {
4565        return Err(Error::VoiceOutOfRange(cmd.voice));
4566    }
4567    let (start, end) = normalized_structural_measure_range(cmd.start_measure, cmd.end_measure);
4568    let staff = score
4569        .parts
4570        .get(cmd.part_index)
4571        .ok_or(Error::PartNotFound(cmd.part_index))?
4572        .staves
4573        .get(cmd.staff_index)
4574        .ok_or(Error::StaffNotFound(cmd.staff_index))?;
4575    for measure_index in start..=end {
4576        let measure = staff
4577            .measures
4578            .get(measure_index)
4579            .ok_or(Error::MeasureNotFound(measure_index))?;
4580        let expected =
4581            effective_staff_measure_beats(staff, &score.settings.time_signature, measure_index)?;
4582        let voice = &measure.voices[cmd.voice];
4583        match cmd.tuplet_policy {
4584            TupletScalePolicy::PreserveRatio => {
4585                uniform_tuplet_ratio(voice)?;
4586            }
4587        }
4588        let mut scaled_beats = 0.0;
4589        for note in voice {
4590            if scaled_duration(&note.duration, cmd.scale).is_none() {
4591                return Err(Error::InvalidCommand(
4592                    "duration scaling exceeds the portable duration range".into(),
4593                ));
4594            }
4595            if !note.is_grace && !note.is_cue {
4596                scaled_beats += match cmd.scale {
4597                    DurationScale::Half => note.beats() / 2.0,
4598                    DurationScale::Double => note.beats() * 2.0,
4599                };
4600            }
4601        }
4602        if scaled_beats > expected + 1e-9 {
4603            return Err(Error::InvalidCommand(format!(
4604                "duration scaling overflows measure {measure_index}: {scaled_beats} beats exceeds {expected}"
4605            )));
4606        }
4607    }
4608
4609    for measure_index in start..=end {
4610        let expected = effective_staff_measure_beats(
4611            &score.parts[cmd.part_index].staves[cmd.staff_index],
4612            &score.settings.time_signature,
4613            measure_index,
4614        )?;
4615        let voice = &mut score.parts[cmd.part_index].staves[cmd.staff_index].measures
4616            [measure_index]
4617            .voices[cmd.voice];
4618        if voice.is_empty() {
4619            continue;
4620        }
4621        let tuplet_ratio = match cmd.tuplet_policy {
4622            TupletScalePolicy::PreserveRatio => uniform_tuplet_ratio(voice)?,
4623        };
4624        for note in voice.iter_mut() {
4625            note.duration = scaled_duration(&note.duration, cmd.scale).ok_or_else(|| {
4626                Error::InvalidCommand("duration scaling exceeds the portable duration range".into())
4627            })?;
4628        }
4629        if let Some(ratio) = tuplet_ratio {
4630            pad_voice_to_measure_with_tuplet_ratio(voice, expected, &ratio)?;
4631        } else {
4632            pad_voice_to_measure(voice, expected);
4633        }
4634    }
4635    Ok(())
4636}
4637
4638fn apply_paste_score_fragment(cmd: &PasteScoreFragmentCmd, score: &mut Score) -> Result<(), Error> {
4639    if !(MIN_SUPPORTED_SCORE_FRAGMENT_CONTRACT_VERSION..=SCORE_FRAGMENT_CONTRACT_VERSION)
4640        .contains(&cmd.fragment.contract_version)
4641    {
4642        return Err(Error::InvalidCommand(format!(
4643            "unsupported score fragment contract version {}",
4644            cmd.fragment.contract_version
4645        )));
4646    }
4647    if cmd.fragment.voices.is_empty() {
4648        return Err(Error::InvalidCommand(
4649            "cannot paste an empty score fragment".into(),
4650        ));
4651    }
4652
4653    // Resolve every target before mutating the score. CommandStack then also
4654    // validates the complete candidate before adding history, preserving
4655    // score/history atomicity for invalid destinations.
4656    let mut lanes = BTreeSet::new();
4657    let mut replaced = BTreeSet::new();
4658    for lane in &cmd.fragment.voices {
4659        let part_index = cmd
4660            .target
4661            .part
4662            .checked_add(lane.relative_part)
4663            .ok_or_else(|| Error::InvalidCommand("fragment part target overflows".into()))?;
4664        let staff_index = cmd
4665            .target
4666            .staff
4667            .checked_add(lane.relative_staff)
4668            .ok_or_else(|| Error::InvalidCommand("fragment staff target overflows".into()))?;
4669        let voice_index = cmd
4670            .target
4671            .voice
4672            .checked_add(lane.relative_voice)
4673            .ok_or_else(|| Error::InvalidCommand("fragment voice target overflows".into()))?;
4674        if voice_index >= 4 {
4675            return Err(Error::VoiceOutOfRange(voice_index));
4676        }
4677        let staff = score
4678            .parts
4679            .get(part_index)
4680            .ok_or(Error::PartNotFound(part_index))?
4681            .staves
4682            .get(staff_index)
4683            .ok_or(Error::StaffNotFound(staff_index))?;
4684        if !lanes.insert((part_index, staff_index, voice_index)) {
4685            return Err(Error::InvalidCommand(
4686                "fragment contains duplicate destination voice lanes".into(),
4687            ));
4688        }
4689        for measure in &lane.measures {
4690            let measure_index = cmd
4691                .target
4692                .measure
4693                .checked_add(measure.relative_measure)
4694                .ok_or_else(|| Error::InvalidCommand("fragment measure target overflows".into()))?;
4695            if measure_index >= staff.measures.len() {
4696                return Err(Error::MeasureNotFound(measure_index));
4697            }
4698            if !measure.cross_staff_targets.is_empty()
4699                && measure.cross_staff_targets.len() != measure.notes.len()
4700            {
4701                return Err(Error::InvalidCommand(
4702                    "fragment cross-staff targets do not match note count".into(),
4703                ));
4704            }
4705            if cmd.policy == ScoreFragmentPastePolicy::Merge
4706                && !measure.notes.is_empty()
4707                && staff.measures[measure_index].voices[voice_index]
4708                    .iter()
4709                    .any(|note| !note.is_rest)
4710            {
4711                return Err(Error::InvalidCommand(
4712                    "fragment merge would overwrite a sounding destination lane".into(),
4713                ));
4714            }
4715            if cmd.policy == ScoreFragmentPastePolicy::Merge
4716                && measure.attributes.present
4717                && measure.attributes
4718                    != super::fragment::ScoreFragmentMeasureAttributes::from_measure(
4719                        &staff.measures[measure_index],
4720                    )
4721            {
4722                return Err(Error::InvalidCommand(
4723                    "fragment merge would overwrite destination measure attributes".into(),
4724                ));
4725            }
4726            for cross_staff in measure.cross_staff_targets.iter().flatten() {
4727                let target_staff = staff_index as i64 + cross_staff.staff_offset;
4728                if target_staff < 0
4729                    || score.parts[part_index]
4730                        .staves
4731                        .get(target_staff as usize)
4732                        .is_none()
4733                {
4734                    return Err(Error::InvalidCommand(
4735                        "fragment cross-staff target is outside destination part".into(),
4736                    ));
4737                }
4738                if cross_staff.target_voice.is_some_and(|voice| voice >= 4) {
4739                    return Err(Error::InvalidCommand(
4740                        "fragment cross-staff target voice is outside editable range".into(),
4741                    ));
4742                }
4743            }
4744            replaced.insert((part_index, staff_index, measure_index, voice_index));
4745        }
4746    }
4747
4748    // Replacing a lane invalidates its former typed-span endpoints. Remove
4749    // those spans explicitly rather than allowing legacy endpoint flags to
4750    // leave dangling canonical addresses.
4751    score.spanners.retain(|spanner| {
4752        !replaced.contains(&(
4753            spanner.start.part,
4754            spanner.start.staff,
4755            spanner.start.measure,
4756            spanner.start.voice,
4757        )) && !replaced.contains(&(
4758            spanner.end.part,
4759            spanner.end.staff,
4760            spanner.end.measure,
4761            spanner.end.voice,
4762        ))
4763    });
4764    for lane in &cmd.fragment.voices {
4765        let part_index = cmd.target.part + lane.relative_part;
4766        let staff_index = cmd.target.staff + lane.relative_staff;
4767        let voice_index = cmd.target.voice + lane.relative_voice;
4768        let staff = &mut score.parts[part_index].staves[staff_index];
4769        for measure in &lane.measures {
4770            let measure_index = cmd.target.measure + measure.relative_measure;
4771            let target = &mut staff.measures[measure_index];
4772            if cmd.policy == ScoreFragmentPastePolicy::Merge && measure.notes.is_empty() {
4773                continue;
4774            }
4775            let mut notes = measure.notes.clone();
4776            for note in &mut notes {
4777                note.id = Uuid::new_v4().to_string();
4778            }
4779            if !measure.cross_staff_targets.is_empty() {
4780                for (note, cross_staff) in notes.iter_mut().zip(&measure.cross_staff_targets) {
4781                    note.cross_staff = cross_staff.as_ref().map(|cross_staff| CrossStaff {
4782                        target_staff: (staff_index as i64 + cross_staff.staff_offset) as usize,
4783                        target_voice: cross_staff.target_voice,
4784                    });
4785                }
4786            }
4787            target.voices[voice_index] = notes;
4788            target.source_voice_numbers[voice_index] = measure.source_voice_number;
4789            if cmd.policy == ScoreFragmentPastePolicy::Replace {
4790                measure.attributes.apply_to_measure(target);
4791            }
4792        }
4793    }
4794
4795    let mut used_spanner_ids = score
4796        .spanners
4797        .iter()
4798        .map(|spanner| spanner.id.clone())
4799        .collect::<BTreeSet<_>>();
4800    for source in &cmd.fragment.spanners {
4801        let mut copied = source.clone();
4802        copied.start = fragment_destination_address(&cmd.target, &source.start)?;
4803        copied.end = fragment_destination_address(&cmd.target, &source.end)?;
4804        if note_at(score, &copied.start).is_none() {
4805            return Err(Error::NoteNotFound(copied.start.note));
4806        }
4807        if note_at(score, &copied.end).is_none() {
4808            return Err(Error::NoteNotFound(copied.end.note));
4809        }
4810        copied.id = unique_fragment_spanner_id(&used_spanner_ids, &source.id);
4811        used_spanner_ids.insert(copied.id.clone());
4812        score.spanners.push(copied);
4813    }
4814    Ok(())
4815}
4816
4817fn fragment_destination_address(target: &NoteAddr, relative: &NoteAddr) -> Result<NoteAddr, Error> {
4818    Ok(NoteAddr {
4819        part: target
4820            .part
4821            .checked_add(relative.part)
4822            .ok_or_else(|| Error::InvalidCommand("fragment part target overflows".into()))?,
4823        staff: target
4824            .staff
4825            .checked_add(relative.staff)
4826            .ok_or_else(|| Error::InvalidCommand("fragment staff target overflows".into()))?,
4827        measure: target
4828            .measure
4829            .checked_add(relative.measure)
4830            .ok_or_else(|| Error::InvalidCommand("fragment measure target overflows".into()))?,
4831        voice: target
4832            .voice
4833            .checked_add(relative.voice)
4834            .ok_or_else(|| Error::InvalidCommand("fragment voice target overflows".into()))?,
4835        note: relative.note,
4836    })
4837}
4838
4839fn unique_fragment_spanner_id(used: &BTreeSet<String>, source_id: &str) -> String {
4840    let base = format!("{source_id}-copy");
4841    if !used.contains(&base) {
4842        return base;
4843    }
4844    let mut suffix = 2usize;
4845    loop {
4846        let candidate = format!("{base}-{suffix}");
4847        if !used.contains(&candidate) {
4848            return candidate;
4849        }
4850        suffix += 1;
4851    }
4852}
4853
4854fn apply_toggle_slur(cmd: &ToggleSlurCmd, score: &mut Score) -> Result<(), Error> {
4855    let new_start = !{
4856        score
4857            .parts
4858            .get(cmd.start.part)
4859            .ok_or(Error::PartNotFound(cmd.start.part))?
4860            .staves
4861            .get(cmd.start.staff)
4862            .ok_or(Error::StaffNotFound(cmd.start.staff))?
4863            .measures
4864            .get(cmd.start.measure)
4865            .ok_or(Error::MeasureNotFound(cmd.start.measure))?
4866            .voices
4867            .get(cmd.start.voice)
4868            .ok_or(Error::VoiceOutOfRange(cmd.start.voice))?
4869            .get(cmd.start.note)
4870            .ok_or(Error::NoteNotFound(cmd.start.note))?
4871            .slur_start
4872    };
4873    let new_end = !{
4874        score
4875            .parts
4876            .get(cmd.end.part)
4877            .ok_or(Error::PartNotFound(cmd.end.part))?
4878            .staves
4879            .get(cmd.end.staff)
4880            .ok_or(Error::StaffNotFound(cmd.end.staff))?
4881            .measures
4882            .get(cmd.end.measure)
4883            .ok_or(Error::MeasureNotFound(cmd.end.measure))?
4884            .voices
4885            .get(cmd.end.voice)
4886            .ok_or(Error::VoiceOutOfRange(cmd.end.voice))?
4887            .get(cmd.end.note)
4888            .ok_or(Error::NoteNotFound(cmd.end.note))?
4889            .slur_end
4890    };
4891    score.parts[cmd.start.part].staves[cmd.start.staff].measures[cmd.start.measure].voices
4892        [cmd.start.voice][cmd.start.note]
4893        .slur_start = new_start;
4894    score.parts[cmd.end.part].staves[cmd.end.staff].measures[cmd.end.measure].voices
4895        [cmd.end.voice][cmd.end.note]
4896        .slur_end = new_end;
4897    Ok(())
4898}
4899
4900fn apply_toggle_trill_line(cmd: &ToggleTrillLineCmd, score: &mut Score) -> Result<(), Error> {
4901    let new_start = !{
4902        score
4903            .parts
4904            .get(cmd.start.part)
4905            .ok_or(Error::PartNotFound(cmd.start.part))?
4906            .staves
4907            .get(cmd.start.staff)
4908            .ok_or(Error::StaffNotFound(cmd.start.staff))?
4909            .measures
4910            .get(cmd.start.measure)
4911            .ok_or(Error::MeasureNotFound(cmd.start.measure))?
4912            .voices
4913            .get(cmd.start.voice)
4914            .ok_or(Error::VoiceOutOfRange(cmd.start.voice))?
4915            .get(cmd.start.note)
4916            .ok_or(Error::NoteNotFound(cmd.start.note))?
4917            .trill_line_start
4918    };
4919    let new_end = !{
4920        score
4921            .parts
4922            .get(cmd.end.part)
4923            .ok_or(Error::PartNotFound(cmd.end.part))?
4924            .staves
4925            .get(cmd.end.staff)
4926            .ok_or(Error::StaffNotFound(cmd.end.staff))?
4927            .measures
4928            .get(cmd.end.measure)
4929            .ok_or(Error::MeasureNotFound(cmd.end.measure))?
4930            .voices
4931            .get(cmd.end.voice)
4932            .ok_or(Error::VoiceOutOfRange(cmd.end.voice))?
4933            .get(cmd.end.note)
4934            .ok_or(Error::NoteNotFound(cmd.end.note))?
4935            .trill_line_end
4936    };
4937    score.parts[cmd.start.part].staves[cmd.start.staff].measures[cmd.start.measure].voices
4938        [cmd.start.voice][cmd.start.note]
4939        .trill_line_start = new_start;
4940    score.parts[cmd.end.part].staves[cmd.end.staff].measures[cmd.end.measure].voices
4941        [cmd.end.voice][cmd.end.note]
4942        .trill_line_end = new_end;
4943    Ok(())
4944}
4945
4946fn apply_add_staff(cmd: &AddStaffCmd, score: &mut Score) -> Result<(), Error> {
4947    let ts = score.settings.time_signature.clone();
4948    let measure_count = score
4949        .parts
4950        .get(cmd.part_index)
4951        .ok_or(Error::PartNotFound(cmd.part_index))?
4952        .staves
4953        .first()
4954        .map_or(0, |s| s.measures.len());
4955    let mut staff = Staff::new(cmd.clef.clone());
4956    for i in 0..measure_count {
4957        let mut m = Measure::empty(ts.numerator, ts.denominator);
4958        m.number = i as u32 + 1;
4959        staff.measures.push(m);
4960    }
4961    score.parts[cmd.part_index].staves.push(staff);
4962    Ok(())
4963}
4964
4965fn apply_delete_staff(cmd: &DeleteStaffCmd, score: &mut Score) -> Result<(), Error> {
4966    let part = score
4967        .parts
4968        .get_mut(cmd.part_index)
4969        .ok_or(Error::PartNotFound(cmd.part_index))?;
4970    if part.staves.len() <= 1 {
4971        return Err(Error::CannotDeleteLastStaff);
4972    }
4973    if cmd.staff_index >= part.staves.len() {
4974        return Err(Error::StaffNotFound(cmd.staff_index));
4975    }
4976    part.staves.remove(cmd.staff_index);
4977    remap_spanners(score, |address| {
4978        if address.part != cmd.part_index {
4979            Some(address.clone())
4980        } else if address.staff == cmd.staff_index {
4981            None
4982        } else if address.staff > cmd.staff_index {
4983            let mut shifted = address.clone();
4984            shifted.staff -= 1;
4985            Some(shifted)
4986        } else {
4987            Some(address.clone())
4988        }
4989    });
4990    Ok(())
4991}
4992
4993/// Returns true when an address refers to the given canonical voice.
4994fn same_voice(address: &NoteAddr, part: usize, staff: usize, measure: usize, voice: usize) -> bool {
4995    address.part == part
4996        && address.staff == staff
4997        && address.measure == measure
4998        && address.voice == voice
4999}
5000
5001fn trim_voice_to_measure(voice: &mut Vec<Note>, max_beats: f64) {
5002    // A lone plain whole rest is a measure rest and already fills any measure.
5003    if matches!(voice.as_slice(), [only] if only.is_plain_whole_rest()) {
5004        return;
5005    }
5006    let mut total = 0.0f64;
5007    let mut cutoff = voice.len();
5008    for (i, n) in voice.iter().enumerate() {
5009        total += n.beats();
5010        if total > max_beats + 1e-9 {
5011            cutoff = i;
5012            break;
5013        }
5014    }
5015    voice.truncate(cutoff);
5016    pad_voice_to_measure(voice, max_beats);
5017}
5018
5019fn pad_voice_to_measure(voice: &mut Vec<Note>, max_beats: f64) {
5020    let mut used = crate::voice_duration_beats(voice, max_beats);
5021    while max_beats - used > 1e-9 {
5022        let remaining = max_beats - used;
5023        let rest = Note::rest(Duration::whole_filling_beats(remaining));
5024        used += rest.beats();
5025        voice.push(rest);
5026    }
5027}
5028
5029#[cfg(test)]
5030mod tests {
5031    use super::*;
5032    use crate::ScoreEngine;
5033    use crate::model::pitch::Step;
5034
5035    fn default_engine_score() -> Score {
5036        let mut s = Score::default();
5037        for part in &mut s.parts {
5038            for staff in &mut part.staves {
5039                for (i, m) in staff.measures.iter_mut().enumerate() {
5040                    m.number = i as u32 + 1;
5041                }
5042            }
5043        }
5044        s
5045    }
5046
5047    #[test]
5048    fn legacy_scale_voice_range_command_defaults_to_preserve_tuplet_ratio() {
5049        let command: Command = serde_json::from_str(
5050            r#"{"type":"scale_voice_range","part_index":0,"staff_index":0,"voice":0,"start_measure":0,"end_measure":0,"scale":"half"}"#,
5051        )
5052        .expect("legacy scale command deserializes");
5053        let Command::ScaleVoiceRange(command) = command else {
5054            panic!("expected scale command");
5055        };
5056        assert_eq!(command.tuplet_policy, TupletScalePolicy::PreserveRatio);
5057    }
5058
5059    fn score_with_typed_spanner() -> Score {
5060        use crate::model::score::{NotationSpanner, NotationSpannerKind};
5061
5062        let mut score = Score::new("Spanner edits", 120, 4, 4, 0, 1);
5063        score.parts[0].staves[0].measures[0].voices[0] = (0..4)
5064            .map(|offset| Note::new(Pitch::new(Step::C, 4 + offset), Duration::Quarter))
5065            .collect();
5066        score.spanners.push(NotationSpanner {
5067            id: "typed-glissando".to_string(),
5068            kind: NotationSpannerKind::Glissando,
5069            start: NoteAddr {
5070                part: 0,
5071                staff: 0,
5072                measure: 0,
5073                voice: 0,
5074                note: 1,
5075            },
5076            end: NoteAddr {
5077                part: 0,
5078                staff: 0,
5079                measure: 0,
5080                voice: 0,
5081                note: 2,
5082            },
5083            number: Some(1),
5084            line_type: None,
5085            text: None,
5086            placement: None,
5087            ottava_size: None,
5088            ottava_type: None,
5089        });
5090        score
5091    }
5092
5093    fn insert_note_at(position: usize) -> Command {
5094        Command::AddNote(AddNoteCmd {
5095            part_index: 0,
5096            staff_index: 0,
5097            measure_index: 0,
5098            voice: 0,
5099            position,
5100            pitch: Some(Pitch::new(Step::D, 5)),
5101            duration: Duration::Quarter,
5102            dot_count: 0,
5103            is_rest: false,
5104            tuplet: None,
5105        })
5106    }
5107
5108    #[test]
5109    fn add_note_inserts_into_voice() {
5110        let mut score = default_engine_score();
5111        let cmd = Command::AddNote(AddNoteCmd {
5112            part_index: 0,
5113            staff_index: 0,
5114            measure_index: 0,
5115            voice: 0,
5116            position: 0,
5117            pitch: Some(Pitch::new(Step::C, 4)),
5118            duration: Duration::Quarter,
5119            dot_count: 0,
5120            is_rest: false,
5121            tuplet: None,
5122        });
5123        apply_command(&cmd, &mut score).unwrap();
5124        let first = &score.parts[0].staves[0].measures[0].voices[0][0];
5125        assert!(!first.is_rest);
5126        assert_eq!(first.pitches[0].step, Step::C);
5127    }
5128
5129    #[test]
5130    fn typed_spanners_follow_note_insertions_at_every_relative_position() {
5131        for (position, expected_start, expected_end) in [
5132            (0, 2, 3), // before the span
5133            (1, 2, 3), // at its start endpoint
5134            (2, 1, 3), // inside the span
5135            (4, 1, 2), // at the end of the voice
5136            (3, 1, 2), // after the span
5137        ] {
5138            let mut score = score_with_typed_spanner();
5139            apply_command(&insert_note_at(position), &mut score).unwrap();
5140            let span = score.spanners.first().expect("span remains attached");
5141            assert_eq!(span.start.note, expected_start, "position {position}");
5142            assert_eq!(span.end.note, expected_end, "position {position}");
5143        }
5144    }
5145
5146    #[test]
5147    fn deleting_a_typed_spanner_endpoint_removes_the_whole_span_and_undo_redo_is_atomic() {
5148        let mut score = score_with_typed_spanner();
5149        let endpoint_id = score.parts[0].staves[0].measures[0].voices[0][1].id.clone();
5150        let mut stack = CommandStack::new(8);
5151
5152        stack
5153            .execute(
5154                Command::DeleteNote(DeleteNoteCmd {
5155                    note_id: endpoint_id,
5156                    part_index: 0,
5157                    staff_index: 0,
5158                    measure_index: 0,
5159                    voice: 0,
5160                }),
5161                &mut score,
5162            )
5163            .unwrap();
5164        assert!(score.spanners.is_empty());
5165
5166        stack.undo(&mut score).unwrap();
5167        assert_eq!(score.spanners[0].start.note, 1);
5168        assert_eq!(score.spanners[0].end.note, 2);
5169
5170        stack.redo(&mut score).unwrap();
5171        assert!(score.spanners.is_empty());
5172    }
5173
5174    #[test]
5175    fn batched_structural_edits_remap_typed_spanners_and_restore_them_on_undo() {
5176        let mut score = score_with_typed_spanner();
5177        let mut stack = CommandStack::new(8);
5178        stack
5179            .batch_execute(
5180                vec![
5181                    insert_note_at(0),
5182                    Command::SetTempo(SetTempoCmd { bpm: 144 }),
5183                ],
5184                &mut score,
5185            )
5186            .unwrap();
5187        assert_eq!(score.spanners[0].start.note, 2);
5188        assert_eq!(score.spanners[0].end.note, 3);
5189
5190        stack.undo(&mut score).unwrap();
5191        assert_eq!(score.spanners[0].start.note, 1);
5192        assert_eq!(score.spanners[0].end.note, 2);
5193
5194        stack.redo(&mut score).unwrap();
5195        assert_eq!(score.spanners[0].start.note, 2);
5196        assert_eq!(score.spanners[0].end.note, 3);
5197    }
5198
5199    #[test]
5200    fn typed_spanners_remap_only_the_edited_endpoint_across_staff_and_voice() {
5201        use crate::model::score::{NotationSpanner, NotationSpannerKind, ScoreTemplate};
5202
5203        let mut score = Score::template(ScoreTemplate::Piano);
5204        for staff in &mut score.parts[0].staves {
5205            let notes: Vec<Note> = (0..4)
5206                .map(|offset| Note::new(Pitch::new(Step::C, 4 + offset), Duration::Quarter))
5207                .collect();
5208            staff.measures[0].voices[0] = notes.clone();
5209            staff.measures[0].voices[1] = notes;
5210        }
5211        score.spanners.push(NotationSpanner {
5212            id: "cross-staff".to_string(),
5213            kind: NotationSpannerKind::Slur,
5214            start: NoteAddr {
5215                part: 0,
5216                staff: 0,
5217                measure: 0,
5218                voice: 0,
5219                note: 1,
5220            },
5221            end: NoteAddr {
5222                part: 0,
5223                staff: 1,
5224                measure: 0,
5225                voice: 1,
5226                note: 2,
5227            },
5228            number: Some(2),
5229            line_type: None,
5230            text: None,
5231            placement: None,
5232            ottava_size: None,
5233            ottava_type: None,
5234        });
5235
5236        let mut stack = CommandStack::new(8);
5237        stack.execute(insert_note_at(0), &mut score).unwrap();
5238        let span = &score.spanners[0];
5239        assert_eq!(span.start.note, 2);
5240        assert_eq!(span.end.staff, 1);
5241        assert_eq!(span.end.voice, 1);
5242        assert_eq!(span.end.note, 2);
5243    }
5244
5245    #[test]
5246    fn set_fingerings_keeps_first_legacy_value_in_sync() {
5247        let mut score = default_engine_score();
5248        apply_command(
5249            &Command::AddNote(AddNoteCmd {
5250                part_index: 0,
5251                staff_index: 0,
5252                measure_index: 0,
5253                voice: 0,
5254                position: 0,
5255                pitch: Some(Pitch::new(Step::C, 4)),
5256                duration: Duration::Quarter,
5257                dot_count: 0,
5258                is_rest: false,
5259                tuplet: None,
5260            }),
5261            &mut score,
5262        )
5263        .unwrap();
5264        apply_command(
5265            &Command::SetFingerings(SetFingeringsCmd {
5266                part_index: 0,
5267                staff_index: 0,
5268                measure_index: 0,
5269                voice: 0,
5270                note_index: 0,
5271                fingerings: vec![1, 3, 4],
5272            }),
5273            &mut score,
5274        )
5275        .unwrap();
5276        let note = &score.parts[0].staves[0].measures[0].voices[0][0];
5277        assert_eq!(note.fingerings, vec![1, 3, 4]);
5278        assert_eq!(note.fingering, Some(1));
5279    }
5280
5281    #[test]
5282    fn set_figured_bass_replaces_measure_figures() {
5283        let mut score = default_engine_score();
5284        let figures = vec![FiguredBassFigure {
5285            number: "6".to_string(),
5286            alter: Some("-1".to_string()),
5287            prefix: Some("+".to_string()),
5288            suffix: None,
5289            extender: false,
5290        }];
5291        apply_command(
5292            &Command::SetFiguredBass(SetFiguredBassCmd {
5293                measure_index: 0,
5294                figures: figures.clone(),
5295            }),
5296            &mut score,
5297        )
5298        .unwrap();
5299        assert_eq!(score.parts[0].staves[0].measures[0].figured_bass, figures);
5300    }
5301
5302    #[test]
5303    fn set_measure_text_supports_append_replace_remove_and_undo_redo() {
5304        let mut engine = crate::ScoreEngine::new();
5305        let address = SetMeasureTextCmd {
5306            part_index: 0,
5307            staff_index: 0,
5308            measure_index: 0,
5309            text_index: 0,
5310            text: Some(StyledText {
5311                style: crate::TextStyle::Technique,
5312                text: "dolce".to_string(),
5313                placement: None,
5314                offset_x: None,
5315                offset_y: None,
5316                relative_x: None,
5317                relative_y: None,
5318            }),
5319        };
5320        engine.apply(Command::SetMeasureText(address)).unwrap();
5321        assert_eq!(
5322            engine.score.parts[0].staves[0].measures[0].texts[0],
5323            StyledText {
5324                style: crate::TextStyle::Technique,
5325                text: "dolce".to_string(),
5326                placement: None,
5327                offset_x: None,
5328                offset_y: None,
5329                relative_x: None,
5330                relative_y: None,
5331            }
5332        );
5333
5334        engine
5335            .apply(Command::SetMeasureText(SetMeasureTextCmd {
5336                text_index: 0,
5337                text: Some(StyledText {
5338                    style: crate::TextStyle::RehearsalMark,
5339                    text: "A".to_string(),
5340                    placement: None,
5341                    offset_x: None,
5342                    offset_y: None,
5343                    relative_x: None,
5344                    relative_y: None,
5345                }),
5346                ..SetMeasureTextCmd {
5347                    part_index: 0,
5348                    staff_index: 0,
5349                    measure_index: 0,
5350                    text_index: 0,
5351                    text: None,
5352                }
5353            }))
5354            .unwrap();
5355        assert_eq!(
5356            engine.score.parts[0].staves[0].measures[0].texts[0].style,
5357            crate::TextStyle::RehearsalMark
5358        );
5359
5360        engine
5361            .apply(Command::SetMeasureText(SetMeasureTextCmd {
5362                text_index: 0,
5363                text: None,
5364                ..SetMeasureTextCmd {
5365                    part_index: 0,
5366                    staff_index: 0,
5367                    measure_index: 0,
5368                    text_index: 0,
5369                    text: None,
5370                }
5371            }))
5372            .unwrap();
5373        assert!(engine.score.parts[0].staves[0].measures[0].texts.is_empty());
5374        engine.undo().unwrap();
5375        assert_eq!(engine.score.parts[0].staves[0].measures[0].texts.len(), 1);
5376        engine.redo().unwrap();
5377        assert!(engine.score.parts[0].staves[0].measures[0].texts.is_empty());
5378    }
5379
5380    #[test]
5381    fn set_measure_text_rejects_invalid_index_atomically_and_round_trips_json() {
5382        let mut score = default_engine_score();
5383        let before = score.clone();
5384        let command = Command::SetMeasureText(SetMeasureTextCmd {
5385            part_index: 0,
5386            staff_index: 0,
5387            measure_index: 0,
5388            text_index: 2,
5389            text: Some(StyledText {
5390                style: crate::TextStyle::Expression,
5391                text: "espressivo".to_string(),
5392                placement: None,
5393                offset_x: None,
5394                offset_y: None,
5395                relative_x: None,
5396                relative_y: None,
5397            }),
5398        });
5399        let json = serde_json::to_string(&command).unwrap();
5400        let restored: Command = serde_json::from_str(&json).unwrap();
5401        assert_eq!(
5402            serde_json::to_value(&restored).unwrap(),
5403            serde_json::to_value(&command).unwrap()
5404        );
5405        assert!(apply_command(&restored, &mut score).is_err());
5406        assert_eq!(
5407            serde_json::to_value(&score).unwrap(),
5408            serde_json::to_value(&before).unwrap()
5409        );
5410    }
5411
5412    #[test]
5413    fn set_score_text_supports_append_replace_remove_and_undo_redo() {
5414        let mut engine = crate::ScoreEngine::new();
5415        let text = StyledText {
5416            style: crate::TextStyle::Expression,
5417            text: "Title".to_string(),
5418            placement: None,
5419            offset_x: None,
5420            offset_y: None,
5421            relative_x: None,
5422            relative_y: None,
5423        };
5424        engine
5425            .apply(Command::SetScoreText(SetScoreTextCmd {
5426                text_index: 0,
5427                text: Some(text.clone()),
5428            }))
5429            .unwrap();
5430        assert_eq!(engine.score.texts, vec![text.clone()]);
5431
5432        let mut replacement = text.clone();
5433        replacement.text = "Subtitle".to_string();
5434        engine
5435            .apply(Command::SetScoreText(SetScoreTextCmd {
5436                text_index: 0,
5437                text: Some(replacement.clone()),
5438            }))
5439            .unwrap();
5440        assert_eq!(engine.score.texts, vec![replacement]);
5441
5442        engine
5443            .apply(Command::SetScoreText(SetScoreTextCmd {
5444                text_index: 0,
5445                text: None,
5446            }))
5447            .unwrap();
5448        assert!(engine.score.texts.is_empty());
5449        engine.undo().unwrap();
5450        assert_eq!(engine.score.texts.len(), 1);
5451        engine.redo().unwrap();
5452        assert!(engine.score.texts.is_empty());
5453    }
5454
5455    #[test]
5456    fn set_score_style_overrides_is_undoable_and_rejects_invalid_values() {
5457        let mut engine = crate::ScoreEngine::new();
5458        let overrides = vec![ViewStyleOverride {
5459            property: super::super::score::ViewStyleProperty::SystemGap,
5460            value: 2.5,
5461        }];
5462        engine
5463            .apply(Command::SetScoreStyleOverrides(SetScoreStyleOverridesCmd {
5464                overrides: overrides.clone(),
5465            }))
5466            .unwrap();
5467        assert_eq!(engine.score.style_overrides, overrides);
5468        engine.undo().unwrap();
5469        assert!(engine.score.style_overrides.is_empty());
5470        engine.redo().unwrap();
5471        assert_eq!(engine.score.style_overrides, overrides);
5472
5473        let before = engine.score.clone();
5474        assert!(
5475            engine
5476                .apply(Command::SetScoreStyleOverrides(SetScoreStyleOverridesCmd {
5477                    overrides: vec![ViewStyleOverride {
5478                        property: super::super::score::ViewStyleProperty::TextScale,
5479                        value: f32::NAN,
5480                    }],
5481                }))
5482                .is_err()
5483        );
5484        assert_eq!(
5485            serde_json::to_value(&engine.score).unwrap(),
5486            serde_json::to_value(&before).unwrap()
5487        );
5488    }
5489
5490    #[test]
5491    fn set_object_style_overrides_is_undoable_and_requires_existing_target() {
5492        let mut engine = crate::ScoreEngine::new();
5493        engine.score.texts.push(StyledText {
5494            style: crate::TextStyle::Expression,
5495            text: "Allegro".into(),
5496            placement: None,
5497            offset_x: None,
5498            offset_y: None,
5499            relative_x: None,
5500            relative_y: None,
5501        });
5502        let overrides = vec![ObjectStyleOverride {
5503            target: super::super::score::ObjectStyleTarget::ScoreText { text_index: 0 },
5504            property: super::super::score::ViewStyleProperty::TextScale,
5505            value: 1.2,
5506            provenance: None,
5507        }];
5508        engine
5509            .apply(Command::SetObjectStyleOverrides(
5510                SetObjectStyleOverridesCmd {
5511                    overrides: overrides.clone(),
5512                },
5513            ))
5514            .unwrap();
5515        assert_eq!(engine.score.object_style_overrides, overrides);
5516        engine.undo().unwrap();
5517        assert!(engine.score.object_style_overrides.is_empty());
5518
5519        assert!(
5520            engine
5521                .apply(Command::SetObjectStyleOverrides(
5522                    SetObjectStyleOverridesCmd {
5523                        overrides: vec![ObjectStyleOverride {
5524                            target: super::super::score::ObjectStyleTarget::ScoreText {
5525                                text_index: 1,
5526                            },
5527                            property: super::super::score::ViewStyleProperty::TextScale,
5528                            value: 1.2,
5529                            provenance: None,
5530                        }],
5531                    }
5532                ))
5533                .is_err()
5534        );
5535    }
5536
5537    #[test]
5538    fn set_harmony_range_is_undoable_and_json_compatible() {
5539        let mut score = default_engine_score();
5540        score.parts[0].staves[0].measures[0].voices[0] =
5541            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
5542        score.parts[0].staves[0].measures[0].voices[0][0].chord_symbol = Some(ChordSymbol {
5543            root: "C".to_owned(),
5544            kind: "major".to_owned(),
5545            bass: None,
5546            placement: None,
5547            extender: true,
5548            harmonic_degree: None,
5549            harmony_function: None,
5550            harmony_type: None,
5551            chord_ref: None,
5552            range_end: None,
5553            degrees: Vec::new(),
5554        });
5555        let mut engine = ScoreEngine::new();
5556        engine.replace_score(score);
5557        let command = Command::SetHarmonyRange(SetHarmonyRangeCmd {
5558            part_index: 0,
5559            staff_index: 0,
5560            measure_index: 0,
5561            voice: 0,
5562            note_index: 0,
5563            end: Some(NoteAddr {
5564                part: 0,
5565                staff: 0,
5566                measure: 0,
5567                voice: 0,
5568                note: 0,
5569            }),
5570        });
5571        let json = serde_json::to_string(&command).unwrap();
5572        let restored: Command = serde_json::from_str(&json).unwrap();
5573        engine.apply(restored).unwrap();
5574        assert!(
5575            engine.score.parts[0].staves[0].measures[0].voices[0][0]
5576                .chord_symbol
5577                .as_ref()
5578                .and_then(|chord| chord.range_end.as_ref())
5579                .is_some()
5580        );
5581        engine.undo().unwrap();
5582        assert!(
5583            engine.score.parts[0].staves[0].measures[0].voices[0][0]
5584                .chord_symbol
5585                .as_ref()
5586                .is_some_and(|chord| chord.range_end.is_none())
5587        );
5588    }
5589
5590    #[test]
5591    fn set_harp_pedal_diagrams_is_undoable_and_json_compatible() {
5592        let mut engine = ScoreEngine::new();
5593        let mut diagram = HarpPedalDiagram::default();
5594        diagram.positions[0] = super::super::score::HarpPedalPosition::Flat;
5595        diagram.positions[6] = super::super::score::HarpPedalPosition::Sharp;
5596        let command = Command::SetHarpPedalDiagrams(SetHarpPedalDiagramsCmd {
5597            part_index: 0,
5598            staff_index: 0,
5599            measure_index: 0,
5600            diagrams: vec![diagram.clone()],
5601        });
5602        let restored: Command =
5603            serde_json::from_str(&serde_json::to_string(&command).unwrap()).unwrap();
5604        engine.apply(restored).unwrap();
5605        assert_eq!(
5606            engine.score.parts[0].staves[0].measures[0].harp_pedal_diagrams,
5607            vec![diagram]
5608        );
5609        engine.undo().unwrap();
5610        assert!(
5611            engine.score.parts[0].staves[0].measures[0]
5612                .harp_pedal_diagrams
5613                .is_empty()
5614        );
5615    }
5616
5617    #[test]
5618    fn set_note_placement_is_undoable_and_rejects_non_finite_values() {
5619        let mut score = default_engine_score();
5620        score.parts[0].staves[0].measures[0].voices[0] =
5621            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5622        let mut engine = ScoreEngine::new();
5623        engine.replace_score(score);
5624        let command = Command::SetNotePlacement(SetNotePlacementCmd {
5625            part_index: 0,
5626            staff_index: 0,
5627            measure_index: 0,
5628            voice: 0,
5629            note_index: 0,
5630            offset_x: Some(12.5),
5631            offset_y: Some(-3.0),
5632            relative_x: Some(1.25),
5633            relative_y: Some(-0.5),
5634        });
5635        engine.apply(command).unwrap();
5636        let note = &engine.score.parts[0].staves[0].measures[0].voices[0][0];
5637        assert_eq!(note.offset_x, Some(12.5));
5638        assert_eq!(note.relative_y, Some(-0.5));
5639        engine.undo().unwrap();
5640        assert_eq!(
5641            engine.score.parts[0].staves[0].measures[0].voices[0][0].offset_x,
5642            None
5643        );
5644        let invalid = Command::SetNotePlacement(SetNotePlacementCmd {
5645            part_index: 0,
5646            staff_index: 0,
5647            measure_index: 0,
5648            voice: 0,
5649            note_index: 0,
5650            offset_x: Some(f64::NAN),
5651            offset_y: None,
5652            relative_x: None,
5653            relative_y: None,
5654        });
5655        assert!(engine.apply(invalid).is_err());
5656        assert_eq!(
5657            engine.score.parts[0].staves[0].measures[0].voices[0][0].offset_x,
5658            None
5659        );
5660    }
5661
5662    #[test]
5663    fn set_guitar_bend_alter_updates_note_and_can_clear() {
5664        let mut score = default_engine_score();
5665        apply_command(
5666            &Command::AddNote(AddNoteCmd {
5667                part_index: 0,
5668                staff_index: 0,
5669                measure_index: 0,
5670                voice: 0,
5671                position: 0,
5672                pitch: Some(Pitch::new(Step::G, 4)),
5673                duration: Duration::Quarter,
5674                dot_count: 0,
5675                is_rest: false,
5676                tuplet: None,
5677            }),
5678            &mut score,
5679        )
5680        .unwrap();
5681        let location = SetGuitarBendAlterCmd {
5682            part_index: 0,
5683            staff_index: 0,
5684            measure_index: 0,
5685            voice: 0,
5686            note_index: 0,
5687            alter_cents: Some(200),
5688        };
5689        apply_command(&Command::SetGuitarBendAlter(location.clone()), &mut score).unwrap();
5690        assert_eq!(
5691            score.parts[0].staves[0].measures[0].voices[0][0].guitar_bend_alter_cents,
5692            Some(200)
5693        );
5694        let mut cleared = location;
5695        cleared.alter_cents = None;
5696        apply_command(&Command::SetGuitarBendAlter(cleared), &mut score).unwrap();
5697        assert_eq!(
5698            score.parts[0].staves[0].measures[0].voices[0][0].guitar_bend_alter_cents,
5699            None
5700        );
5701    }
5702
5703    #[test]
5704    fn set_guitar_bend_curve_is_undoable_and_json_compatible() {
5705        let mut score = Score::new("T", 120, 4, 4, 0, 1);
5706        let command = Command::SetGuitarBendCurve(SetGuitarBendCurveCmd {
5707            part_index: 0,
5708            staff_index: 0,
5709            measure_index: 0,
5710            voice: 0,
5711            note_index: 0,
5712            points: vec![
5713                crate::GuitarBendPoint {
5714                    position_per_mille: 0,
5715                    alter_cents: 0,
5716                },
5717                crate::GuitarBendPoint {
5718                    position_per_mille: 500,
5719                    alter_cents: 200,
5720                },
5721                crate::GuitarBendPoint {
5722                    position_per_mille: 1000,
5723                    alter_cents: 0,
5724                },
5725            ],
5726        });
5727        let decoded: Command =
5728            serde_json::from_str(&serde_json::to_string(&command).unwrap()).unwrap();
5729        let mut stack = CommandStack::new(16);
5730        stack.execute(decoded, &mut score).unwrap();
5731        assert_eq!(
5732            score.parts[0].staves[0].measures[0].voices[0][0]
5733                .guitar_bend_curve
5734                .len(),
5735            3
5736        );
5737        stack.undo(&mut score).unwrap();
5738        assert!(
5739            score.parts[0].staves[0].measures[0].voices[0][0]
5740                .guitar_bend_curve
5741                .is_empty()
5742        );
5743    }
5744
5745    #[test]
5746    fn set_duration_updates_note_and_preserves_measure_capacity() {
5747        let mut score = default_engine_score();
5748        apply_command(
5749            &Command::AddNote(AddNoteCmd {
5750                part_index: 0,
5751                staff_index: 0,
5752                measure_index: 0,
5753                voice: 0,
5754                position: 0,
5755                pitch: Some(Pitch::new(Step::C, 4)),
5756                duration: Duration::Quarter,
5757                dot_count: 0,
5758                is_rest: false,
5759                tuplet: None,
5760            }),
5761            &mut score,
5762        )
5763        .unwrap();
5764        apply_command(
5765            &Command::SetDuration(SetDurationCmd {
5766                part_index: 0,
5767                staff_index: 0,
5768                measure_index: 0,
5769                voice: 0,
5770                note_index: 0,
5771                duration: Duration::Half,
5772                dot_count: 1,
5773            }),
5774            &mut score,
5775        )
5776        .unwrap();
5777        let voice = &score.parts[0].staves[0].measures[0].voices[0];
5778        assert_eq!(voice[0].duration, Duration::Half);
5779        assert_eq!(voice[0].dot_count, 1);
5780        assert!((voice.iter().map(|note| note.beats()).sum::<f64>() - 4.0).abs() < 1e-9);
5781    }
5782
5783    #[test]
5784    fn set_tempo_updates_score() {
5785        let mut score = default_engine_score();
5786        let cmd = Command::SetTempo(SetTempoCmd { bpm: 160 });
5787        apply_command(&cmd, &mut score).unwrap();
5788        assert_eq!(score.settings.tempo_bpm, 160);
5789    }
5790
5791    #[test]
5792    fn add_measure_increases_count() {
5793        let mut score = default_engine_score();
5794        let before = score.measure_count();
5795        apply_command(
5796            &Command::AddMeasure(AddMeasureCmd { after_index: 0 }),
5797            &mut score,
5798        )
5799        .unwrap();
5800        assert_eq!(score.measure_count(), before + 1);
5801    }
5802
5803    #[test]
5804    fn delete_measure_decreases_count() {
5805        let mut score = default_engine_score();
5806        let before = score.measure_count();
5807        apply_command(
5808            &Command::DeleteMeasure(DeleteMeasureCmd { measure_index: 0 }),
5809            &mut score,
5810        )
5811        .unwrap();
5812        assert_eq!(score.measure_count(), before - 1);
5813    }
5814
5815    #[test]
5816    fn undo_restores_score() {
5817        let mut stack = CommandStack::new(50);
5818        let mut score = default_engine_score();
5819        let before = score.settings.tempo_bpm;
5820        stack
5821            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
5822            .unwrap();
5823        assert_eq!(score.settings.tempo_bpm, 200);
5824        stack.undo(&mut score).unwrap();
5825        assert_eq!(score.settings.tempo_bpm, before);
5826    }
5827
5828    #[test]
5829    fn redo_reapplies_command() {
5830        let mut stack = CommandStack::new(50);
5831        let mut score = default_engine_score();
5832        stack
5833            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
5834            .unwrap();
5835        stack.undo(&mut score).unwrap();
5836        stack.redo(&mut score).unwrap();
5837        assert_eq!(score.settings.tempo_bpm, 200);
5838    }
5839
5840    #[test]
5841    fn undo_nothing_returns_error() {
5842        let mut stack = CommandStack::new(50);
5843        let mut score = default_engine_score();
5844        assert!(stack.undo(&mut score).is_err());
5845    }
5846
5847    #[test]
5848    fn add_part_appends_part() {
5849        let mut score = default_engine_score();
5850        let before = score.parts.len();
5851        apply_command(
5852            &Command::AddPart(AddPartCmd {
5853                name: "Violin".into(),
5854                short_name: "Vln.".into(),
5855                clefs: vec!["Treble".into()],
5856                midi_channel: 0,
5857                midi_program: 0,
5858            }),
5859            &mut score,
5860        )
5861        .unwrap();
5862        assert_eq!(score.parts.len(), before + 1);
5863    }
5864
5865    #[test]
5866    fn delete_part_removes_part() {
5867        let mut score = default_engine_score();
5868        apply_command(
5869            &Command::AddPart(AddPartCmd {
5870                name: "Violin".into(),
5871                short_name: "V.".into(),
5872                clefs: vec!["Treble".into()],
5873                midi_channel: 0,
5874                midi_program: 0,
5875            }),
5876            &mut score,
5877        )
5878        .unwrap();
5879        let before = score.parts.len();
5880        apply_command(
5881            &Command::DeletePart(DeletePartCmd { part_index: 0 }),
5882            &mut score,
5883        )
5884        .unwrap();
5885        assert_eq!(score.parts.len(), before - 1);
5886    }
5887
5888    #[test]
5889    fn delete_part_out_of_range_returns_err() {
5890        let mut score = default_engine_score();
5891        assert!(
5892            apply_command(
5893                &Command::DeletePart(DeletePartCmd { part_index: 99 }),
5894                &mut score
5895            )
5896            .is_err()
5897        );
5898    }
5899
5900    #[test]
5901    fn delete_part_undo_restores_part() {
5902        let mut stack = CommandStack::new(50);
5903        let mut score = default_engine_score();
5904        apply_command(
5905            &Command::AddPart(AddPartCmd {
5906                name: "Violin".into(),
5907                short_name: "V.".into(),
5908                clefs: vec!["Treble".into()],
5909                midi_channel: 0,
5910                midi_program: 0,
5911            }),
5912            &mut score,
5913        )
5914        .unwrap();
5915        let before = score.parts.len();
5916        stack
5917            .execute(
5918                Command::DeletePart(DeletePartCmd { part_index: 0 }),
5919                &mut score,
5920            )
5921            .unwrap();
5922        assert_eq!(score.parts.len(), before - 1);
5923        stack.undo(&mut score).unwrap();
5924        assert_eq!(score.parts.len(), before);
5925    }
5926
5927    #[test]
5928    fn reorder_parts_remaps_linked_views_and_preserves_undo() {
5929        let mut engine = crate::ScoreEngine::new();
5930        engine
5931            .apply(Command::AddPart(AddPartCmd {
5932                name: "Flute".into(),
5933                short_name: "Fl.".into(),
5934                clefs: vec!["Treble".into()],
5935                midi_channel: 1,
5936                midi_program: 73,
5937            }))
5938            .unwrap();
5939        engine
5940            .score
5941            .views
5942            .push(ScoreView::linked_part("flute", "Flute", 1));
5943        engine.score.part_groups.push(PartGroup {
5944            first_part: 0,
5945            last_part: 1,
5946            symbol: super::super::score::PartGroupSymbol::Bracket,
5947            barlines_connect: false,
5948        });
5949
5950        engine
5951            .apply(Command::ReorderParts(ReorderPartsCmd { order: vec![1, 0] }))
5952            .unwrap();
5953        assert_eq!(engine.score.parts[0].name, "Flute");
5954        assert_eq!(engine.score.views[0].parts, vec![0]);
5955        engine.undo().unwrap();
5956        assert_eq!(engine.score.parts[0].name, "Piano");
5957        assert_eq!(engine.score.views[0].parts, vec![1]);
5958    }
5959
5960    #[test]
5961    fn reorder_parts_rejects_splitting_a_part_group_without_mutation() {
5962        let mut score = Score::template(ScoreTemplate::StringQuartet);
5963        score.part_groups.push(PartGroup {
5964            first_part: 0,
5965            last_part: 1,
5966            symbol: super::super::score::PartGroupSymbol::Bracket,
5967            barlines_connect: false,
5968        });
5969        let before = serde_json::to_value(&score).unwrap();
5970
5971        assert!(
5972            apply_command(
5973                &Command::ReorderParts(ReorderPartsCmd {
5974                    order: vec![0, 2, 1, 3],
5975                }),
5976                &mut score,
5977            )
5978            .is_err()
5979        );
5980        assert_eq!(serde_json::to_value(&score).unwrap(), before);
5981    }
5982
5983    #[test]
5984    fn set_metadata_updates_title() {
5985        let mut score = default_engine_score();
5986        apply_command(
5987            &Command::SetMetadata(SetMetadataCmd {
5988                title: Some("New Title".into()),
5989                ..Default::default()
5990            }),
5991            &mut score,
5992        )
5993        .unwrap();
5994        assert_eq!(score.metadata.title, "New Title");
5995    }
5996
5997    #[test]
5998    fn set_metadata_none_fields_skipped() {
5999        let mut score = default_engine_score();
6000        let original_composer = score.metadata.composer.clone();
6001        apply_command(
6002            &Command::SetMetadata(SetMetadataCmd {
6003                title: Some("X".into()),
6004                ..Default::default()
6005            }),
6006            &mut score,
6007        )
6008        .unwrap();
6009        assert_eq!(score.metadata.composer, original_composer);
6010    }
6011
6012    #[test]
6013    fn set_volta_sets_bracket() {
6014        use crate::model::score::VoltaBracket;
6015        let mut score = default_engine_score();
6016        let volta = VoltaBracket {
6017            number: 1,
6018            kind: "begin_end".into(),
6019            numbers: Vec::new(),
6020        };
6021        apply_command(
6022            &Command::SetVolta(SetVoltaCmd {
6023                measure_index: 0,
6024                volta: Some(volta.clone()),
6025            }),
6026            &mut score,
6027        )
6028        .unwrap();
6029        assert!(score.parts[0].staves[0].measures[0].volta.is_some());
6030    }
6031
6032    #[test]
6033    fn set_volta_none_clears_bracket() {
6034        use crate::model::score::VoltaBracket;
6035        let mut score = default_engine_score();
6036        score.parts[0].staves[0].measures[0].volta = Some(VoltaBracket {
6037            number: 1,
6038            kind: "begin_end".into(),
6039            numbers: Vec::new(),
6040        });
6041        apply_command(
6042            &Command::SetVolta(SetVoltaCmd {
6043                measure_index: 0,
6044                volta: None,
6045            }),
6046            &mut score,
6047        )
6048        .unwrap();
6049        assert!(score.parts[0].staves[0].measures[0].volta.is_none());
6050    }
6051
6052    #[test]
6053    fn set_volta_undo_restores_old() {
6054        use crate::model::score::VoltaBracket;
6055        let mut stack = CommandStack::new(50);
6056        let mut score = default_engine_score();
6057        stack
6058            .execute(
6059                Command::SetVolta(SetVoltaCmd {
6060                    measure_index: 0,
6061                    volta: Some(VoltaBracket {
6062                        number: 1,
6063                        kind: "begin_end".into(),
6064                        numbers: Vec::new(),
6065                    }),
6066                }),
6067                &mut score,
6068            )
6069            .unwrap();
6070        stack.undo(&mut score).unwrap();
6071        assert!(score.parts[0].staves[0].measures[0].volta.is_none());
6072    }
6073
6074    #[test]
6075    fn set_clef_updates_staff_clef() {
6076        use crate::model::notation::Clef;
6077        let mut score = default_engine_score();
6078        apply_command(
6079            &Command::SetClef(SetClefCmd {
6080                part_index: 0,
6081                staff_index: 0,
6082                clef: Clef::Bass,
6083            }),
6084            &mut score,
6085        )
6086        .unwrap();
6087        assert_eq!(score.parts[0].staves[0].clef, Clef::Bass);
6088    }
6089
6090    #[test]
6091    fn set_clef_out_of_range_returns_err() {
6092        use crate::model::notation::Clef;
6093        let mut score = default_engine_score();
6094        assert!(
6095            apply_command(
6096                &Command::SetClef(SetClefCmd {
6097                    part_index: 99,
6098                    staff_index: 0,
6099                    clef: Clef::Bass,
6100                }),
6101                &mut score
6102            )
6103            .is_err()
6104        );
6105    }
6106
6107    #[test]
6108    fn set_part_name_updates_name() {
6109        let mut score = default_engine_score();
6110        apply_command(
6111            &Command::SetPartName(SetPartNameCmd {
6112                part_index: 0,
6113                name: "Violin".into(),
6114                short_name: "Vln.".into(),
6115            }),
6116            &mut score,
6117        )
6118        .unwrap();
6119        assert_eq!(score.parts[0].name, "Violin");
6120        assert_eq!(score.parts[0].short_name, "Vln.");
6121    }
6122
6123    #[test]
6124    fn set_part_name_undo_restores_old() {
6125        let mut stack = CommandStack::new(50);
6126        let mut score = default_engine_score();
6127        let original = score.parts[0].name.clone();
6128        stack
6129            .execute(
6130                Command::SetPartName(SetPartNameCmd {
6131                    part_index: 0,
6132                    name: "Flute".into(),
6133                    short_name: "Fl.".into(),
6134                }),
6135                &mut score,
6136            )
6137            .unwrap();
6138        stack.undo(&mut score).unwrap();
6139        assert_eq!(score.parts[0].name, original);
6140    }
6141
6142    #[test]
6143    fn set_metadata_undo_restores_old_title() {
6144        let mut stack = CommandStack::new(50);
6145        let mut score = default_engine_score();
6146        let original = score.metadata.title.clone();
6147        stack
6148            .execute(
6149                Command::SetMetadata(SetMetadataCmd {
6150                    title: Some("Changed".into()),
6151                    ..Default::default()
6152                }),
6153                &mut score,
6154            )
6155            .unwrap();
6156        assert_ne!(score.metadata.title, original);
6157        stack.undo(&mut score).unwrap();
6158        assert_eq!(score.metadata.title, original);
6159    }
6160
6161    #[test]
6162    fn set_midi_instrument_updates_channel_and_program() {
6163        let mut score = default_engine_score();
6164        apply_command(
6165            &Command::SetMidiInstrument(SetMidiInstrumentCmd {
6166                part_index: 0,
6167                midi_channel: 2,
6168                midi_program: 40,
6169            }),
6170            &mut score,
6171        )
6172        .unwrap();
6173        assert_eq!(score.parts[0].midi_channel, 2);
6174        assert_eq!(score.parts[0].midi_program, 40);
6175    }
6176
6177    #[test]
6178    fn set_midi_instrument_clamps_channel_to_15() {
6179        let mut score = default_engine_score();
6180        apply_command(
6181            &Command::SetMidiInstrument(SetMidiInstrumentCmd {
6182                part_index: 0,
6183                midi_channel: 20,
6184                midi_program: 0,
6185            }),
6186            &mut score,
6187        )
6188        .unwrap();
6189        assert_eq!(score.parts[0].midi_channel, 15);
6190    }
6191
6192    #[test]
6193    fn set_midi_instrument_undo_restores_old() {
6194        let mut stack = CommandStack::new(50);
6195        let mut score = default_engine_score();
6196        score.parts[0].midi_channel = 3;
6197        score.parts[0].midi_program = 10;
6198        stack
6199            .execute(
6200                Command::SetMidiInstrument(SetMidiInstrumentCmd {
6201                    part_index: 0,
6202                    midi_channel: 9,
6203                    midi_program: 114,
6204                }),
6205                &mut score,
6206            )
6207            .unwrap();
6208        stack.undo(&mut score).unwrap();
6209        assert_eq!(score.parts[0].midi_channel, 3);
6210        assert_eq!(score.parts[0].midi_program, 10);
6211    }
6212
6213    #[test]
6214    fn set_transpose_updates_staff() {
6215        let mut score = default_engine_score();
6216        apply_command(
6217            &Command::SetTranspose(SetTransposeCmd {
6218                part_index: 0,
6219                staff_index: 0,
6220                semitones: -2,
6221            }),
6222            &mut score,
6223        )
6224        .unwrap();
6225        assert_eq!(score.parts[0].staves[0].transpose_semitones, -2);
6226    }
6227
6228    #[test]
6229    fn set_transpose_out_of_range_returns_err() {
6230        let mut score = default_engine_score();
6231        assert!(
6232            apply_command(
6233                &Command::SetTranspose(SetTransposeCmd {
6234                    part_index: 99,
6235                    staff_index: 0,
6236                    semitones: -2,
6237                }),
6238                &mut score
6239            )
6240            .is_err()
6241        );
6242    }
6243
6244    #[test]
6245    fn transpose_staff_region_is_undoable_and_json_compatible() {
6246        let mut stack = CommandStack::new(50);
6247        let mut score = Score::new("Region", 120, 4, 4, 0, 2);
6248        for measure in &mut score.parts[0].staves[0].measures {
6249            measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
6250        }
6251        let command = Command::TransposeStaffRegion(TransposeStaffRegionCmd {
6252            part_index: 0,
6253            staff_index: 0,
6254            start_measure: 1,
6255            end_measure: 2,
6256            semitones: 2,
6257            target: RegionalTranspositionTarget::Written,
6258        });
6259        let json = serde_json::to_string(&command).expect("command serializes");
6260        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
6261        assert_eq!(command_key(&decoded), "TransposeStaffRegion");
6262        stack.execute(decoded, &mut score).expect("region applies");
6263        assert_eq!(
6264            score.parts[0].staves[0].measures[0].voices[0][0].pitches[0].to_midi(),
6265            60
6266        );
6267        assert_eq!(
6268            score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].to_midi(),
6269            62
6270        );
6271        stack.undo(&mut score).expect("region undo applies");
6272        assert_eq!(
6273            score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].to_midi(),
6274            60
6275        );
6276    }
6277
6278    #[test]
6279    fn set_tempo_at_measure_sets_tempo() {
6280        let mut score = default_engine_score();
6281        apply_command(
6282            &Command::SetTempoAtMeasure(SetTempoAtMeasureCmd {
6283                measure_index: 0,
6284                bpm: Some(80),
6285            }),
6286            &mut score,
6287        )
6288        .unwrap();
6289        assert_eq!(score.parts[0].staves[0].measures[0].tempo, Some(80));
6290    }
6291
6292    #[test]
6293    fn set_tempo_at_measure_none_clears_tempo() {
6294        let mut score = default_engine_score();
6295        score.parts[0].staves[0].measures[0].tempo = Some(120);
6296        apply_command(
6297            &Command::SetTempoAtMeasure(SetTempoAtMeasureCmd {
6298                measure_index: 0,
6299                bpm: None,
6300            }),
6301            &mut score,
6302        )
6303        .unwrap();
6304        assert!(score.parts[0].staves[0].measures[0].tempo.is_none());
6305    }
6306
6307    #[test]
6308    fn set_tempo_ramp_at_measure_applies() {
6309        let mut score = default_engine_score();
6310        apply_command(
6311            &Command::SetTempoRampAtMeasure(SetTempoRampAtMeasureCmd {
6312                measure_index: 0,
6313                target_bpm: Some(72),
6314            }),
6315            &mut score,
6316        )
6317        .expect("tempo ramp applies");
6318        assert_eq!(score.parts[0].staves[0].measures[0].tempo_ramp_to, Some(72));
6319    }
6320
6321    // ── batch_execute ─────────────────────────────────────────────────────────
6322
6323    #[test]
6324    fn batch_execute_two_commands_single_undo() {
6325        let mut stack = CommandStack::new(50);
6326        let mut score = default_engine_score();
6327        let original_bpm = score.settings.tempo_bpm;
6328        stack
6329            .batch_execute(
6330                vec![
6331                    Command::SetTempo(SetTempoCmd { bpm: 160 }),
6332                    Command::SetTempo(SetTempoCmd { bpm: 180 }),
6333                ],
6334                &mut score,
6335            )
6336            .unwrap();
6337        assert_eq!(score.settings.tempo_bpm, 180);
6338        stack.undo(&mut score).unwrap();
6339        assert_eq!(score.settings.tempo_bpm, original_bpm);
6340    }
6341
6342    #[test]
6343    fn batch_execute_partial_failure_rollback() {
6344        let mut stack = CommandStack::new(50);
6345        let mut score = default_engine_score();
6346        let original_bpm = score.settings.tempo_bpm;
6347        let result = stack.batch_execute(
6348            vec![
6349                Command::SetTempo(SetTempoCmd { bpm: 160 }),
6350                Command::DeleteNote(DeleteNoteCmd {
6351                    note_id: "nonexistent".into(),
6352                    part_index: 99,
6353                    staff_index: 0,
6354                    measure_index: 0,
6355                    voice: 0,
6356                }),
6357            ],
6358            &mut score,
6359        );
6360        assert!(result.is_err());
6361        assert_eq!(score.settings.tempo_bpm, original_bpm);
6362    }
6363
6364    #[test]
6365    fn execute_rejects_invalid_candidate_without_mutating_or_recording_history() {
6366        let mut stack = CommandStack::new(50);
6367        let mut score = default_engine_score();
6368        score.parts[0].staves[0].tablature = Some(crate::TablatureConfig {
6369            lines: 6,
6370            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6371            capo: 0,
6372        });
6373        stack
6374            .execute(
6375                Command::AddNote(AddNoteCmd {
6376                    part_index: 0,
6377                    staff_index: 0,
6378                    measure_index: 0,
6379                    voice: 0,
6380                    position: 0,
6381                    pitch: Some(Pitch::new(Step::C, 4)),
6382                    duration: Duration::Quarter,
6383                    dot_count: 0,
6384                    is_rest: false,
6385                    tuplet: None,
6386                }),
6387                &mut score,
6388            )
6389            .expect("valid pitched note should be accepted");
6390        let original = score.clone();
6391        let result = stack.execute(
6392            Command::SetTabPosition(SetTabPositionCmd {
6393                part_index: 0,
6394                staff_index: 0,
6395                measure_index: 0,
6396                voice: 0,
6397                note_index: 0,
6398                position: Some(crate::TabPosition { string: 7, fret: 0 }),
6399            }),
6400            &mut score,
6401        );
6402        assert!(matches!(result, Err(Error::InvalidScore)));
6403        let note = &score.parts[0].staves[0].measures[0].voices[0][0];
6404        let original_note = &original.parts[0].staves[0].measures[0].voices[0][0];
6405        assert_eq!(note.pitches, original_note.pitches);
6406        assert_eq!(note.duration, original_note.duration);
6407        assert_eq!(note.tab_position, original_note.tab_position);
6408        assert_eq!(note.tab_positions, original_note.tab_positions);
6409        assert!(stack.can_undo());
6410    }
6411
6412    #[test]
6413    fn set_tablature_config_is_undoable_and_json_compatible() {
6414        let mut stack = CommandStack::new(50);
6415        let mut score = default_engine_score();
6416        let config = crate::TablatureConfig {
6417            lines: 6,
6418            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6419            capo: 2,
6420        };
6421        let command = Command::SetTablatureConfig(SetTablatureConfigCmd {
6422            part_index: 0,
6423            staff_index: 0,
6424            config: Some(config.clone()),
6425        });
6426        let json = serde_json::to_string(&command).expect("command should serialize");
6427        let decoded: Command = serde_json::from_str(&json).expect("command should deserialize");
6428        assert_eq!(command_key(&decoded), "SetTablatureConfig");
6429        stack
6430            .execute(decoded, &mut score)
6431            .expect("config should apply");
6432        assert_eq!(score.parts[0].staves[0].tablature, Some(config));
6433        assert_eq!(
6434            score.parts[0].staves[0].presentation.kind,
6435            StaffKind::Tablature
6436        );
6437        stack.undo(&mut score).expect("config undo should apply");
6438        assert!(score.parts[0].staves[0].tablature.is_none());
6439        assert_eq!(
6440            score.parts[0].staves[0].presentation.kind,
6441            StaffKind::Standard
6442        );
6443        stack.redo(&mut score).expect("config redo should apply");
6444        assert_eq!(
6445            score.parts[0].staves[0]
6446                .tablature
6447                .as_ref()
6448                .map(|tab| tab.capo),
6449            Some(2)
6450        );
6451    }
6452
6453    #[test]
6454    fn set_staff_presentation_is_undoable_and_requires_tablature_config() {
6455        let mut stack = CommandStack::new(50);
6456        let mut score = default_engine_score();
6457        let tab = StaffPresentation {
6458            kind: StaffKind::Tablature,
6459            lines: 6,
6460            line_distance: 1.25,
6461            small: true,
6462            cutaway: false,
6463            visible: true,
6464            notehead_scheme: super::super::score::StaffNoteheadScheme::Standard,
6465            tablature_rhythm_display: super::super::score::TablatureRhythmDisplay::FretOnly,
6466            tablature_fret_mark_style: super::super::score::TablatureFretMarkStyle::Arabic,
6467        };
6468        let rejected = Command::SetStaffPresentation(SetStaffPresentationCmd {
6469            part_index: 0,
6470            staff_index: 0,
6471            presentation: tab.clone(),
6472        });
6473        let before = score.clone();
6474        assert!(stack.execute(rejected, &mut score).is_err());
6475        assert_eq!(
6476            score.parts[0].staves[0].presentation,
6477            before.parts[0].staves[0].presentation
6478        );
6479        assert!(!stack.can_undo());
6480
6481        stack
6482            .execute(
6483                Command::SetTablatureConfig(SetTablatureConfigCmd {
6484                    part_index: 0,
6485                    staff_index: 0,
6486                    config: Some(crate::TablatureConfig {
6487                        lines: 6,
6488                        tuning_midi: vec![40, 45, 50, 55, 59, 64],
6489                        capo: 0,
6490                    }),
6491                }),
6492                &mut score,
6493            )
6494            .expect("tablature configuration should apply");
6495        stack
6496            .execute(
6497                Command::SetStaffPresentation(SetStaffPresentationCmd {
6498                    part_index: 0,
6499                    staff_index: 0,
6500                    presentation: tab.clone(),
6501                }),
6502                &mut score,
6503            )
6504            .expect("staff presentation should apply");
6505        assert_eq!(stack.undo_key().as_deref(), Some("SetStaffPresentation"));
6506        assert_eq!(score.parts[0].staves[0].presentation, tab);
6507        stack
6508            .undo(&mut score)
6509            .expect("presentation undo should apply");
6510        assert_eq!(
6511            score.parts[0].staves[0].presentation.kind,
6512            StaffKind::Tablature
6513        );
6514        stack
6515            .redo(&mut score)
6516            .expect("presentation redo should apply");
6517        assert_eq!(
6518            score.parts[0].staves[0].presentation.kind,
6519            StaffKind::Tablature
6520        );
6521    }
6522
6523    #[test]
6524    fn set_instrument_definition_is_undoable_and_rejects_invalid_ranges() {
6525        let mut stack = CommandStack::new(50);
6526        let mut score = default_engine_score();
6527        let mut invalid = InstrumentDefinition::new("violin", "Violin");
6528        invalid.written_range = Some(InstrumentRange {
6529            lowest: 100,
6530            highest: 55,
6531        });
6532        let before = score.clone();
6533        assert!(
6534            stack
6535                .execute(
6536                    Command::SetInstrumentDefinition(SetInstrumentDefinitionCmd {
6537                        part_index: 0,
6538                        definition: Some(invalid),
6539                    }),
6540                    &mut score,
6541                )
6542                .is_err()
6543        );
6544        assert_eq!(score.parts[0].instrument, before.parts[0].instrument);
6545
6546        let mut definition = InstrumentDefinition::new("violin", "Violin");
6547        definition.short_name = "Vln.".to_string();
6548        definition.family = Some("strings".to_string());
6549        definition.written_range = Some(InstrumentRange {
6550            lowest: 55,
6551            highest: 103,
6552        });
6553        definition.sounding_range = definition.written_range;
6554        definition.default_clefs = vec![Clef::Treble];
6555        definition.midi_program = 40;
6556        let command = Command::SetInstrumentDefinition(SetInstrumentDefinitionCmd {
6557            part_index: 0,
6558            definition: Some(definition.clone()),
6559        });
6560        let json = serde_json::to_string(&command).expect("command should serialize");
6561        let decoded: Command = serde_json::from_str(&json).expect("command should deserialize");
6562        assert_eq!(command_key(&decoded), "SetInstrumentDefinition");
6563        stack
6564            .execute(decoded, &mut score)
6565            .expect("definition applies");
6566        assert_eq!(score.parts[0].instrument, Some(definition));
6567        stack.undo(&mut score).expect("definition undo applies");
6568        assert!(score.parts[0].instrument.is_none());
6569        stack.redo(&mut score).expect("definition redo applies");
6570        assert_eq!(
6571            score.parts[0]
6572                .instrument
6573                .as_ref()
6574                .map(|value| value.id.as_str()),
6575            Some("violin")
6576        );
6577    }
6578
6579    #[test]
6580    fn set_percussion_kit_is_undoable_and_rejects_invalid_entries() {
6581        let mut stack = CommandStack::new(50);
6582        let mut score = default_engine_score();
6583        let invalid = Command::SetPercussionKit(SetPercussionKitCmd {
6584            part_index: 0,
6585            instruments: vec![PercussionInstrument {
6586                id: "snare".to_string(),
6587                name: None,
6588                midi_unpitched: Some(38),
6589                staff_position: Some(33),
6590                notehead: None,
6591                preferred_voice: None,
6592                techniques: Vec::new(),
6593            }],
6594        });
6595        assert!(stack.execute(invalid, &mut score).is_err());
6596        assert!(score.parts[0].percussion_instruments.is_empty());
6597
6598        let kit = vec![PercussionInstrument {
6599            id: "snare".to_string(),
6600            name: Some("Acoustic Snare".to_string()),
6601            midi_unpitched: Some(38),
6602            staff_position: Some(0),
6603            notehead: Some(NoteHead::Cross),
6604            preferred_voice: Some(1),
6605            techniques: vec!["rim-shot".to_string()],
6606        }];
6607        let command = Command::SetPercussionKit(SetPercussionKitCmd {
6608            part_index: 0,
6609            instruments: kit.clone(),
6610        });
6611        let json = serde_json::to_string(&command).expect("command serializes");
6612        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
6613        assert_eq!(command_key(&decoded), "SetPercussionKit");
6614        stack.execute(decoded, &mut score).expect("kit applies");
6615        assert_eq!(score.parts[0].percussion_instruments, kit);
6616        stack.undo(&mut score).expect("kit undo applies");
6617        assert!(score.parts[0].percussion_instruments.is_empty());
6618        stack.redo(&mut score).expect("kit redo applies");
6619        assert_eq!(score.parts[0].percussion_instruments, kit);
6620    }
6621
6622    #[test]
6623    fn set_measure_instrument_change_is_undoable_and_json_compatible() {
6624        let mut stack = CommandStack::new(50);
6625        let mut score = default_engine_score();
6626        let mut definition = InstrumentDefinition::new("clarinet-bb", "B-flat Clarinet");
6627        definition.transpose_semitones = -2;
6628        definition.midi_program = 71;
6629        let command = Command::SetMeasureInstrumentChange(SetMeasureInstrumentChangeCmd {
6630            part_index: 0,
6631            staff_index: 0,
6632            measure_index: 2,
6633            definition: Some(definition.clone()),
6634        });
6635        let json = serde_json::to_string(&command).expect("command should serialize");
6636        let decoded: Command = serde_json::from_str(&json).expect("command should deserialize");
6637        assert_eq!(command_key(&decoded), "SetMeasureInstrumentChange");
6638        stack.execute(decoded, &mut score).expect("change applies");
6639        assert_eq!(
6640            score.parts[0].staves[0].measures[2].instrument_change,
6641            Some(definition)
6642        );
6643        stack.undo(&mut score).expect("undo applies");
6644        assert!(
6645            score.parts[0].staves[0].measures[2]
6646                .instrument_change
6647                .is_none()
6648        );
6649    }
6650
6651    #[test]
6652    fn set_measure_tablature_change_is_undoable_and_uses_base_line_count() {
6653        let mut stack = CommandStack::new(50);
6654        let mut score = default_engine_score();
6655        let base = crate::TablatureConfig {
6656            lines: 6,
6657            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6658            capo: 0,
6659        };
6660        score.parts[0].staves[0].tablature = Some(base.clone());
6661        let mut changed = base;
6662        changed.tuning_midi[0] = 38;
6663        changed.capo = 2;
6664        let command = Command::SetMeasureTablatureChange(SetMeasureTablatureChangeCmd {
6665            part_index: 0,
6666            staff_index: 0,
6667            measure_index: 2,
6668            config: Some(changed.clone()),
6669        });
6670        let json = serde_json::to_string(&command).expect("command serializes");
6671        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
6672        assert_eq!(command_key(&decoded), "SetMeasureTablatureChange");
6673        stack.execute(decoded, &mut score).expect("change applies");
6674        assert_eq!(
6675            score.parts[0].staves[0].tablature_at(1),
6676            score.parts[0].staves[0].tablature
6677        );
6678        assert_eq!(score.parts[0].staves[0].tablature_at(2), Some(changed));
6679        stack.undo(&mut score).expect("change undo applies");
6680        assert!(
6681            score.parts[0].staves[0].measures[2]
6682                .tablature_change
6683                .is_none()
6684        );
6685
6686        let invalid = Command::SetMeasureTablatureChange(SetMeasureTablatureChangeCmd {
6687            part_index: 0,
6688            staff_index: 0,
6689            measure_index: 1,
6690            config: Some(crate::TablatureConfig {
6691                lines: 7,
6692                tuning_midi: vec![40, 45, 50, 55, 59, 64, 69],
6693                capo: 0,
6694            }),
6695        });
6696        assert!(stack.execute(invalid, &mut score).is_err());
6697    }
6698
6699    #[test]
6700    fn batch_execute_empty_is_noop() {
6701        let mut stack = CommandStack::new(50);
6702        let mut score = default_engine_score();
6703        stack.batch_execute(vec![], &mut score).unwrap();
6704        assert!(!stack.can_undo());
6705    }
6706
6707    // ── BatchCmd.label ────────────────────────────────────────────────────────
6708
6709    #[test]
6710    fn batch_label_used_as_command_key() {
6711        let cmd = Command::Batch(BatchCmd {
6712            commands: vec![],
6713            label: Some("ApplyAI".to_string()),
6714        });
6715        assert_eq!(command_key(&cmd), "ApplyAI");
6716    }
6717
6718    #[test]
6719    fn batch_no_label_key_is_batch() {
6720        let cmd = Command::Batch(BatchCmd {
6721            commands: vec![],
6722            label: None,
6723        });
6724        assert_eq!(command_key(&cmd), "Batch");
6725    }
6726
6727    #[test]
6728    fn batch_label_survives_json_roundtrip() {
6729        let cmd = Command::Batch(BatchCmd {
6730            commands: vec![Command::SetTempo(SetTempoCmd { bpm: 120 })],
6731            label: Some("PasteSelection".to_string()),
6732        });
6733        let json = serde_json::to_string(&cmd).unwrap();
6734        let cmd2: Command = serde_json::from_str(&json).unwrap();
6735        assert_eq!(command_key(&cmd2), "PasteSelection");
6736    }
6737
6738    #[test]
6739    fn batch_label_in_undo_key() {
6740        let mut stack = CommandStack::new(50);
6741        let mut score = default_engine_score();
6742        let cmd = Command::Batch(BatchCmd {
6743            commands: vec![Command::SetTempo(SetTempoCmd { bpm: 140 })],
6744            label: Some("ApplyAI".to_string()),
6745        });
6746        stack.execute(cmd, &mut score).unwrap();
6747        assert_eq!(stack.undo_key(), Some("ApplyAI".to_string()));
6748    }
6749
6750    #[test]
6751    fn undo_returns_change_hint() {
6752        use crate::model::change_hint::ChangeScope;
6753        let mut stack = CommandStack::new(50);
6754        let mut score = default_engine_score();
6755        stack
6756            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
6757            .unwrap();
6758        let hint = stack.undo(&mut score).unwrap();
6759        assert_eq!(hint.scope, ChangeScope::Global);
6760        assert!(hint.playback_dirty);
6761    }
6762
6763    #[test]
6764    fn redo_returns_change_hint() {
6765        use crate::model::change_hint::ChangeScope;
6766        let mut stack = CommandStack::new(50);
6767        let mut score = default_engine_score();
6768        stack
6769            .execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
6770            .unwrap();
6771        stack.undo(&mut score).unwrap();
6772        let hint = stack.redo(&mut score).unwrap();
6773        assert_eq!(hint.scope, ChangeScope::Global);
6774        assert!(hint.playback_dirty);
6775    }
6776
6777    // ── Feature A: ToggleSlur ─────────────────────────────────────────────
6778
6779    #[test]
6780    fn toggle_slur_sets_start_and_end() {
6781        let mut score = default_engine_score();
6782        let cmd = Command::AddNote(AddNoteCmd {
6783            part_index: 0,
6784            staff_index: 0,
6785            measure_index: 0,
6786            voice: 0,
6787            position: 0,
6788            pitch: Some(Pitch::new(Step::C, 4)),
6789            duration: Duration::Quarter,
6790            dot_count: 0,
6791            is_rest: false,
6792            tuplet: None,
6793        });
6794        apply_command(&cmd, &mut score).unwrap();
6795        apply_command(
6796            &Command::AddNote(AddNoteCmd {
6797                part_index: 0,
6798                staff_index: 0,
6799                measure_index: 0,
6800                voice: 0,
6801                position: 1,
6802                pitch: Some(Pitch::new(Step::D, 4)),
6803                duration: Duration::Quarter,
6804                dot_count: 0,
6805                is_rest: false,
6806                tuplet: None,
6807            }),
6808            &mut score,
6809        )
6810        .unwrap();
6811        let start = NoteAddr {
6812            part: 0,
6813            staff: 0,
6814            measure: 0,
6815            voice: 0,
6816            note: 0,
6817        };
6818        let end = NoteAddr {
6819            part: 0,
6820            staff: 0,
6821            measure: 0,
6822            voice: 0,
6823            note: 1,
6824        };
6825        apply_command(
6826            &Command::ToggleSlur(ToggleSlurCmd {
6827                start: start.clone(),
6828                end: end.clone(),
6829            }),
6830            &mut score,
6831        )
6832        .unwrap();
6833        assert!(score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
6834        assert!(score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
6835        // toggle off
6836        apply_command(
6837            &Command::ToggleSlur(ToggleSlurCmd { start, end }),
6838            &mut score,
6839        )
6840        .unwrap();
6841        assert!(!score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
6842        assert!(!score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
6843    }
6844
6845    // ── Feature B: AddStaff / DeleteStaff ────────────────────────────────
6846
6847    #[test]
6848    fn add_staff_appends_staff_with_correct_measure_count() {
6849        let mut score = default_engine_score();
6850        let before = score.parts[0].staves.len();
6851        let measure_count = score.parts[0].staves[0].measures.len();
6852        apply_command(
6853            &Command::AddStaff(AddStaffCmd {
6854                part_index: 0,
6855                clef: Clef::Bass,
6856            }),
6857            &mut score,
6858        )
6859        .unwrap();
6860        assert_eq!(score.parts[0].staves.len(), before + 1);
6861        let new_staff = score.parts[0].staves.last().unwrap();
6862        assert_eq!(new_staff.measures.len(), measure_count);
6863    }
6864
6865    #[test]
6866    fn add_staff_out_of_range_returns_err() {
6867        let mut score = default_engine_score();
6868        let result = apply_command(
6869            &Command::AddStaff(AddStaffCmd {
6870                part_index: 99,
6871                clef: Clef::Treble,
6872            }),
6873            &mut score,
6874        );
6875        assert!(result.is_err());
6876    }
6877
6878    #[test]
6879    fn delete_staff_removes_extra_staff() {
6880        let mut score = default_engine_score();
6881        apply_command(
6882            &Command::AddStaff(AddStaffCmd {
6883                part_index: 0,
6884                clef: Clef::Bass,
6885            }),
6886            &mut score,
6887        )
6888        .unwrap();
6889        assert_eq!(score.parts[0].staves.len(), 2);
6890        apply_command(
6891            &Command::DeleteStaff(DeleteStaffCmd {
6892                part_index: 0,
6893                staff_index: 1,
6894            }),
6895            &mut score,
6896        )
6897        .unwrap();
6898        assert_eq!(score.parts[0].staves.len(), 1);
6899    }
6900
6901    #[test]
6902    fn delete_last_staff_returns_err() {
6903        let mut score = default_engine_score();
6904        assert_eq!(score.parts[0].staves.len(), 1);
6905        let result = apply_command(
6906            &Command::DeleteStaff(DeleteStaffCmd {
6907                part_index: 0,
6908                staff_index: 0,
6909            }),
6910            &mut score,
6911        );
6912        assert!(result.is_err());
6913    }
6914
6915    // ── Feature D: SetTuplet ──────────────────────────────────────────────
6916
6917    #[test]
6918    fn set_tuplet_assigns_and_clears() {
6919        use crate::model::notation::TupletInfo;
6920        let mut score = default_engine_score();
6921        apply_command(
6922            &Command::AddNote(AddNoteCmd {
6923                part_index: 0,
6924                staff_index: 0,
6925                measure_index: 0,
6926                voice: 0,
6927                position: 0,
6928                pitch: Some(Pitch::new(Step::C, 4)),
6929                duration: Duration::Quarter,
6930                dot_count: 0,
6931                is_rest: false,
6932                tuplet: None,
6933            }),
6934            &mut score,
6935        )
6936        .unwrap();
6937        let ti = TupletInfo {
6938            actual_notes: 3,
6939            normal_notes: 2,
6940        };
6941        apply_command(
6942            &Command::SetTuplet(SetTupletCmd {
6943                part_index: 0,
6944                staff_index: 0,
6945                measure_index: 0,
6946                voice_index: 0,
6947                note_index: 0,
6948                tuplet: Some(ti.clone()),
6949            }),
6950            &mut score,
6951        )
6952        .unwrap();
6953        assert_eq!(
6954            score.parts[0].staves[0].measures[0].voices[0][0].tuplet,
6955            Some(ti)
6956        );
6957        apply_command(
6958            &Command::SetTuplet(SetTupletCmd {
6959                part_index: 0,
6960                staff_index: 0,
6961                measure_index: 0,
6962                voice_index: 0,
6963                note_index: 0,
6964                tuplet: None,
6965            }),
6966            &mut score,
6967        )
6968        .unwrap();
6969        assert!(
6970            score.parts[0].staves[0].measures[0].voices[0][0]
6971                .tuplet
6972                .is_none()
6973        );
6974    }
6975
6976    // ── Feature E: RespellScore ───────────────────────────────────────────
6977
6978    #[test]
6979    fn respell_score_cmd_changes_all_pitches() {
6980        use crate::model::pitch::Step;
6981        let mut score = default_engine_score();
6982        apply_command(
6983            &Command::AddNote(AddNoteCmd {
6984                part_index: 0,
6985                staff_index: 0,
6986                measure_index: 0,
6987                voice: 0,
6988                position: 0,
6989                pitch: Some(Pitch::with_alter(Step::C, 4, 1)), // C#4
6990                duration: Duration::Quarter,
6991                dot_count: 0,
6992                is_rest: false,
6993                tuplet: None,
6994            }),
6995            &mut score,
6996        )
6997        .unwrap();
6998        apply_command(
6999            &Command::RespellScore(RespellScoreCmd { prefer_flat: true }),
7000            &mut score,
7001        )
7002        .unwrap();
7003        let pitch = &score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
7004        assert_eq!(pitch.step, Step::D);
7005        assert_eq!(pitch.alter, -1); // Db4
7006    }
7007
7008    fn respell_fixture(measures: u32) -> Score {
7009        use crate::model::pitch::Step;
7010        let mut score = Score::new("Respell", 120, 4, 4, 0, measures);
7011        for measure in &mut score.parts[0].staves[0].measures {
7012            measure.voices[0] = vec![Note::new(Pitch::with_alter(Step::C, 4, 1), Duration::Whole)];
7013        }
7014        score
7015    }
7016
7017    fn spelled(score: &Score, measure: usize) -> (crate::model::pitch::Step, i8) {
7018        let pitch = &score.parts[0].staves[0].measures[measure].voices[0][0].pitches[0];
7019        (pitch.step.clone(), pitch.alter)
7020    }
7021
7022    #[test]
7023    fn respell_staff_region_changes_only_the_selection_and_is_undoable() {
7024        use crate::model::pitch::Step;
7025        let mut score = respell_fixture(4);
7026        let mut stack = CommandStack::new(50);
7027        let command = Command::RespellStaffRegion(RespellStaffRegionCmd {
7028            part_index: 0,
7029            staff_index: 0,
7030            start_measure: 1,
7031            end_measure: 3,
7032            policy: RespellPolicy::Flat,
7033        });
7034        let json = serde_json::to_string(&command).expect("command serializes");
7035        assert!(json.contains("\"policy\":\"flat\""));
7036        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
7037        assert_eq!(command_key(&decoded), "RespellStaffRegion");
7038        assert_eq!(command_label(&decoded), "Respell Pitches (flat)");
7039        stack.execute(decoded, &mut score).expect("region respells");
7040        assert_eq!(spelled(&score, 0), (Step::C, 1));
7041        assert_eq!(spelled(&score, 1), (Step::D, -1));
7042        assert_eq!(spelled(&score, 2), (Step::D, -1));
7043        assert_eq!(spelled(&score, 3), (Step::C, 1));
7044        stack.undo(&mut score).expect("region respell undoes");
7045        assert!((0..4).all(|measure| spelled(&score, measure) == (Step::C, 1)));
7046        stack.redo(&mut score).expect("region respell redoes");
7047        assert_eq!(spelled(&score, 2), (Step::D, -1));
7048    }
7049
7050    #[test]
7051    fn respell_staff_region_keeps_ties_across_the_boundary_in_one_spelling() {
7052        use crate::model::pitch::Step;
7053        let mut score = respell_fixture(4);
7054        {
7055            let measures = &mut score.parts[0].staves[0].measures;
7056            measures[0].voices[0][0].tie_start = true;
7057            measures[1].voices[0][0].tie_end = true;
7058            measures[1].voices[0][0].tie_start = true;
7059            measures[2].voices[0][0].tie_end = true;
7060        }
7061        let changed = respell_staff_region(&mut score, 0, 0, 1, 2, RespellPolicy::Flat)
7062            .expect("tied region respells");
7063        assert_eq!(changed, (0, 2));
7064        assert_eq!(spelled(&score, 0), (Step::D, -1));
7065        assert_eq!(spelled(&score, 1), (Step::D, -1));
7066        assert_eq!(spelled(&score, 2), (Step::D, -1));
7067        assert_eq!(spelled(&score, 3), (Step::C, 1));
7068    }
7069
7070    #[test]
7071    fn respell_staff_region_follows_local_keys_and_skips_unpitched_notes() {
7072        use crate::model::notation::KeySignature;
7073        use crate::model::pitch::Step;
7074        let mut score = respell_fixture(4);
7075        for measure in &mut score.parts[0].staves[0].measures {
7076            measure.voices[0][0].pitches[0] = Pitch::with_alter(Step::D, 4, -1);
7077        }
7078        score.parts[0].staves[0].measures[2].key_sig = Some(KeySignature {
7079            fifths: -3,
7080            mode: "major".into(),
7081        });
7082        score.parts[0].staves[0].measures[1].voices[0][0].is_unpitched = true;
7083        respell_staff_region(&mut score, 0, 0, 0, 4, RespellPolicy::Key).expect("key respell");
7084        assert_eq!(spelled(&score, 0), (Step::C, 1));
7085        assert_eq!(
7086            spelled(&score, 1),
7087            (Step::D, -1),
7088            "unpitched staff position is kept"
7089        );
7090        assert_eq!(spelled(&score, 2), (Step::D, -1));
7091        assert_eq!(spelled(&score, 3), (Step::D, -1));
7092
7093        assert!(matches!(
7094            respell_staff_region(&mut score, 0, 0, 2, 2, RespellPolicy::Sharp),
7095            Err(Error::InvalidCommand(_))
7096        ));
7097        assert!(matches!(
7098            respell_staff_region(&mut score, 0, 5, 0, 1, RespellPolicy::Sharp),
7099            Err(Error::StaffNotFound(5))
7100        ));
7101        assert!(matches!(
7102            respell_staff_region(&mut score, 0, 0, 0, 5, RespellPolicy::Sharp),
7103            Err(Error::InvalidCommand(_))
7104        ));
7105    }
7106
7107    #[test]
7108    fn pickup_measure_length_limits_editing_and_survives_time_signature_changes() {
7109        use crate::model::score::MeasureLength;
7110        let mut score = Score::new("Pickup", 120, 4, 4, 0, 2);
7111        score.parts[0].staves[0].measures[0].actual_length = Some(MeasureLength {
7112            numerator: 1,
7113            denominator: 4,
7114        });
7115        score.parts[0].staves[0].measures[0].voices[0] = vec![Note::rest(Duration::Quarter)];
7116        assert!(validate(&score).is_valid());
7117        assert_eq!(
7118            crate::measure_beats_remaining(&score, 0, 0, 0, 0).expect("capacity"),
7119            0.0
7120        );
7121        let mut stack = CommandStack::new(50);
7122        stack
7123            .execute(
7124                Command::AddNote(AddNoteCmd {
7125                    part_index: 0,
7126                    staff_index: 0,
7127                    measure_index: 0,
7128                    voice: 0,
7129                    position: 0,
7130                    pitch: Some(Pitch::new(Step::G, 4)),
7131                    duration: Duration::Quarter,
7132                    dot_count: 0,
7133                    is_rest: false,
7134                    tuplet: None,
7135                }),
7136                &mut score,
7137            )
7138            .expect("note fits the pickup after trimming");
7139        let pickup = &score.parts[0].staves[0].measures[0].voices[0];
7140        assert_eq!(pickup.len(), 1);
7141        assert!(!pickup[0].is_rest);
7142
7143        stack
7144            .execute(
7145                Command::SetTimeSignature(SetTimeSignatureCmd {
7146                    numerator: 3,
7147                    denominator: 4,
7148                }),
7149                &mut score,
7150            )
7151            .expect("time signature changes");
7152        let beats: f64 = score.parts[0].staves[0].measures[0].voices[0]
7153            .iter()
7154            .map(|note| note.beats())
7155            .sum();
7156        assert!((beats - 1.0).abs() < 1e-9, "the pickup keeps one beat");
7157
7158        score.parts[0].staves[0].measures[1].actual_length = Some(MeasureLength {
7159            numerator: 0,
7160            denominator: 4,
7161        });
7162        assert!(validate(&score).errors.iter().any(|error| matches!(
7163            error,
7164            crate::ValidationError::InvalidMeasureLength { measure: 1, .. }
7165        )));
7166    }
7167
7168    #[test]
7169    fn set_lyric_addresses_verses_without_touching_verse_one() {
7170        use crate::model::notation::VerseLyric;
7171        let mut score = Score::new("Verses", 120, 4, 4, 0, 1);
7172        score.parts[0].staves[0].measures[0].voices[0] =
7173            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
7174        let lyric = |text: &str| Lyric {
7175            text: text.into(),
7176            syllabic: "single".into(),
7177            extend: false,
7178        };
7179        let set = |verse: Option<u8>, text: Option<&str>| {
7180            Command::SetLyric(SetLyricCmd {
7181                part_index: 0,
7182                staff_index: 0,
7183                measure_index: 0,
7184                voice: 0,
7185                note_index: 0,
7186                lyric: text.map(lyric),
7187                verse,
7188            })
7189        };
7190        let mut stack = CommandStack::new(50);
7191        stack
7192            .execute(set(None, Some("one")), &mut score)
7193            .expect("verse 1");
7194        stack
7195            .execute(set(Some(3), Some("three")), &mut score)
7196            .expect("verse 3");
7197        stack
7198            .execute(set(Some(2), Some("two")), &mut score)
7199            .expect("verse 2");
7200        stack
7201            .execute(set(Some(2), Some("TWO")), &mut score)
7202            .expect("replace verse 2");
7203        let note = |score: &Score| score.parts[0].staves[0].measures[0].voices[0][0].clone();
7204        assert_eq!(note(&score).lyric, Some(lyric("one")));
7205        assert_eq!(
7206            note(&score).additional_lyrics,
7207            vec![
7208                VerseLyric {
7209                    verse: 2,
7210                    lyric: lyric("TWO")
7211                },
7212                VerseLyric {
7213                    verse: 3,
7214                    lyric: lyric("three")
7215                }
7216            ]
7217        );
7218        stack
7219            .execute(set(Some(3), None), &mut score)
7220            .expect("clear verse 3");
7221        assert_eq!(note(&score).additional_lyrics.len(), 1);
7222        stack.undo(&mut score).expect("clear undoes");
7223        assert_eq!(note(&score).additional_lyrics.len(), 2);
7224
7225        // Legacy JSON without `verse` still targets verse 1.
7226        let current = serde_json::to_string(&set(None, None)).expect("SetLyric serializes");
7227        assert!(current.contains("\"verse\":null"));
7228        let legacy: Command = serde_json::from_str(&current.replace(",\"verse\":null", ""))
7229            .expect("legacy SetLyric JSON");
7230        stack
7231            .execute(legacy, &mut score)
7232            .expect("legacy clears verse 1");
7233        assert_eq!(note(&score).lyric, None);
7234        assert_eq!(note(&score).additional_lyrics.len(), 2);
7235
7236        assert!(matches!(
7237            apply_command(&set(Some(0), Some("zero")), &mut score),
7238            Err(Error::InvalidCommand(_))
7239        ));
7240        assert!(matches!(
7241            apply_command(&set(Some(33), Some("too far")), &mut score),
7242            Err(Error::InvalidCommand(_))
7243        ));
7244        score.parts[0].staves[0].measures[0].voices[0][0]
7245            .additional_lyrics
7246            .push(VerseLyric {
7247                verse: 2,
7248                lyric: lyric("duplicate"),
7249            });
7250        assert!(validate(&score).errors.iter().any(|error| matches!(
7251            error,
7252            crate::ValidationError::InvalidLyricVerse { verse: 2, .. }
7253        )));
7254    }
7255
7256    #[test]
7257    fn cycle_enharmonic_spelling_targets_one_chord_member_and_undoes() {
7258        let mut score = Score::new("Enharmonic", 120, 4, 4, 0, 1);
7259        let mut chord = Note::new(Pitch::with_alter(Step::C, 4, 1), Duration::Whole);
7260        chord.pitches.push(Pitch::with_alter(Step::G, 4, 1));
7261        score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
7262        let mut stack = CommandStack::new(50);
7263        let command = Command::CycleEnharmonicSpelling(CycleEnharmonicSpellingCmd {
7264            part_index: 0,
7265            staff_index: 0,
7266            measure_index: 0,
7267            voice: 0,
7268            note_index: 0,
7269            pitch_index: Some(1),
7270        });
7271        let json = serde_json::to_string(&command).expect("command serializes");
7272        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
7273        assert_eq!(command_key(&decoded), "CycleEnharmonicSpelling");
7274        stack.execute(decoded, &mut score).expect("spelling cycles");
7275        let pitches = |score: &Score| {
7276            score.parts[0].staves[0].measures[0].voices[0][0]
7277                .pitches
7278                .clone()
7279        };
7280        assert_eq!(
7281            pitches(&score),
7282            vec![
7283                Pitch::with_alter(Step::C, 4, 1),
7284                Pitch::with_alter(Step::A, 4, -1)
7285            ]
7286        );
7287        stack.undo(&mut score).expect("spelling undoes");
7288        assert_eq!(pitches(&score)[1], Pitch::with_alter(Step::G, 4, 1));
7289
7290        let mut rest_score = Score::new("Rest", 120, 4, 4, 0, 1);
7291        rest_score.parts[0].staves[0].measures[0].voices[0] = vec![Note::rest(Duration::Whole)];
7292        assert!(matches!(
7293            apply_command(&command, &mut rest_score),
7294            Err(Error::InvalidCommand(_))
7295        ));
7296    }
7297
7298    #[test]
7299    fn resequence_rehearsal_marks_continues_the_first_sequence() {
7300        let mut score = Score::new("Marks", 120, 4, 4, 0, 30);
7301        let mut stack = CommandStack::new(50);
7302        for (index, text) in [(0, "A"), (4, "C"), (9, "C"), (12, "Q")] {
7303            for_each_measure_at(&mut score, index, |measure| {
7304                measure.rehearsal = Some(text.into())
7305            });
7306        }
7307        let command = Command::ResequenceRehearsalMarks(ResequenceRehearsalMarksCmd::default());
7308        let json = serde_json::to_string(&command).expect("command serializes");
7309        let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
7310        assert_eq!(command_key(&decoded), "ResequenceRehearsalMarks");
7311        stack
7312            .execute(decoded, &mut score)
7313            .expect("marks resequence");
7314        let marks = |score: &Score| -> Vec<Option<String>> {
7315            [0, 4, 9, 12]
7316                .iter()
7317                .map(|&index| score.parts[0].staves[0].measures[index].rehearsal.clone())
7318                .collect()
7319        };
7320        assert_eq!(
7321            marks(&score),
7322            ["A", "B", "C", "D"].map(|text| Some(text.to_string()))
7323        );
7324        stack.undo(&mut score).expect("resequence undoes");
7325        assert_eq!(marks(&score)[1].as_deref(), Some("C"));
7326
7327        let sequence = RehearsalSequence::detect("Z", 1).expect("letters");
7328        assert_eq!(sequence.nth(1, 1), "AA");
7329        assert_eq!(sequence.nth(2, 1), "AB");
7330        let lower = RehearsalSequence::detect("y", 1).expect("lower letters");
7331        assert_eq!(lower.nth(2, 1), "aa");
7332        assert_eq!(
7333            RehearsalSequence::detect("7", 3)
7334                .expect("numbers")
7335                .nth(2, 9),
7336            "9"
7337        );
7338        assert_eq!(
7339            RehearsalSequence::detect("5", 5)
7340                .expect("measure numbers")
7341                .nth(3, 17),
7342            "17"
7343        );
7344        assert!(RehearsalSequence::detect("Intro", 1).is_none());
7345
7346        for_each_measure_at(&mut score, 0, |measure| {
7347            measure.rehearsal = Some("Intro".into())
7348        });
7349        assert!(matches!(
7350            apply_command(&command, &mut score),
7351            Err(Error::InvalidCommand(_))
7352        ));
7353    }
7354
7355    #[test]
7356    fn system_break_interval_replaces_line_breaks_only() {
7357        let mut score = Score::new("Breaks", 120, 4, 4, 0, 10);
7358        score.parts[0].staves[0].measures[1].system_break = true;
7359        score.parts[0].staves[0].measures[2].page_break = true;
7360        apply_command(
7361            &Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
7362                interval: 4,
7363                start_measure: None,
7364                end_measure: None,
7365            }),
7366            &mut score,
7367        )
7368        .expect("breaks every four measures");
7369        let breaks: Vec<usize> = score.parts[0].staves[0]
7370            .measures
7371            .iter()
7372            .enumerate()
7373            .filter(|(_, measure)| measure.system_break)
7374            .map(|(index, _)| index)
7375            .collect();
7376        assert_eq!(breaks, vec![3, 7]);
7377        assert!(score.parts[0].staves[0].measures[2].page_break);
7378
7379        let mut exact = Score::new("Exact", 120, 4, 4, 0, 8);
7380        apply_command(
7381            &Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
7382                interval: 4,
7383                start_measure: None,
7384                end_measure: None,
7385            }),
7386            &mut exact,
7387        )
7388        .expect("no break after the final measure");
7389        assert!(!exact.parts[0].staves[0].measures[7].system_break);
7390        assert!(exact.parts[0].staves[0].measures[3].system_break);
7391
7392        apply_command(
7393            &Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
7394                interval: 0,
7395                start_measure: Some(0),
7396                end_measure: Some(6),
7397            }),
7398            &mut score,
7399        )
7400        .expect("breaks removed in range");
7401        assert!(!score.parts[0].staves[0].measures[3].system_break);
7402        assert!(score.parts[0].staves[0].measures[7].system_break);
7403        assert!(matches!(
7404            apply_command(
7405                &Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
7406                    interval: 2,
7407                    start_measure: Some(4),
7408                    end_measure: Some(11),
7409                }),
7410                &mut score,
7411            ),
7412            Err(Error::InvalidCommand(_))
7413        ));
7414    }
7415
7416    #[test]
7417    fn remove_trailing_empty_measures_keeps_content_and_final_barline() {
7418        let mut score = Score::new("Trailing", 120, 4, 4, 0, 6);
7419        let mut stack = CommandStack::new(50);
7420        score.parts[0].staves[0].measures[1].voices[0] =
7421            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
7422        score.parts[0].staves[0].measures[5].barline_right = Barline::Final;
7423        let mut fermata_rest = Note::rest(Duration::Whole);
7424        fermata_rest.articulations = vec![Articulation::Fermata];
7425        score.parts[0].staves[0].measures[3].voices[0] = vec![fermata_rest];
7426        stack
7427            .execute(
7428                Command::RemoveTrailingEmptyMeasures(RemoveTrailingEmptyMeasuresCmd {}),
7429                &mut score,
7430            )
7431            .expect("trailing measures removed");
7432        let staff = &score.parts[0].staves[0];
7433        assert_eq!(staff.measures.len(), 4, "a rest with a fermata is content");
7434        assert!(matches!(staff.measures[3].barline_right, Barline::Final));
7435        assert_eq!(staff.measures[3].number, 4);
7436        stack.undo(&mut score).expect("removal undoes");
7437        assert_eq!(score.parts[0].staves[0].measures.len(), 6);
7438
7439        let mut empty = Score::new("Empty", 120, 4, 4, 0, 3);
7440        apply_command(
7441            &Command::RemoveTrailingEmptyMeasures(RemoveTrailingEmptyMeasuresCmd {}),
7442            &mut empty,
7443        )
7444        .expect("empty score keeps one measure");
7445        assert_eq!(empty.parts[0].staves[0].measures.len(), 1);
7446    }
7447
7448    #[test]
7449    fn typed_spanner_commands_are_atomic_and_undoable() {
7450        use crate::model::score::{NotationSpanner, NotationSpannerKind};
7451
7452        let mut score = default_engine_score();
7453        let address = NoteAddr {
7454            part: 0,
7455            staff: 0,
7456            measure: 0,
7457            voice: 0,
7458            note: 0,
7459        };
7460        let spanner = NotationSpanner {
7461            id: "slur-1".to_string(),
7462            kind: NotationSpannerKind::Slur,
7463            start: address.clone(),
7464            end: address,
7465            number: Some(1),
7466            line_type: Some("dashed".to_string()),
7467            text: None,
7468            placement: Some("above".to_string()),
7469            ottava_size: None,
7470            ottava_type: None,
7471        };
7472        let mut stack = CommandStack::new(8);
7473        stack
7474            .execute(Command::AddSpanner(AddSpannerCmd { spanner }), &mut score)
7475            .unwrap();
7476        assert_eq!(score.spanners.len(), 1);
7477        assert_eq!(stack.undo_key().as_deref(), Some("AddSpanner"));
7478
7479        let before_duplicate = score.clone();
7480        let duplicate = score.spanners[0].clone();
7481        assert!(
7482            stack
7483                .execute(
7484                    Command::AddSpanner(AddSpannerCmd { spanner: duplicate }),
7485                    &mut score,
7486                )
7487                .is_err()
7488        );
7489        assert_eq!(score.spanners, before_duplicate.spanners);
7490
7491        let mut updated = score.spanners[0].clone();
7492        updated.number = Some(2);
7493        stack
7494            .execute(
7495                Command::UpdateSpanner(UpdateSpannerCmd { spanner: updated }),
7496                &mut score,
7497            )
7498            .unwrap();
7499        assert_eq!(score.spanners[0].number, Some(2));
7500        stack.undo(&mut score).unwrap();
7501        assert_eq!(score.spanners[0].number, Some(1));
7502        stack.redo(&mut score).unwrap();
7503        assert_eq!(score.spanners[0].number, Some(2));
7504
7505        stack
7506            .execute(
7507                Command::RemoveSpanner(RemoveSpannerCmd {
7508                    id: "slur-1".to_string(),
7509                }),
7510                &mut score,
7511            )
7512            .unwrap();
7513        assert!(score.spanners.is_empty());
7514        stack.undo(&mut score).unwrap();
7515        assert_eq!(score.spanners.len(), 1);
7516    }
7517
7518    #[test]
7519    fn string_quartet_linked_views_roundtrip_and_undo_redo_as_one_contract() {
7520        let mut score = Score::template(ScoreTemplate::StringQuartet);
7521        let mut stack = CommandStack::new(16);
7522        for (part, id, name) in [
7523            (0, "violin-1", "Violin I"),
7524            (1, "violin-2", "Violin II"),
7525            (2, "viola", "Viola"),
7526            (3, "cello", "Cello"),
7527        ] {
7528            stack
7529                .execute(
7530                    Command::UpsertScoreView(UpsertScoreViewCmd {
7531                        view: ScoreView::linked_part(id, name, part),
7532                    }),
7533                    &mut score,
7534                )
7535                .expect("linked part view applies");
7536        }
7537        assert_eq!(score.parts.len(), 4);
7538        assert_eq!(score.views.len(), 4);
7539        for (part, view) in score.views.iter().enumerate() {
7540            assert_eq!(view.parts, vec![part]);
7541            assert_eq!(score.resolve_view(&view.id).unwrap().parts.len(), 1);
7542        }
7543
7544        let restored: Score =
7545            serde_json::from_str(&serde_json::to_string(&score).expect("quartet score serializes"))
7546                .expect("quartet score deserializes");
7547        assert_eq!(restored.views, score.views);
7548
7549        for _ in 0..4 {
7550            stack.undo(&mut score).expect("view undo applies");
7551        }
7552        assert!(score.views.is_empty());
7553        for _ in 0..4 {
7554            stack.redo(&mut score).expect("view redo applies");
7555        }
7556        assert_eq!(score.views, restored.views);
7557    }
7558
7559    #[test]
7560    fn section_break_is_atomic_undoable_and_synced_across_staves() {
7561        let mut score = Score::template(ScoreTemplate::Piano);
7562        let original = score.clone();
7563        let mut stack = CommandStack::new(4);
7564        stack
7565            .execute(
7566                Command::SetSectionBreak(SetSectionBreakCmd {
7567                    measure_index: 2,
7568                    value: true,
7569                }),
7570                &mut score,
7571            )
7572            .expect("section break applies");
7573        assert!(
7574            score
7575                .parts
7576                .iter()
7577                .flat_map(|part| part.staves.iter())
7578                .all(|staff| staff.measures[2].section_break)
7579        );
7580        stack.undo(&mut score).expect("section break undoes");
7581        assert_eq!(
7582            serde_json::to_value(&score).unwrap(),
7583            serde_json::to_value(&original).unwrap()
7584        );
7585
7586        assert!(
7587            stack
7588                .execute(
7589                    Command::SetSectionBreak(SetSectionBreakCmd {
7590                        measure_index: 99,
7591                        value: true,
7592                    }),
7593                    &mut score,
7594                )
7595                .is_err()
7596        );
7597        assert_eq!(
7598            serde_json::to_value(&score).unwrap(),
7599            serde_json::to_value(&original).unwrap()
7600        );
7601    }
7602}