Skip to main content

acorde_core/model/
commands.rs

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