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