Skip to main content

acorde_core/model/
commands.rs

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