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