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