use super::change_hint::{ChangeHint, ChangeScope};
use super::duration::Duration;
use super::fragment::{
MIN_SUPPORTED_SCORE_FRAGMENT_CONTRACT_VERSION, SCORE_FRAGMENT_CONTRACT_VERSION, ScoreFragment,
ScoreFragmentSelection, extract_score_fragment,
};
use super::notation::{
Articulation, Barline, ChordSymbol, Clef, CrossStaff, Dynamic, FiguredBassFigure,
GuitarTechnique, HairpinKind, KeySignature, Lyric, NoteHead, OttavaKind, StyledText,
TablatureConfig, TimeSignature, TupletInfo, VerseLyric,
};
use super::pitch::Pitch;
use super::score::{
HarpPedalDiagram, InstrumentDefinition, InstrumentRange, Measure, NotationSpanner,
NotationSpannerKind, Note, NoteAddr, ObjectStyleOverride, Part, PartGroup,
PercussionInstrument, RegionalTranspositionTarget, RespellPolicy, Score, ScoreTemplate,
ScoreView, Staff, StaffKind, StaffPresentation, ViewStyleOverride, respell_score,
respell_score_to_key, respell_staff_region, transpose_staff_region_checked,
};
use super::validate::validate;
use crate::Error;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use uuid::Uuid;
mod range_commands;
mod spanner_remap;
mod structural_commands;
use self::range_commands::{apply_paste_range, apply_paste_voice};
use self::spanner_remap::{
clear_legacy_spanner_endpoints, note_at, prune_orphaned_spanners, remap_spanners,
};
use self::structural_commands::{apply_join_measures, apply_split_measure};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Command {
AddNote(AddNoteCmd),
AddPitch(AddPitchCmd),
SetDuration(SetDurationCmd),
DeleteNote(DeleteNoteCmd),
AddMeasure(AddMeasureCmd),
DeleteMeasure(DeleteMeasureCmd),
SetTempo(SetTempoCmd),
NewScore(NewScoreCmd),
AddHairpin(AddHairpinCmd),
ToggleTie(ToggleTieCmd),
SetDynamic(SetDynamicCmd),
ToggleArticulation(ToggleArticulationCmd),
SetKeySignature(SetKeySignatureCmd),
SetTimeSignature(SetTimeSignatureCmd),
SetBarline(SetBarlineCmd),
AddPart(AddPartCmd),
DeletePart(DeletePartCmd),
ReorderParts(ReorderPartsCmd),
SetMetadata(SetMetadataCmd),
SetRehearsalMark(SetRehearsalMarkCmd),
SetNavigationMark(SetNavigationMarkCmd),
SetChordSymbol(SetChordSymbolCmd),
SetHarmonyRange(SetHarmonyRangeCmd),
SetFiguredBass(SetFiguredBassCmd),
SetHarpPedalDiagrams(SetHarpPedalDiagramsCmd),
SetGrace(SetGraceCmd),
SetOttava(SetOttavaCmd),
SetLyric(SetLyricCmd),
SetMultiRest(SetMultiRestCmd),
AddPedal(AddPedalCmd),
SetVolta(SetVoltaCmd),
SetClef(SetClefCmd),
SetPartName(SetPartNameCmd),
SetMidiInstrument(SetMidiInstrumentCmd),
SetPercussionKit(SetPercussionKitCmd),
SetInstrumentDefinition(SetInstrumentDefinitionCmd),
SetMeasureInstrumentChange(SetMeasureInstrumentChangeCmd),
SetMeasureTablatureChange(SetMeasureTablatureChangeCmd),
UpsertScoreView(UpsertScoreViewCmd),
RemoveScoreView(RemoveScoreViewCmd),
SetTranspose(SetTransposeCmd),
TransposeStaffRegion(TransposeStaffRegionCmd),
SetTempoAtMeasure(SetTempoAtMeasureCmd),
SetTempoRampAtMeasure(SetTempoRampAtMeasureCmd),
PasteVoice(PasteVoiceCmd),
PasteRange(PasteRangeCmd),
PasteScoreFragment(PasteScoreFragmentCmd),
ExchangeVoices(ExchangeVoicesCmd),
MoveOrCopyVoiceRange(MoveOrCopyVoiceRangeCmd),
SplitMeasure(SplitMeasureCmd),
JoinMeasures(JoinMeasuresCmd),
ImplodeStaves(ImplodeStavesCmd),
ExplodeVoices(ExplodeVoicesCmd),
ExplodeChordPitches(ExplodeChordPitchesCmd),
ScaleVoiceRange(ScaleVoiceRangeCmd),
SetSystemBreak(SetSystemBreakCmd),
SetPageBreak(SetPageBreakCmd),
SetSectionBreak(SetSectionBreakCmd),
ToggleSlur(ToggleSlurCmd),
AddStaff(AddStaffCmd),
DeleteStaff(DeleteStaffCmd),
SetTuplet(SetTupletCmd),
RespellScore(RespellScoreCmd),
RespellScoreToKey(RespellScoreToKeyCmd),
RespellStaffRegion(RespellStaffRegionCmd),
CycleEnharmonicSpelling(CycleEnharmonicSpellingCmd),
ResequenceRehearsalMarks(ResequenceRehearsalMarksCmd),
SetSystemBreakInterval(SetSystemBreakIntervalCmd),
RemoveTrailingEmptyMeasures(RemoveTrailingEmptyMeasuresCmd),
SetStem(SetStemCmd),
SetArpeggio(SetArpeggioCmd),
SetTechniqueText(SetTechniqueTextCmd),
SetFingering(SetFingeringCmd),
SetFingerings(SetFingeringsCmd),
SetStringNumber(SetStringNumberCmd),
SetTabPosition(SetTabPositionCmd),
SetTablatureConfig(SetTablatureConfigCmd),
SetStaffPresentation(SetStaffPresentationCmd),
SetNoteHead(SetNoteHeadCmd),
SetCue(SetCueCmd),
SetUnpitched(SetUnpitchedCmd),
SetInstrumentId(SetInstrumentIdCmd),
SetNotePlacement(SetNotePlacementCmd),
SetGuitarTechnique(SetGuitarTechniqueCmd),
SetGuitarBendAlter(SetGuitarBendAlterCmd),
SetGuitarBendCurve(SetGuitarBendCurveCmd),
SetExpressionText(SetExpressionTextCmd),
SetMeasureText(SetMeasureTextCmd),
SetScoreText(SetScoreTextCmd),
SetScoreStyleOverrides(SetScoreStyleOverridesCmd),
SetObjectStyleOverrides(SetObjectStyleOverridesCmd),
ToggleTrillLine(ToggleTrillLineCmd),
SetGlissando(SetGlissandoCmd),
SetCrossStaff(SetCrossStaffCmd),
SetPartGroup(SetPartGroupCmd),
AddSpanner(AddSpannerCmd),
UpdateSpanner(UpdateSpannerCmd),
RemoveSpanner(RemoveSpannerCmd),
Batch(BatchCmd),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddNoteCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub position: usize,
pub pitch: Option<Pitch>,
pub duration: Duration,
pub dot_count: u8,
pub is_rest: bool,
#[serde(default)]
pub tuplet: Option<TupletInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddPitchCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub pitch: Pitch,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetDurationCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub duration: Duration,
#[serde(default)]
pub dot_count: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddSpannerCmd {
pub spanner: NotationSpanner,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateSpannerCmd {
pub spanner: NotationSpanner,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoveSpannerCmd {
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteNoteCmd {
pub note_id: String,
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddMeasureCmd {
pub after_index: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteMeasureCmd {
pub measure_index: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetTempoCmd {
pub bpm: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewScoreCmd {
pub title: String,
pub composer: String,
pub tempo_bpm: u16,
pub time_numerator: u8,
pub time_denominator: u8,
pub key_fifths: i8,
pub measure_count: u32,
#[serde(default)]
pub template: Option<ScoreTemplate>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddHairpinCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub start_note_idx: usize,
pub end_note_idx: usize,
pub kind: HairpinKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToggleTieCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetDynamicCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub dynamic: Option<Dynamic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToggleArticulationCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub articulation: Articulation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetKeySignatureCmd {
pub fifths: i8,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetTimeSignatureCmd {
pub numerator: u8,
pub denominator: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetBarlineCmd {
pub measure_index: usize,
pub side: String,
pub barline: Barline,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddPartCmd {
pub name: String,
pub short_name: String,
pub clefs: Vec<String>,
#[serde(default)]
pub midi_channel: u8,
#[serde(default)]
pub midi_program: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeletePartCmd {
pub part_index: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReorderPartsCmd {
pub order: Vec<usize>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SetMetadataCmd {
pub title: Option<String>,
pub composer: Option<String>,
pub lyricist: Option<String>,
pub copyright: Option<String>,
pub work_number: Option<String>,
pub movement_title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetRehearsalMarkCmd {
pub measure_index: usize,
pub text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetNavigationMarkCmd {
pub measure_index: usize,
pub mark: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetChordSymbolCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub chord: Option<ChordSymbol>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetHarmonyRangeCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub end: Option<NoteAddr>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetFiguredBassCmd {
pub measure_index: usize,
pub figures: Vec<FiguredBassFigure>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetHarpPedalDiagramsCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub diagrams: Vec<HarpPedalDiagram>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetGraceCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub is_grace: bool,
pub slash: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetOttavaCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub ottava_start: Option<OttavaKind>,
pub ottava_end: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetLyricCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub lyric: Option<Lyric>,
#[serde(default)]
pub verse: Option<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetMultiRestCmd {
pub measure_index: usize,
pub count: Option<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetVoltaCmd {
pub measure_index: usize,
pub volta: Option<super::score::VoltaBracket>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetClefCmd {
pub part_index: usize,
pub staff_index: usize,
pub clef: Clef,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetPartNameCmd {
pub part_index: usize,
pub name: String,
pub short_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetMidiInstrumentCmd {
pub part_index: usize,
pub midi_channel: u8,
pub midi_program: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetPercussionKitCmd {
pub part_index: usize,
pub instruments: Vec<PercussionInstrument>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetInstrumentDefinitionCmd {
pub part_index: usize,
pub definition: Option<InstrumentDefinition>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetMeasureInstrumentChangeCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub definition: Option<InstrumentDefinition>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetMeasureTablatureChangeCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub config: Option<TablatureConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpsertScoreViewCmd {
pub view: ScoreView,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoveScoreViewCmd {
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetTransposeCmd {
pub part_index: usize,
pub staff_index: usize,
pub semitones: i8,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransposeStaffRegionCmd {
pub part_index: usize,
pub staff_index: usize,
pub start_measure: usize,
pub end_measure: usize,
pub semitones: i8,
pub target: RegionalTranspositionTarget,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetTempoAtMeasureCmd {
pub measure_index: usize,
pub bpm: Option<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetTempoRampAtMeasureCmd {
pub measure_index: usize,
pub target_bpm: Option<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PasteVoiceCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice_index: usize,
pub notes: Vec<Note>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetSystemBreakCmd {
pub measure_index: usize,
pub value: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetPageBreakCmd {
pub measure_index: usize,
pub value: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetSectionBreakCmd {
pub measure_index: usize,
pub value: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchCmd {
pub commands: Vec<Command>,
#[serde(default)]
pub label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddPedalCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub start_note_idx: usize,
pub end_note_idx: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PasteRangeCmd {
pub part_index: usize,
pub staff_index: usize,
pub voice_index: usize,
pub target_measure: usize,
pub measures: Vec<Vec<Note>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PasteScoreFragmentCmd {
pub fragment: ScoreFragment,
pub target: NoteAddr,
#[serde(default)]
pub policy: ScoreFragmentPastePolicy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScoreFragmentPastePolicy {
#[default]
Replace,
Merge,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExchangeVoicesCmd {
pub part_index: usize,
pub staff_index: usize,
pub start_measure: usize,
pub end_measure: usize,
pub first_voice: usize,
pub second_voice: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MoveOrCopyVoiceRangeCmd {
pub source_start: NoteAddr,
pub source_end: NoteAddr,
pub target: NoteAddr,
#[serde(default)]
pub move_source: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SplitMeasureCmd {
pub measure_index: usize,
pub split_at_beats: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JoinMeasuresCmd {
pub measure_index: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImplodeStavesCmd {
pub part_index: usize,
pub source_staves: Vec<usize>,
pub target_staff: usize,
pub start_measure: usize,
pub end_measure: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExplodeVoicesCmd {
pub part_index: usize,
pub source_staff: usize,
pub target_staves: Vec<usize>,
pub start_measure: usize,
pub end_measure: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExplodeChordPitchesCmd {
pub part_index: usize,
pub source_staff: usize,
pub target_staves: Vec<usize>,
pub start_measure: usize,
pub end_measure: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DurationScale {
Half,
Double,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TupletScalePolicy {
#[default]
PreserveRatio,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScaleVoiceRangeCmd {
pub part_index: usize,
pub staff_index: usize,
pub voice: usize,
pub start_measure: usize,
pub end_measure: usize,
pub scale: DurationScale,
#[serde(default)]
pub tuplet_policy: TupletScalePolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToggleSlurCmd {
pub start: NoteAddr,
pub end: NoteAddr,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetPartGroupCmd {
pub group: Option<PartGroup>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToggleTrillLineCmd {
pub start: NoteAddr,
pub end: NoteAddr,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddStaffCmd {
pub part_index: usize,
pub clef: Clef,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteStaffCmd {
pub part_index: usize,
pub staff_index: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetTupletCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice_index: usize,
pub note_index: usize,
pub tuplet: Option<TupletInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetStemCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice_index: usize,
pub note_index: usize,
pub stem_up: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetArpeggioCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice_index: usize,
pub note_index: usize,
pub direction: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetTechniqueTextCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetGlissandoCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub start: bool,
pub end: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetCrossStaffCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub placement: Option<CrossStaff>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetFingeringCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub fingering: Option<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetFingeringsCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub fingerings: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetStringNumberCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub string_number: Option<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetTabPositionCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub position: Option<super::notation::TabPosition>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetTablatureConfigCmd {
pub part_index: usize,
pub staff_index: usize,
pub config: Option<TablatureConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetStaffPresentationCmd {
pub part_index: usize,
pub staff_index: usize,
pub presentation: StaffPresentation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetGuitarTechniqueCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub technique: Option<GuitarTechnique>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetGuitarBendAlterCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub alter_cents: Option<i16>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetGuitarBendCurveCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
#[serde(default)]
pub points: Vec<crate::GuitarBendPoint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetExpressionTextCmd {
pub measure_index: usize,
pub text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetMeasureTextCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub text_index: usize,
pub text: Option<StyledText>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetScoreTextCmd {
pub text_index: usize,
pub text: Option<StyledText>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetScoreStyleOverridesCmd {
pub overrides: Vec<ViewStyleOverride>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetObjectStyleOverridesCmd {
pub overrides: Vec<ObjectStyleOverride>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetCueCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub is_cue: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetUnpitchedCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub is_unpitched: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetInstrumentIdCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub instrument_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetNotePlacementCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
#[serde(default)]
pub offset_x: Option<f64>,
#[serde(default)]
pub offset_y: Option<f64>,
#[serde(default)]
pub relative_x: Option<f64>,
#[serde(default)]
pub relative_y: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetNoteHeadCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
pub note_head: NoteHead,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RespellScoreCmd {
pub prefer_flat: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RespellScoreToKeyCmd {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RespellStaffRegionCmd {
pub part_index: usize,
pub staff_index: usize,
pub start_measure: usize,
pub end_measure: usize,
pub policy: RespellPolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CycleEnharmonicSpellingCmd {
pub part_index: usize,
pub staff_index: usize,
pub measure_index: usize,
pub voice: usize,
pub note_index: usize,
#[serde(default)]
pub pitch_index: Option<usize>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ResequenceRehearsalMarksCmd {
#[serde(default)]
pub start_measure: Option<usize>,
#[serde(default)]
pub end_measure: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetSystemBreakIntervalCmd {
pub interval: u32,
#[serde(default)]
pub start_measure: Option<usize>,
#[serde(default)]
pub end_measure: Option<usize>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RemoveTrailingEmptyMeasuresCmd {}
struct UndoEntry {
command: Command,
snapshot: Score,
}
pub struct CommandStack {
history: Vec<UndoEntry>,
future: Vec<(Command, Score)>,
max_depth: usize,
}
impl CommandStack {
pub fn new(max_depth: usize) -> Self {
Self {
history: Vec::new(),
future: Vec::new(),
max_depth,
}
}
pub fn can_undo(&self) -> bool {
!self.history.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.future.is_empty()
}
pub fn execute(&mut self, cmd: Command, score: &mut Score) -> Result<(), Error> {
let snapshot = score.clone();
let mut candidate = snapshot.clone();
apply_command(&cmd, &mut candidate)?;
if !validate(&candidate).is_valid() {
return Err(Error::InvalidScore);
}
*score = candidate;
self.history.push(UndoEntry {
command: cmd,
snapshot,
});
self.future.clear();
if self.history.len() > self.max_depth {
self.history.remove(0);
}
Ok(())
}
pub fn undo(&mut self, score: &mut Score) -> Result<ChangeHint, Error> {
let entry = self.history.last().ok_or(Error::NothingToUndo)?;
if !validate(&entry.snapshot).is_valid() {
return Err(Error::InvalidScore);
}
let entry = self.history.pop().ok_or(Error::NothingToUndo)?;
let hint = command_hint(&entry.command);
let post_snapshot = score.clone();
*score = entry.snapshot;
self.future.push((entry.command, post_snapshot));
if self.future.len() > self.max_depth {
self.future.remove(0);
}
Ok(hint)
}
pub fn redo(&mut self, score: &mut Score) -> Result<ChangeHint, Error> {
let (_, post) = self.future.last().ok_or(Error::NothingToRedo)?;
if !validate(post).is_valid() {
return Err(Error::InvalidScore);
}
let (cmd, post) = self.future.pop().ok_or(Error::NothingToRedo)?;
let hint = command_hint(&cmd);
let snapshot = score.clone();
*score = post;
self.history.push(UndoEntry {
command: cmd,
snapshot,
});
Ok(hint)
}
pub fn history_commands(&self) -> Vec<Command> {
self.history.iter().map(|e| e.command.clone()).collect()
}
pub fn undo_label(&self) -> Option<String> {
self.history.last().map(|e| command_label(&e.command))
}
pub fn redo_label(&self) -> Option<String> {
self.future.last().map(|(cmd, _)| command_label(cmd))
}
pub fn undo_key(&self) -> Option<String> {
self.history.last().map(|e| command_key(&e.command))
}
pub fn redo_key(&self) -> Option<String> {
self.future.last().map(|(cmd, _)| command_key(cmd))
}
pub fn batch_execute(&mut self, cmds: Vec<Command>, score: &mut Score) -> Result<(), Error> {
if cmds.is_empty() {
return Ok(());
}
let snapshot = score.clone();
let mut candidate = snapshot.clone();
for cmd in &cmds {
apply_command(cmd, &mut candidate)?;
}
if !validate(&candidate).is_valid() {
return Err(Error::InvalidScore);
}
*score = candidate;
self.history.push(UndoEntry {
command: Command::Batch(BatchCmd {
commands: cmds,
label: None,
}),
snapshot,
});
self.future.clear();
if self.history.len() > self.max_depth {
self.history.remove(0);
}
Ok(())
}
pub fn batch_execute_labeled(
&mut self,
cmds: Vec<Command>,
label: String,
score: &mut Score,
) -> Result<(), Error> {
if cmds.is_empty() {
return Ok(());
}
let snapshot = score.clone();
let mut candidate = snapshot.clone();
for cmd in &cmds {
apply_command(cmd, &mut candidate)?;
}
if !validate(&candidate).is_valid() {
return Err(Error::InvalidScore);
}
*score = candidate;
self.history.push(UndoEntry {
command: Command::Batch(BatchCmd {
commands: cmds,
label: Some(label),
}),
snapshot,
});
self.future.clear();
if self.history.len() > self.max_depth {
self.history.remove(0);
}
Ok(())
}
}
pub fn command_hint(cmd: &Command) -> ChangeHint {
use ChangeScope::*;
macro_rules! hint {
($scope:expr, $layout:expr, $playback:expr) => {
ChangeHint {
scope: $scope,
layout_dirty: $layout,
playback_dirty: $playback,
}
};
}
macro_rules! meas {
($c:expr) => {
Measures {
part: $c.part_index,
staff: $c.staff_index,
start: $c.measure_index,
end: $c.measure_index + 1,
}
};
}
match cmd {
Command::NewScore(_)
| Command::AddPart(_)
| Command::DeletePart(_)
| Command::ReorderParts(_)
| Command::AddMeasure(_)
| Command::DeleteMeasure(_) => hint!(Global, true, true),
Command::SetTempo(_) => hint!(Global, false, true),
Command::SetMetadata(_) => hint!(Global, false, false),
Command::SetKeySignature(_) => hint!(Global, true, false),
Command::SetTimeSignature(_) => hint!(Global, true, true),
Command::SetBarline(_)
| Command::SetVolta(_)
| Command::SetRehearsalMark(_)
| Command::SetNavigationMark(_)
| Command::SetExpressionText(_) => hint!(Global, false, false),
Command::SetMeasureText(c) => hint!(
Measures {
part: c.part_index,
staff: c.staff_index,
start: c.measure_index,
end: c.measure_index + 1
},
false,
false
),
Command::SetScoreText(_)
| Command::SetScoreStyleOverrides(_)
| Command::SetObjectStyleOverrides(_) => hint!(Global, true, false),
Command::SetMultiRest(_) => hint!(Global, true, false),
Command::SetTempoAtMeasure(_) => hint!(Global, false, true),
Command::SetTempoRampAtMeasure(_) => hint!(Global, false, true),
Command::SetPartName(c) => hint!(Part(c.part_index), false, false),
Command::SetMidiInstrument(c) => hint!(Part(c.part_index), false, true),
Command::SetPercussionKit(c) => hint!(Part(c.part_index), true, true),
Command::SetInstrumentDefinition(c) => hint!(Part(c.part_index), true, false),
Command::SetMeasureInstrumentChange(c) => hint!(meas!(c), true, true),
Command::SetMeasureTablatureChange(c) => hint!(meas!(c), true, true),
Command::UpsertScoreView(_) | Command::RemoveScoreView(_) => hint!(Global, true, false),
Command::SetTranspose(c) => hint!(Part(c.part_index), false, true),
Command::TransposeStaffRegion(c) => hint!(
Measures {
part: c.part_index,
staff: c.staff_index,
start: c.start_measure,
end: c.end_measure
},
true,
true
),
Command::SetClef(c) => hint!(Part(c.part_index), true, false),
Command::AddNote(c) => hint!(meas!(c), false, true),
Command::AddPitch(c) => hint!(meas!(c), false, true),
Command::SetDuration(c) => hint!(meas!(c), false, true),
Command::DeleteNote(c) => hint!(meas!(c), false, true),
Command::PasteVoice(c) => hint!(meas!(c), false, true),
Command::PasteRange(c) => hint!(
Measures {
part: c.part_index,
staff: c.staff_index,
start: c.target_measure,
end: c.target_measure + c.measures.len()
},
false,
true
),
Command::PasteScoreFragment(_) => hint!(Global, true, true),
Command::ExchangeVoices(c) => hint!(
Measures {
part: c.part_index,
staff: c.staff_index,
start: c.start_measure.min(c.end_measure),
end: c.start_measure.max(c.end_measure) + 1,
},
true,
true
),
Command::MoveOrCopyVoiceRange(_) => hint!(Global, true, true),
Command::SplitMeasure(_) => hint!(Global, true, true),
Command::JoinMeasures(_) => hint!(Global, true, true),
Command::ImplodeStaves(_) | Command::ExplodeVoices(_) => hint!(Global, true, true),
Command::ExplodeChordPitches(_) => hint!(Global, true, true),
Command::ScaleVoiceRange(c) => hint!(
Measures {
part: c.part_index,
staff: c.staff_index,
start: c.start_measure.min(c.end_measure),
end: c.start_measure.max(c.end_measure) + 1,
},
true,
true
),
Command::AddHairpin(c) => hint!(meas!(c), false, true),
Command::ToggleTie(c) => hint!(meas!(c), false, true),
Command::SetDynamic(c) => hint!(meas!(c), false, true),
Command::ToggleArticulation(c) => hint!(meas!(c), false, true),
Command::SetGrace(c) => hint!(meas!(c), false, true),
Command::SetOttava(c) => hint!(meas!(c), false, true),
Command::SetLyric(c) => hint!(meas!(c), false, true),
Command::AddPedal(c) => hint!(meas!(c), false, true),
Command::SetChordSymbol(c) => hint!(meas!(c), false, true),
Command::SetHarmonyRange(c) => hint!(meas!(c), false, true),
Command::SetFiguredBass(_) => hint!(Global, false, true),
Command::SetHarpPedalDiagrams(c) => hint!(meas!(c), false, false),
Command::SetSystemBreak(_) | Command::SetPageBreak(_) | Command::SetSectionBreak(_) => {
hint!(Global, true, false)
}
Command::ToggleSlur(_) | Command::ToggleTrillLine(_) => hint!(Global, true, false),
Command::SetGlissando(c) => hint!(meas!(c), true, true),
Command::SetCrossStaff(c) => hint!(meas!(c), true, true),
Command::AddSpanner(_) | Command::UpdateSpanner(_) | Command::RemoveSpanner(_) => {
hint!(Global, true, true)
}
Command::SetPartGroup(_) => hint!(Global, false, false),
Command::AddStaff(_) | Command::DeleteStaff(_) => hint!(Global, true, true),
Command::SetTuplet(c) => hint!(meas!(c), false, true),
Command::RespellScore(_) | Command::RespellScoreToKey(_) => hint!(Global, true, true),
Command::RespellStaffRegion(c) => hint!(Part(c.part_index), true, true),
Command::CycleEnharmonicSpelling(c) => hint!(meas!(c), true, true),
Command::ResequenceRehearsalMarks(_) | Command::SetSystemBreakInterval(_) => {
hint!(Global, true, false)
}
Command::RemoveTrailingEmptyMeasures(_) => hint!(Global, true, true),
Command::SetStem(c) => hint!(meas!(c), false, false),
Command::SetArpeggio(c) => hint!(meas!(c), false, false),
Command::SetTechniqueText(c) => hint!(meas!(c), false, false),
Command::SetFingering(c) => hint!(meas!(c), false, false),
Command::SetFingerings(c) => hint!(meas!(c), false, false),
Command::SetStringNumber(c) => hint!(meas!(c), false, false),
Command::SetTabPosition(c) => hint!(meas!(c), false, false),
Command::SetTablatureConfig(c) => hint!(Part(c.part_index), true, true),
Command::SetStaffPresentation(c) => hint!(Part(c.part_index), true, false),
Command::SetGuitarTechnique(c) => hint!(meas!(c), false, false),
Command::SetGuitarBendAlter(c) => hint!(meas!(c), false, false),
Command::SetGuitarBendCurve(c) => hint!(meas!(c), false, false),
Command::SetNoteHead(c) => hint!(meas!(c), false, false),
Command::SetCue(c) => hint!(meas!(c), false, true),
Command::SetUnpitched(c) => hint!(meas!(c), false, true),
Command::SetInstrumentId(c) => hint!(meas!(c), false, true),
Command::SetNotePlacement(c) => hint!(meas!(c), true, false),
Command::Batch(c) => {
let Some(first) = c.commands.first() else {
return hint!(Global, false, false);
};
let mut merged = command_hint(first);
for cmd in c.commands.iter().skip(1) {
merged = merged.merge(command_hint(cmd));
}
merged
}
}
}
pub fn command_label(cmd: &Command) -> String {
match cmd {
Command::AddNote(_) => "Add Note".to_string(),
Command::AddPitch(_) => "Add Pitch".to_string(),
Command::SetDuration(_) => "Set Duration".to_string(),
Command::DeleteNote(_) => "Delete Note".to_string(),
Command::AddMeasure(_) => "Add Measure".to_string(),
Command::DeleteMeasure(_) => "Delete Measure".to_string(),
Command::SetTempo(_) => "Set Tempo".to_string(),
Command::NewScore(_) => "New Score".to_string(),
Command::AddHairpin(_) => "Add Hairpin".to_string(),
Command::ToggleTie(_) => "Toggle Tie".to_string(),
Command::SetDynamic(_) => "Set Dynamic".to_string(),
Command::ToggleArticulation(_) => "Toggle Articulation".to_string(),
Command::SetKeySignature(_) => "Set Key Signature".to_string(),
Command::SetTimeSignature(_) => "Set Time Signature".to_string(),
Command::SetBarline(_) => "Set Barline".to_string(),
Command::AddPart(_) => "Add Part".to_string(),
Command::DeletePart(_) => "Delete Part".to_string(),
Command::ReorderParts(_) => "Reorder Parts".to_string(),
Command::SetMetadata(_) => "Set Metadata".to_string(),
Command::SetRehearsalMark(_) => "Set Rehearsal Mark".to_string(),
Command::SetNavigationMark(_) => "Set Navigation Mark".to_string(),
Command::SetChordSymbol(_) => "Set Chord Symbol".to_string(),
Command::SetHarmonyRange(_) => "Set Harmony Range".to_string(),
Command::SetHarpPedalDiagrams(_) => "Set Harp Pedal Diagrams".to_string(),
Command::SetFiguredBass(_) => "Set Figured Bass".to_string(),
Command::SetGrace(_) => "Set Grace Note".to_string(),
Command::SetOttava(_) => "Set Ottava".to_string(),
Command::SetLyric(_) => "Set Lyric".to_string(),
Command::SetMultiRest(_) => "Set Multi-Rest".to_string(),
Command::AddPedal(_) => "Add Pedal".to_string(),
Command::SetVolta(_) => "Set Volta".to_string(),
Command::SetClef(_) => "Set Clef".to_string(),
Command::SetPartName(_) => "Set Part Name".to_string(),
Command::SetMidiInstrument(_) => "Set MIDI Instrument".to_string(),
Command::SetPercussionKit(_) => "Set Percussion Kit".to_string(),
Command::SetInstrumentDefinition(_) => "Set Instrument Definition".to_string(),
Command::SetMeasureInstrumentChange(_) => "Set Measure Instrument Change".to_string(),
Command::SetMeasureTablatureChange(_) => "Set Measure Tablature Change".to_string(),
Command::UpsertScoreView(_) => "Update Score View".to_string(),
Command::RemoveScoreView(_) => "Remove Score View".to_string(),
Command::SetTranspose(_) => "Set Transpose".to_string(),
Command::TransposeStaffRegion(_) => "Transpose Staff Region".to_string(),
Command::SetTempoAtMeasure(_) => "Set Tempo".to_string(),
Command::SetTempoRampAtMeasure(_) => "Set Tempo Ramp".to_string(),
Command::PasteVoice(_) => "Paste Voice".to_string(),
Command::PasteRange(_) => "Paste Range".to_string(),
Command::PasteScoreFragment(_) => "Paste Score Fragment".to_string(),
Command::ExchangeVoices(_) => "Exchange Voices".to_string(),
Command::MoveOrCopyVoiceRange(c) => if c.move_source {
"Move Voice Range"
} else {
"Copy Voice Range"
}
.to_string(),
Command::SplitMeasure(_) => "Split Measure".to_string(),
Command::JoinMeasures(_) => "Join Measures".to_string(),
Command::ImplodeStaves(_) => "Implode Staves".to_string(),
Command::ExplodeVoices(_) => "Explode Voices".to_string(),
Command::ExplodeChordPitches(_) => "Explode Chord Pitches".to_string(),
Command::ScaleVoiceRange(c) => match c.scale {
DurationScale::Half => "Halve Voice Durations",
DurationScale::Double => "Double Voice Durations",
}
.to_string(),
Command::SetSystemBreak(_) => "Set System Break".to_string(),
Command::SetPageBreak(_) => "Set Page Break".to_string(),
Command::SetSectionBreak(_) => "Set Section Break".to_string(),
Command::ToggleSlur(_) => "Toggle Slur".to_string(),
Command::AddStaff(_) => "Add Staff".to_string(),
Command::DeleteStaff(_) => "Delete Staff".to_string(),
Command::SetTuplet(c) => if c.tuplet.is_some() {
"Set Tuplet"
} else {
"Clear Tuplet"
}
.to_string(),
Command::SetInstrumentId(_) => "Set Note Instrument".to_string(),
Command::SetNotePlacement(_) => "Set Note Placement".to_string(),
Command::SetUnpitched(c) => if c.is_unpitched {
"Set Unpitched Note"
} else {
"Clear Unpitched Note"
}
.to_string(),
Command::RespellScore(c) => if c.prefer_flat {
"Respell Score (flat)"
} else {
"Respell Score (sharp)"
}
.to_string(),
Command::RespellScoreToKey(_) => "Respell Score to Key".to_string(),
Command::RespellStaffRegion(c) => match c.policy {
RespellPolicy::Flat => "Respell Pitches (flat)",
RespellPolicy::Sharp => "Respell Pitches (sharp)",
RespellPolicy::Key => "Respell Pitches to Key",
}
.to_string(),
Command::CycleEnharmonicSpelling(_) => "Change Enharmonic Spelling".to_string(),
Command::ResequenceRehearsalMarks(_) => "Resequence Rehearsal Marks".to_string(),
Command::SetSystemBreakInterval(c) => {
if c.interval == 0 {
"Remove System Breaks".to_string()
} else {
format!("System Break Every {} Measures", c.interval)
}
}
Command::RemoveTrailingEmptyMeasures(_) => "Remove Empty Trailing Measures".to_string(),
Command::SetStem(_) => "Set Stem".to_string(),
Command::SetArpeggio(_) => "Set Arpeggio".to_string(),
Command::SetTechniqueText(_) => "Set Technique Text".to_string(),
Command::SetFingering(_) => "Set Fingering".to_string(),
Command::SetFingerings(_) => "Set Fingering Candidates".to_string(),
Command::SetStringNumber(_) => "Set String Number".to_string(),
Command::SetTabPosition(_) => "Set Tablature Position".to_string(),
Command::SetTablatureConfig(_) => "Set Tablature Configuration".to_string(),
Command::SetStaffPresentation(_) => "Set Staff Presentation".to_string(),
Command::SetGuitarTechnique(_) => "Set Guitar Technique".to_string(),
Command::SetGuitarBendAlter(_) => "Set Guitar Bend Alter".to_string(),
Command::SetGuitarBendCurve(_) => "Set Guitar Bend Curve".to_string(),
Command::SetNoteHead(_) => "Set Note Head".to_string(),
Command::SetCue(c) => if c.is_cue {
"Set Cue Note"
} else {
"Clear Cue Note"
}
.to_string(),
Command::SetExpressionText(_) => "Set Expression Text".to_string(),
Command::SetMeasureText(c) => match c.text {
Some(_) => "Set Measure Text",
None => "Remove Measure Text",
}
.to_string(),
Command::SetScoreText(c) => match c.text {
Some(_) => "Set Score Text",
None => "Remove Score Text",
}
.to_string(),
Command::SetScoreStyleOverrides(_) => "Set Score Style Defaults".to_string(),
Command::SetObjectStyleOverrides(_) => "Set Object Style Overrides".to_string(),
Command::ToggleTrillLine(_) => "Toggle Trill Line".to_string(),
Command::SetGlissando(_) => "Set Glissando".to_string(),
Command::SetCrossStaff(_) => "Set Cross-Staff Placement".to_string(),
Command::SetPartGroup(_) => "Set Part Group".to_string(),
Command::AddSpanner(_) => "Add Notation Spanner".to_string(),
Command::UpdateSpanner(_) => "Update Notation Spanner".to_string(),
Command::RemoveSpanner(_) => "Remove Notation Spanner".to_string(),
Command::Batch(c) => c.label.clone().unwrap_or_else(|| {
c.commands
.first()
.map(command_label)
.unwrap_or_else(|| "Batch".to_string())
}),
}
}
pub fn command_key(cmd: &Command) -> String {
match cmd {
Command::AddNote(_) => "AddNote".to_string(),
Command::AddPitch(_) => "AddPitch".to_string(),
Command::SetDuration(_) => "SetDuration".to_string(),
Command::DeleteNote(_) => "DeleteNote".to_string(),
Command::AddMeasure(_) => "AddMeasure".to_string(),
Command::DeleteMeasure(_) => "DeleteMeasure".to_string(),
Command::SetTempo(_) => "SetTempo".to_string(),
Command::NewScore(_) => "NewScore".to_string(),
Command::AddHairpin(_) => "AddHairpin".to_string(),
Command::ToggleTie(_) => "ToggleTie".to_string(),
Command::SetDynamic(_) => "SetDynamic".to_string(),
Command::ToggleArticulation(_) => "ToggleArticulation".to_string(),
Command::SetKeySignature(_) => "SetKeySignature".to_string(),
Command::SetTimeSignature(_) => "SetTimeSignature".to_string(),
Command::SetBarline(_) => "SetBarline".to_string(),
Command::AddPart(_) => "AddPart".to_string(),
Command::DeletePart(_) => "DeletePart".to_string(),
Command::ReorderParts(_) => "ReorderParts".to_string(),
Command::SetMetadata(_) => "SetMetadata".to_string(),
Command::SetRehearsalMark(_) => "SetRehearsalMark".to_string(),
Command::SetNavigationMark(_) => "SetNavigationMark".to_string(),
Command::SetChordSymbol(_) => "SetChordSymbol".to_string(),
Command::SetHarmonyRange(_) => "SetHarmonyRange".to_string(),
Command::SetFiguredBass(_) => "SetFiguredBass".to_string(),
Command::SetHarpPedalDiagrams(_) => "SetHarpPedalDiagrams".to_string(),
Command::SetGrace(_) => "SetGrace".to_string(),
Command::SetOttava(_) => "SetOttava".to_string(),
Command::SetLyric(_) => "SetLyric".to_string(),
Command::SetMultiRest(_) => "SetMultiRest".to_string(),
Command::AddPedal(_) => "AddPedal".to_string(),
Command::SetVolta(_) => "SetVolta".to_string(),
Command::SetClef(_) => "SetClef".to_string(),
Command::SetPartName(_) => "SetPartName".to_string(),
Command::SetMidiInstrument(_) => "SetMidiInstrument".to_string(),
Command::SetPercussionKit(_) => "SetPercussionKit".to_string(),
Command::SetInstrumentDefinition(_) => "SetInstrumentDefinition".to_string(),
Command::SetMeasureInstrumentChange(_) => "SetMeasureInstrumentChange".to_string(),
Command::SetMeasureTablatureChange(_) => "SetMeasureTablatureChange".to_string(),
Command::UpsertScoreView(_) => "UpsertScoreView".to_string(),
Command::RemoveScoreView(_) => "RemoveScoreView".to_string(),
Command::SetTranspose(_) => "SetTranspose".to_string(),
Command::TransposeStaffRegion(_) => "TransposeStaffRegion".to_string(),
Command::SetTempoAtMeasure(_) => "SetTempoAtMeasure".to_string(),
Command::SetTempoRampAtMeasure(_) => "SetTempoRampAtMeasure".to_string(),
Command::PasteVoice(_) => "PasteVoice".to_string(),
Command::PasteRange(_) => "PasteRange".to_string(),
Command::PasteScoreFragment(_) => "PasteScoreFragment".to_string(),
Command::ExchangeVoices(_) => "ExchangeVoices".to_string(),
Command::MoveOrCopyVoiceRange(_) => "MoveOrCopyVoiceRange".to_string(),
Command::SplitMeasure(_) => "SplitMeasure".to_string(),
Command::JoinMeasures(_) => "JoinMeasures".to_string(),
Command::ImplodeStaves(_) => "ImplodeStaves".to_string(),
Command::ExplodeVoices(_) => "ExplodeVoices".to_string(),
Command::ExplodeChordPitches(_) => "ExplodeChordPitches".to_string(),
Command::ScaleVoiceRange(_) => "ScaleVoiceRange".to_string(),
Command::SetSystemBreak(_) => "SetSystemBreak".to_string(),
Command::SetPageBreak(_) => "SetPageBreak".to_string(),
Command::SetSectionBreak(_) => "SetSectionBreak".to_string(),
Command::ToggleSlur(_) => "ToggleSlur".to_string(),
Command::AddStaff(_) => "AddStaff".to_string(),
Command::DeleteStaff(_) => "DeleteStaff".to_string(),
Command::SetTuplet(_) => "SetTuplet".to_string(),
Command::RespellScore(_) => "RespellScore".to_string(),
Command::RespellScoreToKey(_) => "RespellScoreToKey".to_string(),
Command::RespellStaffRegion(_) => "RespellStaffRegion".to_string(),
Command::CycleEnharmonicSpelling(_) => "CycleEnharmonicSpelling".to_string(),
Command::ResequenceRehearsalMarks(_) => "ResequenceRehearsalMarks".to_string(),
Command::SetSystemBreakInterval(_) => "SetSystemBreakInterval".to_string(),
Command::RemoveTrailingEmptyMeasures(_) => "RemoveTrailingEmptyMeasures".to_string(),
Command::SetStem(_) => "SetStem".to_string(),
Command::SetArpeggio(_) => "SetArpeggio".to_string(),
Command::SetTechniqueText(_) => "SetTechniqueText".to_string(),
Command::SetFingering(_) => "SetFingering".to_string(),
Command::SetFingerings(_) => "SetFingerings".to_string(),
Command::SetStringNumber(_) => "SetStringNumber".to_string(),
Command::SetTabPosition(_) => "SetTabPosition".to_string(),
Command::SetTablatureConfig(_) => "SetTablatureConfig".to_string(),
Command::SetStaffPresentation(_) => "SetStaffPresentation".to_string(),
Command::SetGuitarTechnique(_) => "SetGuitarTechnique".to_string(),
Command::SetGuitarBendAlter(_) => "SetGuitarBendAlter".to_string(),
Command::SetGuitarBendCurve(_) => "SetGuitarBendCurve".to_string(),
Command::SetNoteHead(_) => "SetNoteHead".to_string(),
Command::SetCue(_) => "SetCue".to_string(),
Command::SetUnpitched(_) => "SetUnpitched".to_string(),
Command::SetInstrumentId(_) => "SetInstrumentId".to_string(),
Command::SetNotePlacement(_) => "SetNotePlacement".to_string(),
Command::SetExpressionText(_) => "SetExpressionText".to_string(),
Command::SetMeasureText(_) => "SetMeasureText".to_string(),
Command::SetScoreText(_) => "SetScoreText".to_string(),
Command::SetScoreStyleOverrides(_) => "SetScoreStyleOverrides".to_string(),
Command::SetObjectStyleOverrides(_) => "SetObjectStyleOverrides".to_string(),
Command::ToggleTrillLine(_) => "ToggleTrillLine".to_string(),
Command::SetGlissando(_) => "SetGlissando".to_string(),
Command::SetCrossStaff(_) => "SetCrossStaff".to_string(),
Command::SetPartGroup(_) => "SetPartGroup".to_string(),
Command::AddSpanner(_) => "AddSpanner".to_string(),
Command::UpdateSpanner(_) => "UpdateSpanner".to_string(),
Command::RemoveSpanner(_) => "RemoveSpanner".to_string(),
Command::Batch(c) => c.label.clone().unwrap_or_else(|| "Batch".to_string()),
}
}
pub fn apply_command(cmd: &Command, score: &mut Score) -> Result<(), Error> {
match cmd {
Command::AddNote(c) => apply_add_note(c, score),
Command::AddPitch(c) => apply_add_pitch(c, score),
Command::SetDuration(c) => apply_set_duration(c, score),
Command::DeleteNote(c) => apply_delete_note(c, score),
Command::AddMeasure(c) => apply_add_measure(c, score),
Command::DeleteMeasure(c) => apply_delete_measure(c, score),
Command::SetTempo(c) => {
score.settings.tempo_bpm = c.bpm;
Ok(())
}
Command::NewScore(c) => {
let mut s = match c.template {
Some(kind) => Score::template(kind),
None => Score::new(
&c.title,
c.tempo_bpm,
c.time_numerator,
c.time_denominator,
c.key_fifths,
c.measure_count,
),
};
if c.template.is_some() {
s.metadata.title = c.title.clone();
s.metadata.composer = c.composer.clone();
s.settings.tempo_bpm = c.tempo_bpm;
s.settings.time_signature = TimeSignature {
numerator: c.time_numerator,
denominator: c.time_denominator,
};
s.settings.key_signature = KeySignature {
fifths: c.key_fifths,
mode: "major".to_string(),
};
for part in &mut s.parts {
for staff in &mut part.staves {
staff.measures.clear();
for i in 0..c.measure_count {
let mut m = Measure::empty(c.time_numerator, c.time_denominator);
m.number = i + 1;
staff.measures.push(m);
}
}
}
}
*score = s;
Ok(())
}
Command::AddHairpin(c) => apply_add_hairpin(c, score),
Command::ToggleTie(c) => apply_toggle_tie(c, score),
Command::SetDynamic(c) => apply_set_dynamic(c, score),
Command::ToggleArticulation(c) => apply_toggle_articulation(c, score),
Command::SetKeySignature(c) => {
score.settings.key_signature = KeySignature {
fifths: c.fifths,
mode: "major".to_string(),
};
Ok(())
}
Command::SetTimeSignature(c) => apply_set_time_signature(c, score),
Command::SetBarline(c) => apply_set_barline(c, score),
Command::AddPart(c) => apply_add_part(c, score),
Command::DeletePart(c) => apply_delete_part(c, score),
Command::ReorderParts(c) => apply_reorder_parts(c, score),
Command::SetMetadata(c) => apply_set_metadata(c, score),
Command::SetRehearsalMark(c) => {
for_each_measure_at(score, c.measure_index, |m| {
m.rehearsal = c.text.clone();
});
Ok(())
}
Command::SetNavigationMark(c) => {
for_each_measure_at(score, c.measure_index, |m| {
m.navigation = c.mark.clone();
});
Ok(())
}
Command::SetChordSymbol(c) => {
get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?
.chord_symbol = c.chord.clone();
Ok(())
}
Command::SetHarmonyRange(c) => {
if let Some(end) = &c.end
&& !score
.parts
.get(end.part)
.and_then(|part| part.staves.get(end.staff))
.and_then(|staff| staff.measures.get(end.measure))
.and_then(|measure| measure.voices.get(end.voice))
.and_then(|voice| voice.get(end.note))
.is_some()
{
return Err(Error::InvalidCommand(
"harmony range end does not point to an existing note".into(),
));
}
let note = get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?;
let chord = note.chord_symbol.as_mut().ok_or_else(|| {
Error::InvalidCommand("cannot set a harmony range without a chord symbol".into())
})?;
chord.range_end = c.end.clone();
Ok(())
}
Command::SetFiguredBass(c) => {
for part in &mut score.parts {
for staff in &mut part.staves {
if let Some(measure) = staff.measures.get_mut(c.measure_index) {
measure.figured_bass = c.figures.clone();
}
}
}
Ok(())
}
Command::SetHarpPedalDiagrams(c) => {
let measure = score
.parts
.get_mut(c.part_index)
.ok_or(Error::PartNotFound(c.part_index))?
.staves
.get_mut(c.staff_index)
.ok_or(Error::StaffNotFound(c.staff_index))?
.measures
.get_mut(c.measure_index)
.ok_or(Error::MeasureNotFound(c.measure_index))?;
measure.harp_pedal_diagrams = c.diagrams.clone();
Ok(())
}
Command::SetGrace(c) => {
let note = get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?;
if note.is_rest {
return Err(Error::InvalidCommand(
"cannot make a rest into a grace note".into(),
));
}
note.is_grace = c.is_grace;
note.grace_slash = c.slash;
Ok(())
}
Command::SetOttava(c) => {
let note = get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?;
note.ottava_start = c.ottava_start;
note.ottava_end = c.ottava_end;
Ok(())
}
Command::SetLyric(c) => {
let note = get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?;
match c.verse.unwrap_or(1) {
0 => Err(Error::InvalidCommand(
"lyric verse numbers start at 1".into(),
)),
1 => {
note.lyric = c.lyric.clone();
Ok(())
}
verse if verse > VerseLyric::MAX_VERSE => Err(Error::InvalidCommand(format!(
"lyric verse {verse} exceeds {}",
VerseLyric::MAX_VERSE
))),
verse => {
note.additional_lyrics.retain(|entry| entry.verse != verse);
if let Some(lyric) = &c.lyric {
note.additional_lyrics.push(VerseLyric {
verse,
lyric: lyric.clone(),
});
note.additional_lyrics.sort_by_key(|entry| entry.verse);
}
Ok(())
}
}
}
Command::SetMultiRest(c) => {
for_each_measure_at(score, c.measure_index, |m| {
m.multi_rest_count = c.count;
});
Ok(())
}
Command::AddPedal(c) => apply_add_pedal(c, score),
Command::SetVolta(c) => {
for_each_measure_at(score, c.measure_index, |m| {
m.volta = c.volta.clone();
});
Ok(())
}
Command::SetClef(c) => apply_set_clef(c, score),
Command::SetPartName(c) => apply_set_part_name(c, score),
Command::SetMidiInstrument(c) => apply_set_midi_instrument(c, score),
Command::SetPercussionKit(c) => apply_set_percussion_kit(c, score),
Command::SetInstrumentDefinition(c) => apply_set_instrument_definition(c, score),
Command::SetMeasureInstrumentChange(c) => apply_set_measure_instrument_change(c, score),
Command::SetMeasureTablatureChange(c) => apply_set_measure_tablature_change(c, score),
Command::UpsertScoreView(c) => apply_upsert_score_view(c, score),
Command::RemoveScoreView(c) => apply_remove_score_view(c, score),
Command::SetTranspose(c) => apply_set_transpose(c, score),
Command::TransposeStaffRegion(c) => apply_transpose_staff_region(c, score),
Command::SetTempoAtMeasure(c) => {
for_each_measure_at(score, c.measure_index, |m| {
m.tempo = c.bpm;
});
Ok(())
}
Command::SetTempoRampAtMeasure(c) => {
for_each_measure_at(score, c.measure_index, |m| {
m.tempo_ramp_to = c.target_bpm;
});
Ok(())
}
Command::PasteVoice(c) => apply_paste_voice(c, score),
Command::PasteRange(c) => apply_paste_range(c, score),
Command::PasteScoreFragment(c) => apply_paste_score_fragment(c, score),
Command::ExchangeVoices(c) => apply_exchange_voices(c, score),
Command::MoveOrCopyVoiceRange(c) => apply_move_or_copy_voice_range(c, score),
Command::SplitMeasure(c) => apply_split_measure(c, score),
Command::JoinMeasures(c) => apply_join_measures(c, score),
Command::ImplodeStaves(c) => apply_implode_staves(c, score),
Command::ExplodeVoices(c) => apply_explode_voices(c, score),
Command::ExplodeChordPitches(c) => apply_explode_chord_pitches(c, score),
Command::ScaleVoiceRange(c) => apply_scale_voice_range(c, score),
Command::SetSystemBreak(c) => {
for_each_measure_at(score, c.measure_index, |m| {
m.system_break = c.value;
});
Ok(())
}
Command::SetPageBreak(c) => {
for_each_measure_at(score, c.measure_index, |m| {
m.page_break = c.value;
});
Ok(())
}
Command::SetSectionBreak(c) => {
if c.measure_index >= score.measure_count() {
return Err(Error::MeasureNotFound(c.measure_index));
}
for_each_measure_at(score, c.measure_index, |m| {
m.section_break = c.value;
});
Ok(())
}
Command::ToggleSlur(c) => apply_toggle_slur(c, score),
Command::AddStaff(c) => apply_add_staff(c, score),
Command::DeleteStaff(c) => apply_delete_staff(c, score),
Command::SetTuplet(c) => {
get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice_index,
c.note_index,
)?
.tuplet = c.tuplet.clone();
Ok(())
}
Command::RespellScore(c) => {
respell_score(score, c.prefer_flat);
Ok(())
}
Command::RespellScoreToKey(_) => {
respell_score_to_key(score);
Ok(())
}
Command::CycleEnharmonicSpelling(c) => {
let note = get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?;
if note.is_rest || note.is_unpitched || note.pitches.is_empty() {
return Err(Error::InvalidCommand(
"enharmonic spelling requires a pitched note".into(),
));
}
match c.pitch_index {
Some(index) => {
let pitch = note.pitches.get_mut(index).ok_or_else(|| {
Error::InvalidCommand(format!("pitch index {index} out of range"))
})?;
*pitch = pitch.next_enharmonic();
}
None => {
for pitch in &mut note.pitches {
*pitch = pitch.next_enharmonic();
}
}
}
Ok(())
}
Command::ResequenceRehearsalMarks(c) => apply_resequence_rehearsal_marks(c, score),
Command::SetSystemBreakInterval(c) => apply_system_break_interval(c, score),
Command::RemoveTrailingEmptyMeasures(_) => apply_remove_trailing_empty_measures(score),
Command::RespellStaffRegion(c) => {
respell_staff_region(
score,
c.part_index,
c.staff_index,
c.start_measure,
c.end_measure,
c.policy,
)?;
Ok(())
}
Command::SetStem(c) => {
get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice_index,
c.note_index,
)?
.stem_up = c.stem_up;
Ok(())
}
Command::SetArpeggio(c) => {
get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice_index,
c.note_index,
)?
.arpeggiate = c.direction;
Ok(())
}
Command::SetTechniqueText(c) => {
get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?
.technique_text = c.text.clone();
Ok(())
}
Command::SetGlissando(c) => {
let note = get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?;
note.glissando_start = c.start;
note.glissando_end = c.end;
Ok(())
}
Command::SetCrossStaff(c) => {
let staff_count = score
.parts
.get(c.part_index)
.ok_or(Error::PartNotFound(c.part_index))?
.staves
.len();
let note = get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?;
if let Some(ref placement) = c.placement
&& placement.target_staff == c.staff_index
{
return Err(Error::InvalidCommand(
"cross-staff target must differ from source staff".into(),
));
}
if let Some(ref placement) = c.placement
&& placement.target_staff >= staff_count
{
return Err(Error::StaffNotFound(placement.target_staff));
}
note.cross_staff = c.placement.clone();
Ok(())
}
Command::SetFingering(c) => {
let note = get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?;
note.fingering = c.fingering;
note.fingerings = c.fingering.into_iter().collect();
Ok(())
}
Command::SetFingerings(c) => {
let note = get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?;
note.fingerings = c.fingerings.clone();
note.fingering = note.fingerings.first().copied();
Ok(())
}
Command::SetStringNumber(c) => {
get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?
.string_number = c.string_number;
Ok(())
}
Command::SetTabPosition(c) => {
let note = get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?;
note.string_number = c.position.as_ref().map(|position| position.string);
note.tab_position = c.position.clone();
note.tab_positions = c.position.iter().cloned().collect();
Ok(())
}
Command::SetGuitarTechnique(c) => {
get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?
.guitar_technique = c.technique.clone();
Ok(())
}
Command::SetGuitarBendAlter(c) => {
get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?
.guitar_bend_alter_cents = c.alter_cents;
Ok(())
}
Command::SetGuitarBendCurve(c) => {
get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?
.guitar_bend_curve = c.points.clone();
Ok(())
}
Command::SetNoteHead(c) => {
get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?
.note_head = c.note_head.clone();
Ok(())
}
Command::SetCue(c) => {
get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?
.is_cue = c.is_cue;
Ok(())
}
Command::SetUnpitched(c) => {
get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?
.is_unpitched = c.is_unpitched;
Ok(())
}
Command::SetInstrumentId(c) => {
get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?
.instrument_id = c.instrument_id.clone();
Ok(())
}
Command::SetNotePlacement(c) => {
let note = get_note_mut(
score,
c.part_index,
c.staff_index,
c.measure_index,
c.voice,
c.note_index,
)?;
for value in [c.offset_x, c.offset_y, c.relative_x, c.relative_y] {
if value.is_some_and(|value| !value.is_finite()) {
return Err(Error::InvalidCommand(
"note placement offsets must be finite".into(),
));
}
}
note.offset_x = c.offset_x;
note.offset_y = c.offset_y;
note.relative_x = c.relative_x;
note.relative_y = c.relative_y;
Ok(())
}
Command::SetExpressionText(c) => {
for_each_measure_at(score, c.measure_index, |m| {
m.expression_text = c.text.clone();
});
Ok(())
}
Command::SetMeasureText(c) => apply_set_measure_text(c, score),
Command::SetScoreText(c) => apply_set_score_text(c, score),
Command::SetScoreStyleOverrides(c) => apply_set_score_style_overrides(c, score),
Command::SetObjectStyleOverrides(c) => apply_set_object_style_overrides(c, score),
Command::SetTablatureConfig(c) => apply_set_tablature_config(c, score),
Command::SetStaffPresentation(c) => apply_set_staff_presentation(c, score),
Command::ToggleTrillLine(c) => apply_toggle_trill_line(c, score),
Command::SetPartGroup(c) => {
if let Some(group) = &c.group {
score
.part_groups
.retain(|g| g.first_part != group.first_part || g.last_part != group.last_part);
score.part_groups.push(group.clone());
} else {
score.part_groups.clear();
}
Ok(())
}
Command::AddSpanner(c) => {
if score
.spanners
.iter()
.any(|spanner| spanner.id == c.spanner.id)
{
return Err(Error::InvalidCommand(format!(
"notation spanner id already exists: {}",
c.spanner.id
)));
}
score.spanners.push(c.spanner.clone());
Ok(())
}
Command::UpdateSpanner(c) => {
let index = score
.spanners
.iter()
.position(|spanner| spanner.id == c.spanner.id)
.ok_or_else(|| {
Error::InvalidCommand(format!(
"notation spanner id does not exist: {}",
c.spanner.id
))
})?;
let previous = score.spanners[index].clone();
score.spanners[index] = c.spanner.clone();
clear_legacy_spanner_endpoints(score, &previous);
clear_legacy_spanner_endpoints(score, &c.spanner);
Ok(())
}
Command::RemoveSpanner(c) => {
let index = score
.spanners
.iter()
.position(|spanner| spanner.id == c.id)
.ok_or_else(|| {
Error::InvalidCommand(format!("notation spanner id does not exist: {}", c.id))
})?;
let removed = score.spanners.remove(index);
clear_legacy_spanner_endpoints(score, &removed);
Ok(())
}
Command::Batch(c) => {
for cmd in &c.commands {
apply_command(cmd, score)?;
}
Ok(())
}
}
}
fn apply_set_tablature_config(cmd: &SetTablatureConfigCmd, score: &mut Score) -> Result<(), Error> {
let staff = score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?
.staves
.get_mut(cmd.staff_index)
.ok_or_else(|| Error::InvalidCommand(format!("staff {} out of range", cmd.staff_index)))?;
staff.tablature = cmd.config.clone();
if staff.tablature.is_some() {
staff.presentation.kind = StaffKind::Tablature;
} else if staff.presentation.kind == StaffKind::Tablature {
staff.presentation.kind = StaffKind::Standard;
}
if staff.tablature.is_none() {
for measure in &mut staff.measures {
measure.tablature_change = None;
}
}
Ok(())
}
fn apply_set_staff_presentation(
cmd: &SetStaffPresentationCmd,
score: &mut Score,
) -> Result<(), Error> {
if !(1..=64).contains(&cmd.presentation.lines) {
return Err(Error::InvalidCommand(format!(
"staff line count {} is outside 1..=64",
cmd.presentation.lines
)));
}
if !cmd.presentation.line_distance.is_finite()
|| !(0.1..=16.0).contains(&cmd.presentation.line_distance)
{
return Err(Error::InvalidCommand(
"staff line distance must be finite and within 0.1..=16.0".into(),
));
}
let staff = score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?
.staves
.get_mut(cmd.staff_index)
.ok_or_else(|| Error::InvalidCommand(format!("staff {} out of range", cmd.staff_index)))?;
if cmd.presentation.kind == StaffKind::Tablature && staff.tablature.is_none() {
return Err(Error::InvalidCommand(
"tablature staff presentation requires a tablature configuration".into(),
));
}
staff.presentation = cmd.presentation.clone();
Ok(())
}
fn get_note_mut(
score: &mut Score,
part_index: usize,
staff_index: usize,
measure_index: usize,
voice: usize,
note_index: usize,
) -> Result<&mut Note, Error> {
score
.parts
.get_mut(part_index)
.ok_or(Error::PartNotFound(part_index))?
.staves
.get_mut(staff_index)
.ok_or(Error::StaffNotFound(staff_index))?
.measures
.get_mut(measure_index)
.ok_or(Error::MeasureNotFound(measure_index))?
.voices
.get_mut(voice)
.ok_or(Error::VoiceOutOfRange(voice))?
.get_mut(note_index)
.ok_or(Error::NoteNotFound(note_index))
}
fn for_each_measure_at(score: &mut Score, index: usize, mut f: impl FnMut(&mut Measure)) {
for part in &mut score.parts {
for staff in &mut part.staves {
if let Some(m) = staff.measures.get_mut(index) {
f(m);
}
}
}
}
fn apply_set_measure_text(cmd: &SetMeasureTextCmd, score: &mut Score) -> Result<(), Error> {
let measure = score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?
.staves
.get_mut(cmd.staff_index)
.ok_or(Error::StaffNotFound(cmd.staff_index))?
.measures
.get_mut(cmd.measure_index)
.ok_or(Error::MeasureNotFound(cmd.measure_index))?;
if cmd.text_index > measure.texts.len()
|| (cmd.text.is_none() && cmd.text_index == measure.texts.len())
{
return Err(Error::InvalidCommand(format!(
"styled text index {} out of range for {} entries",
cmd.text_index,
measure.texts.len()
)));
}
if let Some(text) = &cmd.text {
if cmd.text_index == measure.texts.len() {
measure.texts.push(text.clone());
} else {
measure.texts[cmd.text_index] = text.clone();
}
} else {
measure.texts.remove(cmd.text_index);
}
Ok(())
}
fn apply_set_score_text(cmd: &SetScoreTextCmd, score: &mut Score) -> Result<(), Error> {
if cmd.text_index > score.texts.len()
|| (cmd.text.is_none() && cmd.text_index == score.texts.len())
{
return Err(Error::InvalidCommand(format!(
"styled score text index {} out of range for {} entries",
cmd.text_index,
score.texts.len()
)));
}
if let Some(text) = &cmd.text {
if cmd.text_index == score.texts.len() {
score.texts.push(text.clone());
} else {
score.texts[cmd.text_index] = text.clone();
}
} else {
score.texts.remove(cmd.text_index);
}
Ok(())
}
fn apply_set_score_style_overrides(
cmd: &SetScoreStyleOverridesCmd,
score: &mut Score,
) -> Result<(), Error> {
if cmd
.overrides
.iter()
.any(|override_| !override_.value.is_finite() || !(0.05..=64.0).contains(&override_.value))
{
return Err(Error::InvalidCommand(
"score typed style override values must be finite and within 0.05..=64".into(),
));
}
score.style_overrides = cmd.overrides.clone();
Ok(())
}
fn apply_set_object_style_overrides(
cmd: &SetObjectStyleOverridesCmd,
score: &mut Score,
) -> Result<(), Error> {
let mut candidate = score.clone();
candidate.object_style_overrides = cmd.overrides.clone();
if validate(&candidate).errors.iter().any(|error| {
matches!(
error,
super::validate::ValidationError::InvalidObjectStyleOverride { .. }
)
}) {
return Err(Error::InvalidCommand(
"object style overrides require existing targets, finite values within 0.05..=64, and bounded provenance".into(),
));
}
score.object_style_overrides = cmd.overrides.clone();
Ok(())
}
fn editing_measure_beats(score: &Score, part: usize, staff: usize, measure: usize) -> f64 {
score
.parts
.get(part)
.and_then(|part| part.staves.get(staff))
.and_then(|staff| staff.measures.get(measure))
.and_then(|measure| measure.actual_length)
.and_then(|length| length.beats())
.unwrap_or_else(|| score.settings.time_signature.total_beats())
}
fn apply_add_note(cmd: &AddNoteCmd, score: &mut Score) -> Result<(), Error> {
let ts_beats = editing_measure_beats(score, cmd.part_index, cmd.staff_index, cmd.measure_index);
let note = if cmd.is_rest {
let mut n = Note::rest(cmd.duration.clone());
n.dot_count = cmd.dot_count;
n.tuplet = cmd.tuplet.clone();
n
} else {
let pitch = cmd
.pitch
.clone()
.ok_or_else(|| Error::InvalidCommand("pitch required for non-rest note".into()))?;
let mut n = Note::new(pitch, cmd.duration.clone());
n.dot_count = cmd.dot_count;
n.tuplet = cmd.tuplet.clone();
n
};
let pos = {
let voice = score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?
.staves
.get_mut(cmd.staff_index)
.ok_or(Error::StaffNotFound(cmd.staff_index))?
.measures
.get_mut(cmd.measure_index)
.ok_or(Error::MeasureNotFound(cmd.measure_index))?
.voices
.get_mut(cmd.voice)
.ok_or(Error::VoiceOutOfRange(cmd.voice))?;
let pos = cmd.position.min(voice.len());
voice.insert(pos, note);
trim_voice_to_measure(voice, ts_beats);
pos
};
remap_spanners(score, |address| {
if same_voice(
address,
cmd.part_index,
cmd.staff_index,
cmd.measure_index,
cmd.voice,
) && address.note >= pos
{
let mut shifted = address.clone();
shifted.note += 1;
Some(shifted)
} else {
Some(address.clone())
}
});
Ok(())
}
fn apply_add_pitch(cmd: &AddPitchCmd, score: &mut Score) -> Result<(), Error> {
let voice = score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?
.staves
.get_mut(cmd.staff_index)
.ok_or(Error::StaffNotFound(cmd.staff_index))?
.measures
.get_mut(cmd.measure_index)
.ok_or(Error::MeasureNotFound(cmd.measure_index))?
.voices
.get_mut(cmd.voice)
.ok_or(Error::VoiceOutOfRange(cmd.voice))?;
let note = voice
.get_mut(cmd.note_index)
.ok_or(Error::NoteNotFound(cmd.note_index))?;
if note.is_rest {
return Err(Error::InvalidCommand("cannot add pitch to a rest".into()));
}
if !note
.pitches
.iter()
.any(|p| p.step == cmd.pitch.step && p.octave == cmd.pitch.octave)
{
note.pitches.push(cmd.pitch.clone());
}
Ok(())
}
fn apply_set_duration(cmd: &SetDurationCmd, score: &mut Score) -> Result<(), Error> {
let ts_beats = editing_measure_beats(score, cmd.part_index, cmd.staff_index, cmd.measure_index);
{
let voice = score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?
.staves
.get_mut(cmd.staff_index)
.ok_or(Error::StaffNotFound(cmd.staff_index))?
.measures
.get_mut(cmd.measure_index)
.ok_or(Error::MeasureNotFound(cmd.measure_index))?
.voices
.get_mut(cmd.voice)
.ok_or(Error::VoiceOutOfRange(cmd.voice))?;
let note = voice
.get_mut(cmd.note_index)
.ok_or(Error::NoteNotFound(cmd.note_index))?;
note.duration = cmd.duration.clone();
note.dot_count = cmd.dot_count;
trim_voice_to_measure(voice, ts_beats);
}
prune_orphaned_spanners(score);
Ok(())
}
fn apply_delete_note(cmd: &DeleteNoteCmd, score: &mut Score) -> Result<(), Error> {
let ts_beats = editing_measure_beats(score, cmd.part_index, cmd.staff_index, cmd.measure_index);
let deleted_position = {
let voice = score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?
.staves
.get_mut(cmd.staff_index)
.ok_or(Error::StaffNotFound(cmd.staff_index))?
.measures
.get_mut(cmd.measure_index)
.ok_or(Error::MeasureNotFound(cmd.measure_index))?
.voices
.get_mut(cmd.voice)
.ok_or(Error::VoiceOutOfRange(cmd.voice))?;
let position = voice.iter().position(|note| note.id == cmd.note_id);
voice.retain(|note| note.id != cmd.note_id);
pad_voice_to_measure(voice, ts_beats);
position
};
if let Some(position) = deleted_position {
remap_spanners(score, |address| {
if !same_voice(
address,
cmd.part_index,
cmd.staff_index,
cmd.measure_index,
cmd.voice,
) {
return Some(address.clone());
}
if address.note == position {
None
} else if address.note > position {
let mut shifted = address.clone();
shifted.note -= 1;
Some(shifted)
} else {
Some(address.clone())
}
});
} else {
prune_orphaned_spanners(score);
}
Ok(())
}
fn apply_add_measure(cmd: &AddMeasureCmd, score: &mut Score) -> Result<(), Error> {
let ts = score.settings.time_signature.clone();
let insert_at = cmd.after_index.saturating_add(1);
for part in &mut score.parts {
for staff in &mut part.staves {
let insert_at = insert_at.min(staff.measures.len());
let mut m = Measure::empty(ts.numerator, ts.denominator);
m.number = insert_at as u32 + 1;
staff.measures.insert(insert_at, m);
for (i, measure) in staff.measures.iter_mut().enumerate() {
measure.number = i as u32 + 1;
}
}
}
remap_spanners(score, |address| {
if address.measure >= insert_at {
let mut shifted = address.clone();
shifted.measure += 1;
Some(shifted)
} else {
Some(address.clone())
}
});
Ok(())
}
fn score_measure_range(
score: &Score,
start: Option<usize>,
end: Option<usize>,
) -> Result<(usize, usize), Error> {
let count = score
.parts
.first()
.and_then(|part| part.staves.first())
.map_or(0, |staff| staff.measures.len());
let start_measure = start.unwrap_or(0);
let end_measure = end.unwrap_or(count);
if start_measure >= end_measure || end_measure > count {
return Err(Error::InvalidCommand(format!(
"invalid measure range {start_measure}..{end_measure}"
)));
}
Ok((start_measure, end_measure))
}
enum RehearsalSequence {
MeasureNumber,
Number(u64),
Letters { first: u64, lower: bool },
}
impl RehearsalSequence {
fn detect(text: &str, measure_number: u32) -> Option<Self> {
if !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit()) {
let value = text.parse::<u64>().ok()?;
return Some(if value == u64::from(measure_number) {
Self::MeasureNumber
} else {
Self::Number(value)
});
}
let lower = text.bytes().all(|byte| byte.is_ascii_lowercase());
let upper = text.bytes().all(|byte| byte.is_ascii_uppercase());
if text.is_empty() || text.len() > 6 || !(lower || upper) {
return None;
}
let first = text.bytes().fold(0u64, |value, byte| {
value * 26 + u64::from(byte.to_ascii_uppercase() - b'A') + 1
}) - 1;
Some(Self::Letters { first, lower })
}
fn nth(&self, offset: u64, measure_number: u32) -> String {
match self {
Self::MeasureNumber => measure_number.to_string(),
Self::Number(first) => (first + offset).to_string(),
Self::Letters { first, lower } => {
let mut value = first + offset + 1;
let mut letters = Vec::new();
while value > 0 {
value -= 1;
letters.push(b'A' + (value % 26) as u8);
value /= 26;
}
letters.reverse();
let text = String::from_utf8(letters).unwrap_or_default();
if *lower {
text.to_ascii_lowercase()
} else {
text
}
}
}
}
}
fn apply_resequence_rehearsal_marks(
cmd: &ResequenceRehearsalMarksCmd,
score: &mut Score,
) -> Result<(), Error> {
let (start, end) = score_measure_range(score, cmd.start_measure, cmd.end_measure)?;
let reference = &score.parts[0].staves[0].measures;
let marks: Vec<(usize, u32)> = (start..end)
.filter(|&index| {
reference[index]
.rehearsal
.as_deref()
.is_some_and(|text| !text.trim().is_empty())
})
.map(|index| (index, reference[index].number))
.collect();
let Some(&(first_index, first_number)) = marks.first() else {
return Ok(());
};
let first_text = reference[first_index]
.rehearsal
.as_deref()
.unwrap_or_default()
.trim()
.to_string();
let sequence = RehearsalSequence::detect(&first_text, first_number).ok_or_else(|| {
Error::InvalidCommand(format!(
"rehearsal mark '{first_text}' does not start a letter or number sequence"
))
})?;
for (offset, (index, number)) in marks.into_iter().enumerate() {
let text = sequence.nth(offset as u64, number);
for_each_measure_at(score, index, |measure| {
measure.rehearsal = Some(text.clone());
});
}
Ok(())
}
fn apply_system_break_interval(
cmd: &SetSystemBreakIntervalCmd,
score: &mut Score,
) -> Result<(), Error> {
let (start, end) = score_measure_range(score, cmd.start_measure, cmd.end_measure)?;
let last_measure = score.parts[0].staves[0].measures.len() - 1;
let interval = usize::try_from(cmd.interval).unwrap_or(usize::MAX);
for index in start..end {
let value = interval > 0 && index < last_measure && (index - start + 1) % interval == 0;
for_each_measure_at(score, index, |measure| measure.system_break = value);
}
Ok(())
}
fn is_empty_trailing_measure(measure: &Measure) -> bool {
let strip = |value: serde_json::Value| -> serde_json::Value {
let mut value = value;
if let Some(object) = value.as_object_mut() {
for field in ["number", "voices", "source_voice_numbers", "barline_right"] {
object.remove(field);
}
}
value
};
let (Ok(actual), Ok(blank)) = (
serde_json::to_value(measure),
serde_json::to_value(Measure::empty(4, 4)),
) else {
return false;
};
if strip(actual) != strip(blank) {
return false;
}
measure.voices.iter().flatten().all(|note| {
if !note.is_rest {
return false;
}
let mut plain = Note::rest(note.duration.clone());
plain.dot_count = note.dot_count;
plain.tuplet = note.tuplet.clone();
plain.id = note.id.clone();
matches!(
(serde_json::to_value(note), serde_json::to_value(&plain)),
(Ok(actual), Ok(plain)) if actual == plain
)
})
}
fn apply_remove_trailing_empty_measures(score: &mut Score) -> Result<(), Error> {
let count = score
.parts
.first()
.and_then(|part| part.staves.first())
.map_or(0, |staff| staff.measures.len());
let mut keep = count;
while keep > 1
&& score.parts.iter().all(|part| {
part.staves.iter().all(|staff| {
staff
.measures
.get(keep - 1)
.is_some_and(is_empty_trailing_measure)
})
})
{
keep -= 1;
}
if keep == count {
return Ok(());
}
let final_barline = score.parts.iter().any(|part| {
part.staves.iter().any(|staff| {
staff
.measures
.last()
.is_some_and(|measure| matches!(measure.barline_right, Barline::Final))
})
});
for index in (keep..count).rev() {
apply_delete_measure(
&DeleteMeasureCmd {
measure_index: index,
},
score,
)?;
}
if final_barline {
for_each_measure_at(score, keep - 1, |measure| {
if matches!(measure.barline_right, Barline::Normal) {
measure.barline_right = Barline::Final;
}
});
}
Ok(())
}
fn apply_delete_measure(cmd: &DeleteMeasureCmd, score: &mut Score) -> Result<(), Error> {
for part in &mut score.parts {
for staff in &mut part.staves {
if cmd.measure_index < staff.measures.len() {
staff.measures.remove(cmd.measure_index);
for (i, m) in staff.measures.iter_mut().enumerate() {
m.number = i as u32 + 1;
}
}
}
}
remap_spanners(score, |address| {
if address.measure == cmd.measure_index {
None
} else if address.measure > cmd.measure_index {
let mut shifted = address.clone();
shifted.measure -= 1;
Some(shifted)
} else {
Some(address.clone())
}
});
Ok(())
}
fn apply_add_hairpin(cmd: &AddHairpinCmd, score: &mut Score) -> Result<(), Error> {
let voice = score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?
.staves
.get_mut(cmd.staff_index)
.ok_or(Error::StaffNotFound(cmd.staff_index))?
.measures
.get_mut(cmd.measure_index)
.ok_or(Error::MeasureNotFound(cmd.measure_index))?
.voices
.get_mut(cmd.voice)
.ok_or(Error::VoiceOutOfRange(cmd.voice))?;
if cmd.start_note_idx >= voice.len() {
return Err(Error::NoteNotFound(cmd.start_note_idx));
}
if cmd.end_note_idx >= voice.len() {
return Err(Error::NoteNotFound(cmd.end_note_idx));
}
if cmd.start_note_idx >= cmd.end_note_idx {
return Err(Error::InvalidCommand(
"start_note_idx must be less than end_note_idx".into(),
));
}
for note in voice
.iter_mut()
.take(cmd.end_note_idx + 1)
.skip(cmd.start_note_idx)
{
note.hairpin_start = None;
note.hairpin_end = false;
}
voice[cmd.start_note_idx].hairpin_start = Some(cmd.kind);
voice[cmd.end_note_idx].hairpin_end = true;
Ok(())
}
fn apply_add_pedal(cmd: &AddPedalCmd, score: &mut Score) -> Result<(), Error> {
let voice = score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?
.staves
.get_mut(cmd.staff_index)
.ok_or(Error::StaffNotFound(cmd.staff_index))?
.measures
.get_mut(cmd.measure_index)
.ok_or(Error::MeasureNotFound(cmd.measure_index))?
.voices
.get_mut(cmd.voice)
.ok_or(Error::VoiceOutOfRange(cmd.voice))?;
if cmd.start_note_idx >= voice.len() {
return Err(Error::NoteNotFound(cmd.start_note_idx));
}
if cmd.end_note_idx >= voice.len() {
return Err(Error::NoteNotFound(cmd.end_note_idx));
}
if cmd.start_note_idx >= cmd.end_note_idx {
return Err(Error::InvalidCommand(
"start_note_idx must be less than end_note_idx".into(),
));
}
for note in voice
.iter_mut()
.take(cmd.end_note_idx + 1)
.skip(cmd.start_note_idx)
{
note.pedal_start = false;
note.pedal_end = false;
}
voice[cmd.start_note_idx].pedal_start = true;
voice[cmd.end_note_idx].pedal_end = true;
Ok(())
}
fn apply_toggle_tie(cmd: &ToggleTieCmd, score: &mut Score) -> Result<(), Error> {
let current_tie_start = {
let v = score
.parts
.get(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?
.staves
.get(cmd.staff_index)
.ok_or(Error::StaffNotFound(cmd.staff_index))?
.measures
.get(cmd.measure_index)
.ok_or(Error::MeasureNotFound(cmd.measure_index))?
.voices
.get(cmd.voice)
.ok_or(Error::VoiceOutOfRange(cmd.voice))?;
v.get(cmd.note_index)
.ok_or(Error::NoteNotFound(cmd.note_index))?
.tie_start
};
let voice_len = score.parts[cmd.part_index].staves[cmd.staff_index].measures[cmd.measure_index]
.voices[cmd.voice]
.len();
let total_measures = score.parts[cmd.part_index].staves[cmd.staff_index]
.measures
.len();
let new_tie = !current_tie_start;
score.parts[cmd.part_index].staves[cmd.staff_index].measures[cmd.measure_index].voices
[cmd.voice][cmd.note_index]
.tie_start = new_tie;
if cmd.note_index + 1 < voice_len {
score.parts[cmd.part_index].staves[cmd.staff_index].measures[cmd.measure_index].voices
[cmd.voice][cmd.note_index + 1]
.tie_end = new_tie;
} else {
let next_mi = cmd.measure_index + 1;
if next_mi < total_measures {
let next_voice = &mut score.parts[cmd.part_index].staves[cmd.staff_index].measures
[next_mi]
.voices[cmd.voice];
if let Some(n) = next_voice.get_mut(0) {
n.tie_end = new_tie;
}
}
}
Ok(())
}
fn apply_set_dynamic(cmd: &SetDynamicCmd, score: &mut Score) -> Result<(), Error> {
get_note_mut(
score,
cmd.part_index,
cmd.staff_index,
cmd.measure_index,
cmd.voice,
cmd.note_index,
)?
.dynamic = cmd.dynamic.clone();
Ok(())
}
fn apply_toggle_articulation(cmd: &ToggleArticulationCmd, score: &mut Score) -> Result<(), Error> {
let note = get_note_mut(
score,
cmd.part_index,
cmd.staff_index,
cmd.measure_index,
cmd.voice,
cmd.note_index,
)?;
if let Some(pos) = note
.articulations
.iter()
.position(|a| a == &cmd.articulation)
{
note.articulations.remove(pos);
} else {
note.articulations.push(cmd.articulation.clone());
}
Ok(())
}
fn apply_set_time_signature(cmd: &SetTimeSignatureCmd, score: &mut Score) -> Result<(), Error> {
if cmd.numerator == 0 || cmd.denominator == 0 {
return Err(Error::InvalidCommand(
"time signature numerator and denominator must be > 0".into(),
));
}
if ![1u8, 2, 4, 8, 16, 32].contains(&cmd.denominator) {
return Err(Error::InvalidCommand(format!(
"invalid time signature denominator: {}",
cmd.denominator
)));
}
score.settings.time_signature = TimeSignature {
numerator: cmd.numerator,
denominator: cmd.denominator,
};
let max_beats = score.settings.time_signature.total_beats();
for part in &mut score.parts {
for staff in &mut part.staves {
for measure in &mut staff.measures {
let beats = measure
.actual_length
.and_then(|length| length.beats())
.unwrap_or(max_beats);
for voice in &mut measure.voices {
trim_voice_to_measure(voice, beats);
pad_voice_to_measure(voice, beats);
}
}
}
}
Ok(())
}
fn apply_set_barline(cmd: &SetBarlineCmd, score: &mut Score) -> Result<(), Error> {
for part in &mut score.parts {
for staff in &mut part.staves {
let measure = staff
.measures
.get_mut(cmd.measure_index)
.ok_or(Error::MeasureNotFound(cmd.measure_index))?;
match cmd.side.as_str() {
"left" => measure.barline_left = cmd.barline.clone(),
"right" => measure.barline_right = cmd.barline.clone(),
_ => {
return Err(Error::InvalidCommand(format!(
"invalid barline side: '{}'",
cmd.side
)));
}
}
}
}
Ok(())
}
fn apply_add_part(cmd: &AddPartCmd, score: &mut Score) -> Result<(), Error> {
if cmd.clefs.is_empty() {
return Err(Error::InvalidCommand(
"AddPart requires at least one clef".into(),
));
}
let measure_count = score.measure_count();
let ts = score.settings.time_signature.clone();
let mut part = Part::new(&cmd.name, &cmd.short_name);
part.midi_channel = cmd.midi_channel.min(15);
part.midi_program = cmd.midi_program;
for clef_str in &cmd.clefs {
let clef = match clef_str.as_str() {
"Bass" => Clef::Bass,
"Alto" => Clef::Alto,
"Tenor" => Clef::Tenor,
"Percussion" => Clef::Percussion,
_ => Clef::Treble,
};
let mut staff = Staff::new(clef);
for i in 0..measure_count {
let mut m = Measure::empty(ts.numerator, ts.denominator);
m.number = i as u32 + 1;
staff.measures.push(m);
}
part.staves.push(staff);
}
score.parts.push(part);
Ok(())
}
fn apply_set_clef(cmd: &SetClefCmd, score: &mut Score) -> Result<(), Error> {
score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?
.staves
.get_mut(cmd.staff_index)
.ok_or(Error::StaffNotFound(cmd.staff_index))?
.clef = cmd.clef.clone();
Ok(())
}
fn apply_set_part_name(cmd: &SetPartNameCmd, score: &mut Score) -> Result<(), Error> {
let part = score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?;
part.name = cmd.name.clone();
part.short_name = cmd.short_name.clone();
Ok(())
}
fn apply_delete_part(cmd: &DeletePartCmd, score: &mut Score) -> Result<(), Error> {
if cmd.part_index >= score.parts.len() {
return Err(Error::PartNotFound(cmd.part_index));
}
score.parts.remove(cmd.part_index);
remap_spanners(score, |address| {
if address.part == cmd.part_index {
None
} else if address.part > cmd.part_index {
let mut shifted = address.clone();
shifted.part -= 1;
Some(shifted)
} else {
Some(address.clone())
}
});
Ok(())
}
fn apply_reorder_parts(cmd: &ReorderPartsCmd, score: &mut Score) -> Result<(), Error> {
let count = score.parts.len();
if cmd.order.len() != count {
return Err(Error::InvalidCommand(format!(
"part order must contain exactly {count} entries"
)));
}
let mut old_to_new = vec![usize::MAX; count];
for (new_index, &old_index) in cmd.order.iter().enumerate() {
if old_index >= count {
return Err(Error::PartNotFound(old_index));
}
if std::mem::replace(&mut old_to_new[old_index], new_index) != usize::MAX {
return Err(Error::InvalidCommand(format!(
"part order contains duplicate index {old_index}"
)));
}
}
for view in &score.views {
for &part in &view.parts {
if part >= count {
return Err(Error::PartNotFound(part));
}
}
for override_ in &view.staff_kind_overrides {
if override_.staff.part >= count {
return Err(Error::PartNotFound(override_.staff.part));
}
}
}
for spanner in &score.spanners {
for address in [&spanner.start, &spanner.end] {
if address.part >= count {
return Err(Error::PartNotFound(address.part));
}
}
}
for group in &score.part_groups {
if group.first_part > group.last_part || group.last_part >= count {
return Err(Error::InvalidCommand(
"part group references an invalid part range".into(),
));
}
let mut remapped: Vec<_> = (group.first_part..=group.last_part)
.map(|part| old_to_new[part])
.collect();
remapped.sort_unstable();
if remapped
.windows(2)
.any(|pair| pair[1] != pair[0].saturating_add(1))
{
return Err(Error::InvalidCommand(
"part order would split an existing part group".into(),
));
}
}
let existing = std::mem::take(&mut score.parts);
score.parts = cmd
.order
.iter()
.map(|&old_index| existing[old_index].clone())
.collect();
for view in &mut score.views {
for part in &mut view.parts {
*part = old_to_new[*part];
}
for override_ in &mut view.staff_kind_overrides {
override_.staff.part = old_to_new[override_.staff.part];
}
}
for group in &mut score.part_groups {
group.first_part = old_to_new[group.first_part];
group.last_part = old_to_new[group.last_part];
if group.first_part > group.last_part {
std::mem::swap(&mut group.first_part, &mut group.last_part);
}
}
remap_spanners(score, |address| {
let mut remapped = address.clone();
remapped.part = old_to_new[address.part];
Some(remapped)
});
Ok(())
}
fn apply_set_metadata(cmd: &SetMetadataCmd, score: &mut Score) -> Result<(), Error> {
if let Some(v) = &cmd.title {
score.metadata.title = v.clone();
}
if let Some(v) = &cmd.composer {
score.metadata.composer = v.clone();
}
if let Some(v) = &cmd.lyricist {
score.metadata.lyricist = v.clone();
}
if let Some(v) = &cmd.copyright {
score.metadata.copyright = v.clone();
}
if let Some(v) = &cmd.work_number {
score.metadata.work_number = v.clone();
}
if let Some(v) = &cmd.movement_title {
score.metadata.movement_title = v.clone();
}
Ok(())
}
fn apply_set_midi_instrument(cmd: &SetMidiInstrumentCmd, score: &mut Score) -> Result<(), Error> {
let part = score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?;
part.midi_channel = cmd.midi_channel.min(15);
part.midi_program = cmd.midi_program;
Ok(())
}
fn apply_set_percussion_kit(cmd: &SetPercussionKitCmd, score: &mut Score) -> Result<(), Error> {
validate_percussion_kit(&cmd.instruments)?;
let part = score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?;
part.percussion_instruments = cmd.instruments.clone();
Ok(())
}
fn apply_set_instrument_definition(
cmd: &SetInstrumentDefinitionCmd,
score: &mut Score,
) -> Result<(), Error> {
if let Some(definition) = &cmd.definition {
validate_instrument_definition(definition)?;
}
let part = score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?;
part.instrument = cmd.definition.clone();
Ok(())
}
fn apply_set_measure_instrument_change(
cmd: &SetMeasureInstrumentChangeCmd,
score: &mut Score,
) -> Result<(), Error> {
if let Some(definition) = &cmd.definition {
validate_instrument_definition(definition)?;
}
score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?
.staves
.get_mut(cmd.staff_index)
.ok_or(Error::StaffNotFound(cmd.staff_index))?
.measures
.get_mut(cmd.measure_index)
.ok_or(Error::MeasureNotFound(cmd.measure_index))?
.instrument_change = cmd.definition.clone();
Ok(())
}
fn apply_set_measure_tablature_change(
cmd: &SetMeasureTablatureChangeCmd,
score: &mut Score,
) -> Result<(), Error> {
let staff = score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?
.staves
.get_mut(cmd.staff_index)
.ok_or(Error::StaffNotFound(cmd.staff_index))?;
let base = staff.tablature.as_ref().ok_or_else(|| {
Error::InvalidCommand("tablature change requires a staff tablature configuration".into())
})?;
if let Some(config) = &cmd.config {
validate_tablature_config(config)?;
if config.lines != base.lines {
return Err(Error::InvalidCommand(format!(
"tablature change line count {} must match staff line count {}",
config.lines, base.lines
)));
}
}
staff
.measures
.get_mut(cmd.measure_index)
.ok_or(Error::MeasureNotFound(cmd.measure_index))?
.tablature_change = cmd.config.clone();
Ok(())
}
fn validate_tablature_config(config: &TablatureConfig) -> Result<(), Error> {
if !(1..=64).contains(&config.lines) {
return Err(Error::InvalidCommand(format!(
"tablature line count {} is outside 1..=64",
config.lines
)));
}
if config.tuning_midi.len() > usize::from(config.lines) {
return Err(Error::InvalidCommand(format!(
"tablature has {} tunings for {} lines",
config.tuning_midi.len(),
config.lines
)));
}
if let Some(midi) = config
.tuning_midi
.iter()
.copied()
.find(|midi| !(0..=127).contains(midi))
{
return Err(Error::InvalidCommand(format!(
"tablature tuning MIDI {midi} is outside 0..=127"
)));
}
Ok(())
}
fn validate_percussion_kit(instruments: &[PercussionInstrument]) -> Result<(), Error> {
let mut ids = std::collections::HashSet::new();
for instrument in instruments {
if instrument.id.trim().is_empty() {
return Err(Error::InvalidCommand(
"percussion instrument id must not be empty".into(),
));
}
if !ids.insert(instrument.id.as_str()) {
return Err(Error::InvalidCommand(format!(
"percussion instrument id '{}' is duplicated",
instrument.id
)));
}
if let Some(position) = instrument.staff_position
&& !(-32..=32).contains(&position)
{
return Err(Error::InvalidCommand(format!(
"percussion staff position {position} is outside -32..=32"
)));
}
if let Some(voice) = instrument.preferred_voice
&& !(1..=4).contains(&voice)
{
return Err(Error::InvalidCommand(format!(
"percussion preferred voice {voice} is outside 1..=4"
)));
}
if instrument
.techniques
.iter()
.any(|technique| technique.trim().is_empty() || technique.len() > 128)
{
return Err(Error::InvalidCommand(
"percussion techniques must be non-empty and at most 128 bytes".into(),
));
}
}
Ok(())
}
fn validate_instrument_definition(definition: &InstrumentDefinition) -> Result<(), Error> {
if definition.id.trim().is_empty() {
return Err(Error::InvalidCommand(
"instrument definition id must not be empty".into(),
));
}
if !(1..=64).contains(&definition.staff_count) {
return Err(Error::InvalidCommand(format!(
"instrument staff count {} is outside 1..=64",
definition.staff_count
)));
}
if definition.midi_channel > 15 {
return Err(Error::InvalidCommand(format!(
"instrument MIDI channel {} is outside 0..=15",
definition.midi_channel
)));
}
for (name, range) in [
("written", definition.written_range),
("sounding", definition.sounding_range),
] {
if let Some(InstrumentRange { lowest, highest }) = range
&& lowest > highest
{
return Err(Error::InvalidCommand(format!(
"instrument {name} range has lowest MIDI note {lowest} above highest {highest}"
)));
}
}
Ok(())
}
fn apply_upsert_score_view(cmd: &UpsertScoreViewCmd, score: &mut Score) -> Result<(), Error> {
validate_score_view(&cmd.view, score)?;
if let Some(index) = score.views.iter().position(|view| view.id == cmd.view.id) {
score.views[index] = cmd.view.clone();
} else {
score.views.push(cmd.view.clone());
}
Ok(())
}
fn apply_remove_score_view(cmd: &RemoveScoreViewCmd, score: &mut Score) -> Result<(), Error> {
let index = score
.views
.iter()
.position(|view| view.id == cmd.id)
.ok_or_else(|| Error::InvalidCommand(format!("score view '{}' does not exist", cmd.id)))?;
score.views.remove(index);
Ok(())
}
fn validate_score_view(view: &ScoreView, score: &Score) -> Result<(), Error> {
if view.id.trim().is_empty() || view.name.trim().is_empty() {
return Err(Error::InvalidCommand(
"score view id and name must not be empty".into(),
));
}
if view.parts.is_empty() {
return Err(Error::InvalidCommand(
"score view must select at least one part".into(),
));
}
let mut selected = vec![false; score.parts.len()];
for &part in &view.parts {
if part >= score.parts.len() {
return Err(Error::PartNotFound(part));
}
if std::mem::replace(&mut selected[part], true) {
return Err(Error::InvalidCommand(format!(
"score view '{}' selects part {part} more than once",
view.id
)));
}
}
if view.layout.measures_per_row.is_some_and(|value| value == 0) {
return Err(Error::InvalidCommand(
"score view measures per row must be greater than zero".into(),
));
}
if view
.layout
.typed_style_overrides
.iter()
.any(|override_| !override_.value.is_finite() || !(0.05..=64.0).contains(&override_.value))
{
return Err(Error::InvalidCommand(
"score view typed style override values must be finite and within 0.05..=64".into(),
));
}
for reference in &view.layout.hidden_staves {
let Some(part) = score.parts.get(reference.part) else {
return Err(Error::PartNotFound(reference.part));
};
if reference.staff >= part.staves.len() {
return Err(Error::StaffNotFound(reference.staff));
}
if !selected[reference.part] {
return Err(Error::InvalidCommand(
"score view cannot hide a staff outside its selected parts".into(),
));
}
}
let mut overridden = std::collections::HashSet::new();
for override_ in &view.staff_kind_overrides {
let reference = override_.staff;
let Some(part) = score.parts.get(reference.part) else {
return Err(Error::PartNotFound(reference.part));
};
if reference.staff >= part.staves.len() {
return Err(Error::StaffNotFound(reference.staff));
}
if !selected[reference.part] {
return Err(Error::InvalidCommand(
"score view cannot override a staff outside its selected parts".into(),
));
}
if !overridden.insert((reference.part, reference.staff)) {
return Err(Error::InvalidCommand(
"score view may override each staff kind at most once".into(),
));
}
if override_.kind == StaffKind::Tablature
&& part.staves[reference.staff].tablature.is_none()
{
return Err(Error::InvalidCommand(
"tablature score view requires a tablature configuration".into(),
));
}
}
let measure_count = score.measure_count();
for &break_index in view
.layout
.system_breaks
.iter()
.chain(view.layout.page_breaks.iter())
{
if break_index >= measure_count {
return Err(Error::InvalidCommand(format!(
"score view break measure {break_index} is out of range"
)));
}
}
for (key, value) in &view.layout.style_overrides {
if key.trim().is_empty() || value.len() > 4096 {
return Err(Error::InvalidCommand(
"score view style overrides need a non-empty key and a value of at most 4096 bytes"
.into(),
));
}
}
Ok(())
}
fn apply_set_transpose(cmd: &SetTransposeCmd, score: &mut Score) -> Result<(), Error> {
score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?
.staves
.get_mut(cmd.staff_index)
.ok_or(Error::StaffNotFound(cmd.staff_index))?
.transpose_semitones = cmd.semitones;
Ok(())
}
fn apply_transpose_staff_region(
cmd: &TransposeStaffRegionCmd,
score: &mut Score,
) -> Result<(), Error> {
*score = transpose_staff_region_checked(
score,
cmd.part_index,
cmd.staff_index,
cmd.start_measure,
cmd.end_measure,
cmd.semitones,
cmd.target,
)?;
Ok(())
}
fn apply_exchange_voices(cmd: &ExchangeVoicesCmd, score: &mut Score) -> Result<(), Error> {
if cmd.first_voice >= 4 {
return Err(Error::VoiceOutOfRange(cmd.first_voice));
}
if cmd.second_voice >= 4 {
return Err(Error::VoiceOutOfRange(cmd.second_voice));
}
if cmd.first_voice == cmd.second_voice {
return Err(Error::InvalidCommand(
"exchange voices requires two distinct voices".into(),
));
}
let start = cmd.start_measure.min(cmd.end_measure);
let end = cmd.start_measure.max(cmd.end_measure);
let staff = score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?
.staves
.get_mut(cmd.staff_index)
.ok_or(Error::StaffNotFound(cmd.staff_index))?;
if end >= staff.measures.len() {
return Err(Error::MeasureNotFound(end));
}
for measure in &mut staff.measures[start..=end] {
measure.voices.swap(cmd.first_voice, cmd.second_voice);
measure
.source_voice_numbers
.swap(cmd.first_voice, cmd.second_voice);
}
remap_spanners(score, |address| {
if address.part != cmd.part_index
|| address.staff != cmd.staff_index
|| address.measure < start
|| address.measure > end
{
return Some(address.clone());
}
let mut remapped = address.clone();
if address.voice == cmd.first_voice {
remapped.voice = cmd.second_voice;
} else if address.voice == cmd.second_voice {
remapped.voice = cmd.first_voice;
}
Some(remapped)
});
Ok(())
}
fn apply_move_or_copy_voice_range(
cmd: &MoveOrCopyVoiceRangeCmd,
score: &mut Score,
) -> Result<(), Error> {
if cmd.source_start.part != cmd.source_end.part
|| cmd.source_start.staff != cmd.source_end.staff
|| cmd.source_start.voice != cmd.source_end.voice
{
return Err(Error::InvalidCommand(
"move or copy source endpoints must share part, staff, and voice".into(),
));
}
if cmd.source_start.voice >= 4 {
return Err(Error::VoiceOutOfRange(cmd.source_start.voice));
}
let source_from = cmd.source_start.measure.min(cmd.source_end.measure);
let source_to = cmd.source_start.measure.max(cmd.source_end.measure);
let source_count = source_to - source_from + 1;
let target_end = cmd
.target
.measure
.checked_add(source_count - 1)
.ok_or_else(|| Error::InvalidCommand("voice range target overflows".into()))?;
if cmd.move_source
&& cmd.target.part == cmd.source_start.part
&& cmd.target.staff == cmd.source_start.staff
&& cmd.target.voice == cmd.source_start.voice
&& cmd.target.measure <= source_to
&& target_end >= source_from
{
return Err(Error::InvalidCommand(
"moving a voice range onto itself is not supported".into(),
));
}
let fragment = extract_score_fragment(
score,
&[ScoreFragmentSelection {
start: cmd.source_start.clone(),
end: cmd.source_end.clone(),
}],
)?;
apply_paste_score_fragment(
&PasteScoreFragmentCmd {
fragment,
target: cmd.target.clone(),
policy: ScoreFragmentPastePolicy::Replace,
},
score,
)?;
if !cmd.move_source {
return Ok(());
}
let settings_time_signature = score.settings.time_signature.clone();
let staff = score
.parts
.get_mut(cmd.source_start.part)
.ok_or(Error::PartNotFound(cmd.source_start.part))?
.staves
.get_mut(cmd.source_start.staff)
.ok_or(Error::StaffNotFound(cmd.source_start.staff))?;
if source_to >= staff.measures.len() {
return Err(Error::MeasureNotFound(source_to));
}
for measure in &mut staff.measures[source_from..=source_to] {
let signature = measure
.time_sig
.as_ref()
.unwrap_or(&settings_time_signature);
let mut replacement = Vec::new();
pad_voice_to_measure(&mut replacement, signature.total_beats());
measure.voices[cmd.source_start.voice] = replacement;
measure.source_voice_numbers[cmd.source_start.voice] = None;
}
prune_orphaned_spanners(score);
Ok(())
}
fn effective_staff_measure_beats(
staff: &Staff,
default_time_signature: &TimeSignature,
measure_index: usize,
) -> Result<f64, Error> {
let mut time_signature = default_time_signature.clone();
for measure in staff.measures.iter().take(measure_index.saturating_add(1)) {
if let Some(signature) = &measure.time_sig {
time_signature = signature.clone();
}
}
if staff.measures.get(measure_index).is_none() {
return Err(Error::MeasureNotFound(measure_index));
}
Ok(time_signature.total_beats())
}
fn normalized_structural_measure_range(start: usize, end: usize) -> (usize, usize) {
(start.min(end), start.max(end))
}
fn apply_implode_staves(cmd: &ImplodeStavesCmd, score: &mut Score) -> Result<(), Error> {
if !(2..=4).contains(&cmd.source_staves.len()) {
return Err(Error::InvalidCommand(
"implode requires two to four source staves".into(),
));
}
let mut unique_staves = BTreeSet::new();
for &staff_index in &cmd.source_staves {
if !unique_staves.insert(staff_index) {
return Err(Error::InvalidCommand(
"implode source staves must be distinct".into(),
));
}
}
if !unique_staves.contains(&cmd.target_staff) {
return Err(Error::InvalidCommand(
"implode target staff must be included in source staves".into(),
));
}
let (start, end) = normalized_structural_measure_range(cmd.start_measure, cmd.end_measure);
let part = score
.parts
.get(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?;
for &staff_index in &cmd.source_staves {
if part.staves.get(staff_index).is_none() {
return Err(Error::StaffNotFound(staff_index));
}
}
for measure_index in start..=end {
let mut expected_beats: Option<f64> = None;
for &staff_index in &cmd.source_staves {
let staff = &part.staves[staff_index];
let measure = staff
.measures
.get(measure_index)
.ok_or(Error::MeasureNotFound(measure_index))?;
let beats = effective_staff_measure_beats(
staff,
&score.settings.time_signature,
measure_index,
)?;
if let Some(expected) = expected_beats
&& (expected - beats).abs() > 1e-9
{
return Err(Error::InvalidCommand(
"implode source staves must have matching measure durations".into(),
));
}
expected_beats = Some(beats);
if measure.voices[1..].iter().any(|voice| !voice.is_empty()) {
return Err(Error::InvalidCommand(
"implode requires empty secondary source voices".into(),
));
}
if measure.voices[0]
.iter()
.any(|note| note.cross_staff.is_some())
{
return Err(Error::InvalidCommand(
"implode does not support cross-staff source notes".into(),
));
}
let total: f64 = measure.voices[0].iter().map(Note::beats).sum();
if total > beats + 1e-9 {
return Err(Error::InvalidCommand(
"implode source voice exceeds its measure duration".into(),
));
}
}
}
for measure_index in start..=end {
let transferred: Vec<(Vec<Note>, Option<u32>)> = cmd
.source_staves
.iter()
.map(|&staff_index| {
let measure =
&score.parts[cmd.part_index].staves[staff_index].measures[measure_index];
(measure.voices[0].clone(), measure.source_voice_numbers[0])
})
.collect();
{
let target =
&mut score.parts[cmd.part_index].staves[cmd.target_staff].measures[measure_index];
target.voices = [Vec::new(), Vec::new(), Vec::new(), Vec::new()];
target.source_voice_numbers = [None; 4];
for (voice_index, (notes, source_voice_number)) in transferred.into_iter().enumerate() {
target.voices[voice_index] = notes;
target.source_voice_numbers[voice_index] = source_voice_number;
}
}
for &staff_index in &cmd.source_staves {
if staff_index == cmd.target_staff {
continue;
}
let beats = effective_staff_measure_beats(
&score.parts[cmd.part_index].staves[staff_index],
&score.settings.time_signature,
measure_index,
)?;
let mut rest_voice = Vec::new();
pad_voice_to_measure(&mut rest_voice, beats);
let measure =
&mut score.parts[cmd.part_index].staves[staff_index].measures[measure_index];
measure.voices = [rest_voice, Vec::new(), Vec::new(), Vec::new()];
measure.source_voice_numbers = [None; 4];
}
}
remap_spanners(score, |address| {
if address.part != cmd.part_index || address.measure < start || address.measure > end {
return Some(address.clone());
}
let voice = cmd
.source_staves
.iter()
.position(|&staff| staff == address.staff);
if address.voice != 0 {
return Some(address.clone());
}
voice
.map(|voice| NoteAddr {
staff: cmd.target_staff,
voice,
..address.clone()
})
.or_else(|| Some(address.clone()))
});
Ok(())
}
fn apply_explode_voices(cmd: &ExplodeVoicesCmd, score: &mut Score) -> Result<(), Error> {
if !(2..=4).contains(&cmd.target_staves.len()) {
return Err(Error::InvalidCommand(
"explode requires two to four target staves".into(),
));
}
if cmd.target_staves.first() != Some(&cmd.source_staff) {
return Err(Error::InvalidCommand(
"explode target staves must begin with the source staff".into(),
));
}
let mut unique_staves = BTreeSet::new();
for &staff_index in &cmd.target_staves {
if !unique_staves.insert(staff_index) {
return Err(Error::InvalidCommand(
"explode target staves must be distinct".into(),
));
}
}
let (start, end) = normalized_structural_measure_range(cmd.start_measure, cmd.end_measure);
let part = score
.parts
.get(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?;
let source = part
.staves
.get(cmd.source_staff)
.ok_or(Error::StaffNotFound(cmd.source_staff))?;
for &staff_index in &cmd.target_staves {
if part.staves.get(staff_index).is_none() {
return Err(Error::StaffNotFound(staff_index));
}
}
for measure_index in start..=end {
let expected =
effective_staff_measure_beats(source, &score.settings.time_signature, measure_index)?;
let source_measure = source
.measures
.get(measure_index)
.ok_or(Error::MeasureNotFound(measure_index))?;
for voice_index in cmd.target_staves.len()..4 {
if !source_measure.voices[voice_index].is_empty() {
return Err(Error::InvalidCommand(
"explode would discard a source voice without a target staff".into(),
));
}
}
for voice_index in 0..cmd.target_staves.len() {
if source_measure.voices[voice_index]
.iter()
.any(|note| note.cross_staff.is_some())
{
return Err(Error::InvalidCommand(
"explode does not support cross-staff source notes".into(),
));
}
let total: f64 = source_measure.voices[voice_index]
.iter()
.map(Note::beats)
.sum();
if total > expected + 1e-9 {
return Err(Error::InvalidCommand(
"explode source voice exceeds its measure duration".into(),
));
}
}
for &staff_index in cmd.target_staves.iter().skip(1) {
let target = &part.staves[staff_index];
let target_beats = effective_staff_measure_beats(
target,
&score.settings.time_signature,
measure_index,
)?;
if (target_beats - expected).abs() > 1e-9 {
return Err(Error::InvalidCommand(
"explode target staves must have matching measure durations".into(),
));
}
let measure = target
.measures
.get(measure_index)
.ok_or(Error::MeasureNotFound(measure_index))?;
if measure.voices[0].iter().any(|note| !note.is_rest)
|| measure.voices[1..].iter().any(|voice| !voice.is_empty())
|| measure.source_voice_numbers.iter().any(Option::is_some)
{
return Err(Error::InvalidCommand(
"explode destination staff must contain only an unnumbered rest voice".into(),
));
}
}
}
for measure_index in start..=end {
let transferred: Vec<(Vec<Note>, Option<u32>)> = (0..cmd.target_staves.len())
.map(|voice_index| {
let measure =
&score.parts[cmd.part_index].staves[cmd.source_staff].measures[measure_index];
(
measure.voices[voice_index].clone(),
measure.source_voice_numbers[voice_index],
)
})
.collect();
for (voice_index, &staff_index) in cmd.target_staves.iter().enumerate() {
let (notes, source_voice_number) = &transferred[voice_index];
let target =
&mut score.parts[cmd.part_index].staves[staff_index].measures[measure_index];
target.voices = [notes.clone(), Vec::new(), Vec::new(), Vec::new()];
target.source_voice_numbers = [*source_voice_number, None, None, None];
}
}
remap_spanners(score, |address| {
if address.part != cmd.part_index
|| address.staff != cmd.source_staff
|| address.measure < start
|| address.measure > end
|| address.voice >= cmd.target_staves.len()
{
return Some(address.clone());
}
Some(NoteAddr {
staff: cmd.target_staves[address.voice],
voice: 0,
..address.clone()
})
});
Ok(())
}
fn clear_derived_chord_note_notation(note: &mut Note) {
note.articulations.clear();
note.dynamic = None;
note.stem_up = None;
note.hairpin_start = None;
note.hairpin_end = false;
note.chord_symbol = None;
note.ottava_start = None;
note.ottava_end = false;
note.lyric = None;
note.additional_lyrics.clear();
note.pedal_start = false;
note.pedal_end = false;
note.slur_start = false;
note.slur_end = false;
note.arpeggiate = None;
note.technique_text = None;
note.glissando_start = false;
note.glissando_end = false;
note.cross_staff = None;
note.fingering = None;
note.fingerings.clear();
note.string_number = None;
note.trill_line_start = false;
note.trill_line_end = false;
note.guitar_technique = None;
note.guitar_bend_alter_cents = None;
note.guitar_bend_curve.clear();
}
fn exploded_chord_note(source: &Note, pitch_index: usize) -> Note {
if pitch_index == 0 {
let mut retained = source.clone();
if !retained.is_rest {
retained.pitches = vec![source.pitches[0].clone()];
retained.tab_positions = source.tab_positions.first().cloned().into_iter().collect();
retained.tab_position = retained.tab_positions.first().cloned();
}
return retained;
}
let mut derived = source.clone();
derived.id = Uuid::new_v4().to_string();
clear_derived_chord_note_notation(&mut derived);
if derived.is_rest || pitch_index >= source.pitches.len() {
derived.is_rest = true;
derived.is_unpitched = false;
derived.pitches.clear();
derived.tab_position = None;
derived.tab_positions.clear();
} else {
derived.pitches = vec![source.pitches[pitch_index].clone()];
derived.tab_positions = source
.tab_positions
.get(pitch_index)
.cloned()
.into_iter()
.collect();
derived.tab_position = derived.tab_positions.first().cloned();
}
derived
}
fn apply_explode_chord_pitches(
cmd: &ExplodeChordPitchesCmd,
score: &mut Score,
) -> Result<(), Error> {
if !(2..=4).contains(&cmd.target_staves.len()) {
return Err(Error::InvalidCommand(
"chord explode requires two to four target staves".into(),
));
}
if cmd.target_staves.first() != Some(&cmd.source_staff) {
return Err(Error::InvalidCommand(
"chord explode target staves must begin with the source staff".into(),
));
}
let mut unique_staves = BTreeSet::new();
for &staff_index in &cmd.target_staves {
if !unique_staves.insert(staff_index) {
return Err(Error::InvalidCommand(
"chord explode target staves must be distinct".into(),
));
}
}
let (start, end) = normalized_structural_measure_range(cmd.start_measure, cmd.end_measure);
let part = score
.parts
.get(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?;
let source = part
.staves
.get(cmd.source_staff)
.ok_or(Error::StaffNotFound(cmd.source_staff))?;
for &staff_index in &cmd.target_staves {
if part.staves.get(staff_index).is_none() {
return Err(Error::StaffNotFound(staff_index));
}
}
for measure_index in start..=end {
let expected =
effective_staff_measure_beats(source, &score.settings.time_signature, measure_index)?;
let source_measure = source
.measures
.get(measure_index)
.ok_or(Error::MeasureNotFound(measure_index))?;
for note in &source_measure.voices[0] {
if note.tuplet.is_some() {
return Err(Error::InvalidCommand(
"chord explode does not support tuplets".into(),
));
}
if note.cross_staff.is_some() {
return Err(Error::InvalidCommand(
"chord explode does not support cross-staff source notes".into(),
));
}
if note.is_unpitched || note.is_grace || note.is_cue {
return Err(Error::InvalidCommand(
"chord explode does not support unpitched, grace, or cue notes".into(),
));
}
if !note.is_rest
&& (note.pitches.is_empty() || note.pitches.len() > cmd.target_staves.len())
{
return Err(Error::InvalidCommand(
"chord explode pitch count must fit target staves".into(),
));
}
}
let total: f64 = source_measure.voices[0].iter().map(Note::beats).sum();
if total > expected + 1e-9 {
return Err(Error::InvalidCommand(
"chord explode source voice exceeds its measure duration".into(),
));
}
for &staff_index in cmd.target_staves.iter().skip(1) {
let target = &part.staves[staff_index];
let target_beats = effective_staff_measure_beats(
target,
&score.settings.time_signature,
measure_index,
)?;
if (target_beats - expected).abs() > 1e-9 {
return Err(Error::InvalidCommand(
"chord explode target staves must have matching measure durations".into(),
));
}
let measure = target
.measures
.get(measure_index)
.ok_or(Error::MeasureNotFound(measure_index))?;
if measure.voices[0].iter().any(|note| !note.is_rest)
|| measure.voices[1..].iter().any(|voice| !voice.is_empty())
|| measure.source_voice_numbers.iter().any(Option::is_some)
{
return Err(Error::InvalidCommand(
"chord explode destination staff must contain only an unnumbered rest voice"
.into(),
));
}
}
}
for measure_index in start..=end {
let source_measure =
&score.parts[cmd.part_index].staves[cmd.source_staff].measures[measure_index];
let source_voice_number = source_measure.source_voice_numbers[0];
let mut exploded = vec![Vec::new(); cmd.target_staves.len()];
for note in &source_measure.voices[0] {
for (pitch_index, voice) in exploded.iter_mut().enumerate() {
voice.push(exploded_chord_note(note, pitch_index));
}
}
for (index, &staff_index) in cmd.target_staves.iter().enumerate() {
let target =
&mut score.parts[cmd.part_index].staves[staff_index].measures[measure_index];
target.voices = [exploded[index].clone(), Vec::new(), Vec::new(), Vec::new()];
target.source_voice_numbers = [source_voice_number, None, None, None];
}
}
Ok(())
}
fn scaled_duration(duration: &Duration, scale: DurationScale) -> Option<Duration> {
match (duration, scale) {
(Duration::Whole, DurationScale::Double) | (Duration::SixtyFourth, DurationScale::Half) => {
None
}
(Duration::Whole, DurationScale::Half) | (Duration::Half, DurationScale::Double) => {
Some(Duration::Half)
}
(Duration::Half, DurationScale::Half) | (Duration::Quarter, DurationScale::Double) => {
Some(Duration::Quarter)
}
(Duration::Quarter, DurationScale::Half) | (Duration::Eighth, DurationScale::Double) => {
Some(Duration::Eighth)
}
(Duration::Eighth, DurationScale::Half) | (Duration::Sixteenth, DurationScale::Double) => {
Some(Duration::Sixteenth)
}
(Duration::Sixteenth, DurationScale::Half)
| (Duration::ThirtySecond, DurationScale::Double) => Some(Duration::ThirtySecond),
(Duration::ThirtySecond, DurationScale::Half)
| (Duration::SixtyFourth, DurationScale::Double) => Some(Duration::SixtyFourth),
}
}
fn uniform_tuplet_ratio(voice: &[Note]) -> Result<Option<TupletInfo>, Error> {
let mut ratio: Option<TupletInfo> = None;
for note in voice {
let Some(tuplet) = ¬e.tuplet else {
continue;
};
if tuplet.actual_notes == 0 || tuplet.normal_notes == 0 {
return Err(Error::InvalidCommand(
"duration scaling requires a non-zero tuplet ratio".into(),
));
}
if let Some(existing) = &ratio {
if existing != tuplet {
return Err(Error::InvalidCommand(
"duration scaling requires one shared tuplet ratio per voice".into(),
));
}
} else {
ratio = Some(tuplet.clone());
}
}
Ok(ratio)
}
fn pad_voice_to_measure_with_tuplet_ratio(
voice: &mut Vec<Note>,
max_beats: f64,
ratio: &TupletInfo,
) -> Result<(), Error> {
let mut used: f64 = voice.iter().map(Note::beats).sum();
let ratio_scale = f64::from(ratio.normal_notes) / f64::from(ratio.actual_notes);
while max_beats - used > 1e-9 {
let remaining = max_beats - used;
let duration = [
Duration::Whole,
Duration::Half,
Duration::Quarter,
Duration::Eighth,
Duration::Sixteenth,
Duration::ThirtySecond,
Duration::SixtyFourth,
]
.into_iter()
.find(|duration| duration.beats(0) * ratio_scale <= remaining + 1e-9)
.ok_or_else(|| {
Error::InvalidCommand(
"duration scaling cannot represent the remaining tuplet duration".into(),
)
})?;
let mut rest = Note::rest(duration);
rest.tuplet = Some(ratio.clone());
used += rest.beats();
voice.push(rest);
}
Ok(())
}
fn apply_scale_voice_range(cmd: &ScaleVoiceRangeCmd, score: &mut Score) -> Result<(), Error> {
if cmd.voice >= 4 {
return Err(Error::VoiceOutOfRange(cmd.voice));
}
let (start, end) = normalized_structural_measure_range(cmd.start_measure, cmd.end_measure);
let staff = score
.parts
.get(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?
.staves
.get(cmd.staff_index)
.ok_or(Error::StaffNotFound(cmd.staff_index))?;
for measure_index in start..=end {
let measure = staff
.measures
.get(measure_index)
.ok_or(Error::MeasureNotFound(measure_index))?;
let expected =
effective_staff_measure_beats(staff, &score.settings.time_signature, measure_index)?;
let voice = &measure.voices[cmd.voice];
match cmd.tuplet_policy {
TupletScalePolicy::PreserveRatio => {
uniform_tuplet_ratio(voice)?;
}
}
let mut scaled_beats = 0.0;
for note in voice {
if scaled_duration(¬e.duration, cmd.scale).is_none() {
return Err(Error::InvalidCommand(
"duration scaling exceeds the portable duration range".into(),
));
}
if !note.is_grace && !note.is_cue {
scaled_beats += match cmd.scale {
DurationScale::Half => note.beats() / 2.0,
DurationScale::Double => note.beats() * 2.0,
};
}
}
if scaled_beats > expected + 1e-9 {
return Err(Error::InvalidCommand(format!(
"duration scaling overflows measure {measure_index}: {scaled_beats} beats exceeds {expected}"
)));
}
}
for measure_index in start..=end {
let expected = effective_staff_measure_beats(
&score.parts[cmd.part_index].staves[cmd.staff_index],
&score.settings.time_signature,
measure_index,
)?;
let voice = &mut score.parts[cmd.part_index].staves[cmd.staff_index].measures
[measure_index]
.voices[cmd.voice];
if voice.is_empty() {
continue;
}
let tuplet_ratio = match cmd.tuplet_policy {
TupletScalePolicy::PreserveRatio => uniform_tuplet_ratio(voice)?,
};
for note in voice.iter_mut() {
note.duration = scaled_duration(¬e.duration, cmd.scale).ok_or_else(|| {
Error::InvalidCommand("duration scaling exceeds the portable duration range".into())
})?;
}
if let Some(ratio) = tuplet_ratio {
pad_voice_to_measure_with_tuplet_ratio(voice, expected, &ratio)?;
} else {
pad_voice_to_measure(voice, expected);
}
}
Ok(())
}
fn apply_paste_score_fragment(cmd: &PasteScoreFragmentCmd, score: &mut Score) -> Result<(), Error> {
if !(MIN_SUPPORTED_SCORE_FRAGMENT_CONTRACT_VERSION..=SCORE_FRAGMENT_CONTRACT_VERSION)
.contains(&cmd.fragment.contract_version)
{
return Err(Error::InvalidCommand(format!(
"unsupported score fragment contract version {}",
cmd.fragment.contract_version
)));
}
if cmd.fragment.voices.is_empty() {
return Err(Error::InvalidCommand(
"cannot paste an empty score fragment".into(),
));
}
let mut lanes = BTreeSet::new();
let mut replaced = BTreeSet::new();
for lane in &cmd.fragment.voices {
let part_index = cmd
.target
.part
.checked_add(lane.relative_part)
.ok_or_else(|| Error::InvalidCommand("fragment part target overflows".into()))?;
let staff_index = cmd
.target
.staff
.checked_add(lane.relative_staff)
.ok_or_else(|| Error::InvalidCommand("fragment staff target overflows".into()))?;
let voice_index = cmd
.target
.voice
.checked_add(lane.relative_voice)
.ok_or_else(|| Error::InvalidCommand("fragment voice target overflows".into()))?;
if voice_index >= 4 {
return Err(Error::VoiceOutOfRange(voice_index));
}
let staff = score
.parts
.get(part_index)
.ok_or(Error::PartNotFound(part_index))?
.staves
.get(staff_index)
.ok_or(Error::StaffNotFound(staff_index))?;
if !lanes.insert((part_index, staff_index, voice_index)) {
return Err(Error::InvalidCommand(
"fragment contains duplicate destination voice lanes".into(),
));
}
for measure in &lane.measures {
let measure_index = cmd
.target
.measure
.checked_add(measure.relative_measure)
.ok_or_else(|| Error::InvalidCommand("fragment measure target overflows".into()))?;
if measure_index >= staff.measures.len() {
return Err(Error::MeasureNotFound(measure_index));
}
if !measure.cross_staff_targets.is_empty()
&& measure.cross_staff_targets.len() != measure.notes.len()
{
return Err(Error::InvalidCommand(
"fragment cross-staff targets do not match note count".into(),
));
}
if cmd.policy == ScoreFragmentPastePolicy::Merge
&& !measure.notes.is_empty()
&& staff.measures[measure_index].voices[voice_index]
.iter()
.any(|note| !note.is_rest)
{
return Err(Error::InvalidCommand(
"fragment merge would overwrite a sounding destination lane".into(),
));
}
if cmd.policy == ScoreFragmentPastePolicy::Merge
&& measure.attributes.present
&& measure.attributes
!= super::fragment::ScoreFragmentMeasureAttributes::from_measure(
&staff.measures[measure_index],
)
{
return Err(Error::InvalidCommand(
"fragment merge would overwrite destination measure attributes".into(),
));
}
for cross_staff in measure.cross_staff_targets.iter().flatten() {
let target_staff = staff_index as i64 + cross_staff.staff_offset;
if target_staff < 0
|| score.parts[part_index]
.staves
.get(target_staff as usize)
.is_none()
{
return Err(Error::InvalidCommand(
"fragment cross-staff target is outside destination part".into(),
));
}
if cross_staff.target_voice.is_some_and(|voice| voice >= 4) {
return Err(Error::InvalidCommand(
"fragment cross-staff target voice is outside editable range".into(),
));
}
}
replaced.insert((part_index, staff_index, measure_index, voice_index));
}
}
score.spanners.retain(|spanner| {
!replaced.contains(&(
spanner.start.part,
spanner.start.staff,
spanner.start.measure,
spanner.start.voice,
)) && !replaced.contains(&(
spanner.end.part,
spanner.end.staff,
spanner.end.measure,
spanner.end.voice,
))
});
for lane in &cmd.fragment.voices {
let part_index = cmd.target.part + lane.relative_part;
let staff_index = cmd.target.staff + lane.relative_staff;
let voice_index = cmd.target.voice + lane.relative_voice;
let staff = &mut score.parts[part_index].staves[staff_index];
for measure in &lane.measures {
let measure_index = cmd.target.measure + measure.relative_measure;
let target = &mut staff.measures[measure_index];
if cmd.policy == ScoreFragmentPastePolicy::Merge && measure.notes.is_empty() {
continue;
}
let mut notes = measure.notes.clone();
for note in &mut notes {
note.id = Uuid::new_v4().to_string();
}
if !measure.cross_staff_targets.is_empty() {
for (note, cross_staff) in notes.iter_mut().zip(&measure.cross_staff_targets) {
note.cross_staff = cross_staff.as_ref().map(|cross_staff| CrossStaff {
target_staff: (staff_index as i64 + cross_staff.staff_offset) as usize,
target_voice: cross_staff.target_voice,
});
}
}
target.voices[voice_index] = notes;
target.source_voice_numbers[voice_index] = measure.source_voice_number;
if cmd.policy == ScoreFragmentPastePolicy::Replace {
measure.attributes.apply_to_measure(target);
}
}
}
let mut used_spanner_ids = score
.spanners
.iter()
.map(|spanner| spanner.id.clone())
.collect::<BTreeSet<_>>();
for source in &cmd.fragment.spanners {
let mut copied = source.clone();
copied.start = fragment_destination_address(&cmd.target, &source.start)?;
copied.end = fragment_destination_address(&cmd.target, &source.end)?;
if note_at(score, &copied.start).is_none() {
return Err(Error::NoteNotFound(copied.start.note));
}
if note_at(score, &copied.end).is_none() {
return Err(Error::NoteNotFound(copied.end.note));
}
copied.id = unique_fragment_spanner_id(&used_spanner_ids, &source.id);
used_spanner_ids.insert(copied.id.clone());
score.spanners.push(copied);
}
Ok(())
}
fn fragment_destination_address(target: &NoteAddr, relative: &NoteAddr) -> Result<NoteAddr, Error> {
Ok(NoteAddr {
part: target
.part
.checked_add(relative.part)
.ok_or_else(|| Error::InvalidCommand("fragment part target overflows".into()))?,
staff: target
.staff
.checked_add(relative.staff)
.ok_or_else(|| Error::InvalidCommand("fragment staff target overflows".into()))?,
measure: target
.measure
.checked_add(relative.measure)
.ok_or_else(|| Error::InvalidCommand("fragment measure target overflows".into()))?,
voice: target
.voice
.checked_add(relative.voice)
.ok_or_else(|| Error::InvalidCommand("fragment voice target overflows".into()))?,
note: relative.note,
})
}
fn unique_fragment_spanner_id(used: &BTreeSet<String>, source_id: &str) -> String {
let base = format!("{source_id}-copy");
if !used.contains(&base) {
return base;
}
let mut suffix = 2usize;
loop {
let candidate = format!("{base}-{suffix}");
if !used.contains(&candidate) {
return candidate;
}
suffix += 1;
}
}
fn apply_toggle_slur(cmd: &ToggleSlurCmd, score: &mut Score) -> Result<(), Error> {
let new_start = !{
score
.parts
.get(cmd.start.part)
.ok_or(Error::PartNotFound(cmd.start.part))?
.staves
.get(cmd.start.staff)
.ok_or(Error::StaffNotFound(cmd.start.staff))?
.measures
.get(cmd.start.measure)
.ok_or(Error::MeasureNotFound(cmd.start.measure))?
.voices
.get(cmd.start.voice)
.ok_or(Error::VoiceOutOfRange(cmd.start.voice))?
.get(cmd.start.note)
.ok_or(Error::NoteNotFound(cmd.start.note))?
.slur_start
};
let new_end = !{
score
.parts
.get(cmd.end.part)
.ok_or(Error::PartNotFound(cmd.end.part))?
.staves
.get(cmd.end.staff)
.ok_or(Error::StaffNotFound(cmd.end.staff))?
.measures
.get(cmd.end.measure)
.ok_or(Error::MeasureNotFound(cmd.end.measure))?
.voices
.get(cmd.end.voice)
.ok_or(Error::VoiceOutOfRange(cmd.end.voice))?
.get(cmd.end.note)
.ok_or(Error::NoteNotFound(cmd.end.note))?
.slur_end
};
score.parts[cmd.start.part].staves[cmd.start.staff].measures[cmd.start.measure].voices
[cmd.start.voice][cmd.start.note]
.slur_start = new_start;
score.parts[cmd.end.part].staves[cmd.end.staff].measures[cmd.end.measure].voices
[cmd.end.voice][cmd.end.note]
.slur_end = new_end;
Ok(())
}
fn apply_toggle_trill_line(cmd: &ToggleTrillLineCmd, score: &mut Score) -> Result<(), Error> {
let new_start = !{
score
.parts
.get(cmd.start.part)
.ok_or(Error::PartNotFound(cmd.start.part))?
.staves
.get(cmd.start.staff)
.ok_or(Error::StaffNotFound(cmd.start.staff))?
.measures
.get(cmd.start.measure)
.ok_or(Error::MeasureNotFound(cmd.start.measure))?
.voices
.get(cmd.start.voice)
.ok_or(Error::VoiceOutOfRange(cmd.start.voice))?
.get(cmd.start.note)
.ok_or(Error::NoteNotFound(cmd.start.note))?
.trill_line_start
};
let new_end = !{
score
.parts
.get(cmd.end.part)
.ok_or(Error::PartNotFound(cmd.end.part))?
.staves
.get(cmd.end.staff)
.ok_or(Error::StaffNotFound(cmd.end.staff))?
.measures
.get(cmd.end.measure)
.ok_or(Error::MeasureNotFound(cmd.end.measure))?
.voices
.get(cmd.end.voice)
.ok_or(Error::VoiceOutOfRange(cmd.end.voice))?
.get(cmd.end.note)
.ok_or(Error::NoteNotFound(cmd.end.note))?
.trill_line_end
};
score.parts[cmd.start.part].staves[cmd.start.staff].measures[cmd.start.measure].voices
[cmd.start.voice][cmd.start.note]
.trill_line_start = new_start;
score.parts[cmd.end.part].staves[cmd.end.staff].measures[cmd.end.measure].voices
[cmd.end.voice][cmd.end.note]
.trill_line_end = new_end;
Ok(())
}
fn apply_add_staff(cmd: &AddStaffCmd, score: &mut Score) -> Result<(), Error> {
let ts = score.settings.time_signature.clone();
let measure_count = score
.parts
.get(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?
.staves
.first()
.map_or(0, |s| s.measures.len());
let mut staff = Staff::new(cmd.clef.clone());
for i in 0..measure_count {
let mut m = Measure::empty(ts.numerator, ts.denominator);
m.number = i as u32 + 1;
staff.measures.push(m);
}
score.parts[cmd.part_index].staves.push(staff);
Ok(())
}
fn apply_delete_staff(cmd: &DeleteStaffCmd, score: &mut Score) -> Result<(), Error> {
let part = score
.parts
.get_mut(cmd.part_index)
.ok_or(Error::PartNotFound(cmd.part_index))?;
if part.staves.len() <= 1 {
return Err(Error::CannotDeleteLastStaff);
}
if cmd.staff_index >= part.staves.len() {
return Err(Error::StaffNotFound(cmd.staff_index));
}
part.staves.remove(cmd.staff_index);
remap_spanners(score, |address| {
if address.part != cmd.part_index {
Some(address.clone())
} else if address.staff == cmd.staff_index {
None
} else if address.staff > cmd.staff_index {
let mut shifted = address.clone();
shifted.staff -= 1;
Some(shifted)
} else {
Some(address.clone())
}
});
Ok(())
}
fn same_voice(address: &NoteAddr, part: usize, staff: usize, measure: usize, voice: usize) -> bool {
address.part == part
&& address.staff == staff
&& address.measure == measure
&& address.voice == voice
}
fn trim_voice_to_measure(voice: &mut Vec<Note>, max_beats: f64) {
if matches!(voice.as_slice(), [only] if only.is_plain_whole_rest()) {
return;
}
let mut total = 0.0f64;
let mut cutoff = voice.len();
for (i, n) in voice.iter().enumerate() {
total += n.beats();
if total > max_beats + 1e-9 {
cutoff = i;
break;
}
}
voice.truncate(cutoff);
pad_voice_to_measure(voice, max_beats);
}
fn pad_voice_to_measure(voice: &mut Vec<Note>, max_beats: f64) {
let mut used = crate::voice_duration_beats(voice, max_beats);
while max_beats - used > 1e-9 {
let remaining = max_beats - used;
let rest = Note::rest(Duration::whole_filling_beats(remaining));
used += rest.beats();
voice.push(rest);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ScoreEngine;
use crate::model::pitch::Step;
fn default_engine_score() -> Score {
let mut s = Score::default();
for part in &mut s.parts {
for staff in &mut part.staves {
for (i, m) in staff.measures.iter_mut().enumerate() {
m.number = i as u32 + 1;
}
}
}
s
}
#[test]
fn legacy_scale_voice_range_command_defaults_to_preserve_tuplet_ratio() {
let command: Command = serde_json::from_str(
r#"{"type":"scale_voice_range","part_index":0,"staff_index":0,"voice":0,"start_measure":0,"end_measure":0,"scale":"half"}"#,
)
.expect("legacy scale command deserializes");
let Command::ScaleVoiceRange(command) = command else {
panic!("expected scale command");
};
assert_eq!(command.tuplet_policy, TupletScalePolicy::PreserveRatio);
}
fn score_with_typed_spanner() -> Score {
use crate::model::score::{NotationSpanner, NotationSpannerKind};
let mut score = Score::new("Spanner edits", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0] = (0..4)
.map(|offset| Note::new(Pitch::new(Step::C, 4 + offset), Duration::Quarter))
.collect();
score.spanners.push(NotationSpanner {
id: "typed-glissando".to_string(),
kind: NotationSpannerKind::Glissando,
start: NoteAddr {
part: 0,
staff: 0,
measure: 0,
voice: 0,
note: 1,
},
end: NoteAddr {
part: 0,
staff: 0,
measure: 0,
voice: 0,
note: 2,
},
number: Some(1),
line_type: None,
text: None,
placement: None,
ottava_size: None,
ottava_type: None,
});
score
}
fn insert_note_at(position: usize) -> Command {
Command::AddNote(AddNoteCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
position,
pitch: Some(Pitch::new(Step::D, 5)),
duration: Duration::Quarter,
dot_count: 0,
is_rest: false,
tuplet: None,
})
}
#[test]
fn add_note_inserts_into_voice() {
let mut score = default_engine_score();
let cmd = Command::AddNote(AddNoteCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
position: 0,
pitch: Some(Pitch::new(Step::C, 4)),
duration: Duration::Quarter,
dot_count: 0,
is_rest: false,
tuplet: None,
});
apply_command(&cmd, &mut score).unwrap();
let first = &score.parts[0].staves[0].measures[0].voices[0][0];
assert!(!first.is_rest);
assert_eq!(first.pitches[0].step, Step::C);
}
#[test]
fn typed_spanners_follow_note_insertions_at_every_relative_position() {
for (position, expected_start, expected_end) in [
(0, 2, 3), (1, 2, 3), (2, 1, 3), (4, 1, 2), (3, 1, 2), ] {
let mut score = score_with_typed_spanner();
apply_command(&insert_note_at(position), &mut score).unwrap();
let span = score.spanners.first().expect("span remains attached");
assert_eq!(span.start.note, expected_start, "position {position}");
assert_eq!(span.end.note, expected_end, "position {position}");
}
}
#[test]
fn deleting_a_typed_spanner_endpoint_removes_the_whole_span_and_undo_redo_is_atomic() {
let mut score = score_with_typed_spanner();
let endpoint_id = score.parts[0].staves[0].measures[0].voices[0][1].id.clone();
let mut stack = CommandStack::new(8);
stack
.execute(
Command::DeleteNote(DeleteNoteCmd {
note_id: endpoint_id,
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
}),
&mut score,
)
.unwrap();
assert!(score.spanners.is_empty());
stack.undo(&mut score).unwrap();
assert_eq!(score.spanners[0].start.note, 1);
assert_eq!(score.spanners[0].end.note, 2);
stack.redo(&mut score).unwrap();
assert!(score.spanners.is_empty());
}
#[test]
fn batched_structural_edits_remap_typed_spanners_and_restore_them_on_undo() {
let mut score = score_with_typed_spanner();
let mut stack = CommandStack::new(8);
stack
.batch_execute(
vec![
insert_note_at(0),
Command::SetTempo(SetTempoCmd { bpm: 144 }),
],
&mut score,
)
.unwrap();
assert_eq!(score.spanners[0].start.note, 2);
assert_eq!(score.spanners[0].end.note, 3);
stack.undo(&mut score).unwrap();
assert_eq!(score.spanners[0].start.note, 1);
assert_eq!(score.spanners[0].end.note, 2);
stack.redo(&mut score).unwrap();
assert_eq!(score.spanners[0].start.note, 2);
assert_eq!(score.spanners[0].end.note, 3);
}
#[test]
fn typed_spanners_remap_only_the_edited_endpoint_across_staff_and_voice() {
use crate::model::score::{NotationSpanner, NotationSpannerKind, ScoreTemplate};
let mut score = Score::template(ScoreTemplate::Piano);
for staff in &mut score.parts[0].staves {
let notes: Vec<Note> = (0..4)
.map(|offset| Note::new(Pitch::new(Step::C, 4 + offset), Duration::Quarter))
.collect();
staff.measures[0].voices[0] = notes.clone();
staff.measures[0].voices[1] = notes;
}
score.spanners.push(NotationSpanner {
id: "cross-staff".to_string(),
kind: NotationSpannerKind::Slur,
start: NoteAddr {
part: 0,
staff: 0,
measure: 0,
voice: 0,
note: 1,
},
end: NoteAddr {
part: 0,
staff: 1,
measure: 0,
voice: 1,
note: 2,
},
number: Some(2),
line_type: None,
text: None,
placement: None,
ottava_size: None,
ottava_type: None,
});
let mut stack = CommandStack::new(8);
stack.execute(insert_note_at(0), &mut score).unwrap();
let span = &score.spanners[0];
assert_eq!(span.start.note, 2);
assert_eq!(span.end.staff, 1);
assert_eq!(span.end.voice, 1);
assert_eq!(span.end.note, 2);
}
#[test]
fn set_fingerings_keeps_first_legacy_value_in_sync() {
let mut score = default_engine_score();
apply_command(
&Command::AddNote(AddNoteCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
position: 0,
pitch: Some(Pitch::new(Step::C, 4)),
duration: Duration::Quarter,
dot_count: 0,
is_rest: false,
tuplet: None,
}),
&mut score,
)
.unwrap();
apply_command(
&Command::SetFingerings(SetFingeringsCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
note_index: 0,
fingerings: vec![1, 3, 4],
}),
&mut score,
)
.unwrap();
let note = &score.parts[0].staves[0].measures[0].voices[0][0];
assert_eq!(note.fingerings, vec![1, 3, 4]);
assert_eq!(note.fingering, Some(1));
}
#[test]
fn set_figured_bass_replaces_measure_figures() {
let mut score = default_engine_score();
let figures = vec![FiguredBassFigure {
number: "6".to_string(),
alter: Some("-1".to_string()),
prefix: Some("+".to_string()),
suffix: None,
extender: false,
}];
apply_command(
&Command::SetFiguredBass(SetFiguredBassCmd {
measure_index: 0,
figures: figures.clone(),
}),
&mut score,
)
.unwrap();
assert_eq!(score.parts[0].staves[0].measures[0].figured_bass, figures);
}
#[test]
fn set_measure_text_supports_append_replace_remove_and_undo_redo() {
let mut engine = crate::ScoreEngine::new();
let address = SetMeasureTextCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
text_index: 0,
text: Some(StyledText {
style: crate::TextStyle::Technique,
text: "dolce".to_string(),
placement: None,
offset_x: None,
offset_y: None,
relative_x: None,
relative_y: None,
}),
};
engine.apply(Command::SetMeasureText(address)).unwrap();
assert_eq!(
engine.score.parts[0].staves[0].measures[0].texts[0],
StyledText {
style: crate::TextStyle::Technique,
text: "dolce".to_string(),
placement: None,
offset_x: None,
offset_y: None,
relative_x: None,
relative_y: None,
}
);
engine
.apply(Command::SetMeasureText(SetMeasureTextCmd {
text_index: 0,
text: Some(StyledText {
style: crate::TextStyle::RehearsalMark,
text: "A".to_string(),
placement: None,
offset_x: None,
offset_y: None,
relative_x: None,
relative_y: None,
}),
..SetMeasureTextCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
text_index: 0,
text: None,
}
}))
.unwrap();
assert_eq!(
engine.score.parts[0].staves[0].measures[0].texts[0].style,
crate::TextStyle::RehearsalMark
);
engine
.apply(Command::SetMeasureText(SetMeasureTextCmd {
text_index: 0,
text: None,
..SetMeasureTextCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
text_index: 0,
text: None,
}
}))
.unwrap();
assert!(engine.score.parts[0].staves[0].measures[0].texts.is_empty());
engine.undo().unwrap();
assert_eq!(engine.score.parts[0].staves[0].measures[0].texts.len(), 1);
engine.redo().unwrap();
assert!(engine.score.parts[0].staves[0].measures[0].texts.is_empty());
}
#[test]
fn set_measure_text_rejects_invalid_index_atomically_and_round_trips_json() {
let mut score = default_engine_score();
let before = score.clone();
let command = Command::SetMeasureText(SetMeasureTextCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
text_index: 2,
text: Some(StyledText {
style: crate::TextStyle::Expression,
text: "espressivo".to_string(),
placement: None,
offset_x: None,
offset_y: None,
relative_x: None,
relative_y: None,
}),
});
let json = serde_json::to_string(&command).unwrap();
let restored: Command = serde_json::from_str(&json).unwrap();
assert_eq!(
serde_json::to_value(&restored).unwrap(),
serde_json::to_value(&command).unwrap()
);
assert!(apply_command(&restored, &mut score).is_err());
assert_eq!(
serde_json::to_value(&score).unwrap(),
serde_json::to_value(&before).unwrap()
);
}
#[test]
fn set_score_text_supports_append_replace_remove_and_undo_redo() {
let mut engine = crate::ScoreEngine::new();
let text = StyledText {
style: crate::TextStyle::Expression,
text: "Title".to_string(),
placement: None,
offset_x: None,
offset_y: None,
relative_x: None,
relative_y: None,
};
engine
.apply(Command::SetScoreText(SetScoreTextCmd {
text_index: 0,
text: Some(text.clone()),
}))
.unwrap();
assert_eq!(engine.score.texts, vec![text.clone()]);
let mut replacement = text.clone();
replacement.text = "Subtitle".to_string();
engine
.apply(Command::SetScoreText(SetScoreTextCmd {
text_index: 0,
text: Some(replacement.clone()),
}))
.unwrap();
assert_eq!(engine.score.texts, vec![replacement]);
engine
.apply(Command::SetScoreText(SetScoreTextCmd {
text_index: 0,
text: None,
}))
.unwrap();
assert!(engine.score.texts.is_empty());
engine.undo().unwrap();
assert_eq!(engine.score.texts.len(), 1);
engine.redo().unwrap();
assert!(engine.score.texts.is_empty());
}
#[test]
fn set_score_style_overrides_is_undoable_and_rejects_invalid_values() {
let mut engine = crate::ScoreEngine::new();
let overrides = vec![ViewStyleOverride {
property: super::super::score::ViewStyleProperty::SystemGap,
value: 2.5,
}];
engine
.apply(Command::SetScoreStyleOverrides(SetScoreStyleOverridesCmd {
overrides: overrides.clone(),
}))
.unwrap();
assert_eq!(engine.score.style_overrides, overrides);
engine.undo().unwrap();
assert!(engine.score.style_overrides.is_empty());
engine.redo().unwrap();
assert_eq!(engine.score.style_overrides, overrides);
let before = engine.score.clone();
assert!(
engine
.apply(Command::SetScoreStyleOverrides(SetScoreStyleOverridesCmd {
overrides: vec![ViewStyleOverride {
property: super::super::score::ViewStyleProperty::TextScale,
value: f32::NAN,
}],
}))
.is_err()
);
assert_eq!(
serde_json::to_value(&engine.score).unwrap(),
serde_json::to_value(&before).unwrap()
);
}
#[test]
fn set_object_style_overrides_is_undoable_and_requires_existing_target() {
let mut engine = crate::ScoreEngine::new();
engine.score.texts.push(StyledText {
style: crate::TextStyle::Expression,
text: "Allegro".into(),
placement: None,
offset_x: None,
offset_y: None,
relative_x: None,
relative_y: None,
});
let overrides = vec![ObjectStyleOverride {
target: super::super::score::ObjectStyleTarget::ScoreText { text_index: 0 },
property: super::super::score::ViewStyleProperty::TextScale,
value: 1.2,
provenance: None,
}];
engine
.apply(Command::SetObjectStyleOverrides(
SetObjectStyleOverridesCmd {
overrides: overrides.clone(),
},
))
.unwrap();
assert_eq!(engine.score.object_style_overrides, overrides);
engine.undo().unwrap();
assert!(engine.score.object_style_overrides.is_empty());
assert!(
engine
.apply(Command::SetObjectStyleOverrides(
SetObjectStyleOverridesCmd {
overrides: vec![ObjectStyleOverride {
target: super::super::score::ObjectStyleTarget::ScoreText {
text_index: 1,
},
property: super::super::score::ViewStyleProperty::TextScale,
value: 1.2,
provenance: None,
}],
}
))
.is_err()
);
}
#[test]
fn set_harmony_range_is_undoable_and_json_compatible() {
let mut score = default_engine_score();
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
score.parts[0].staves[0].measures[0].voices[0][0].chord_symbol = Some(ChordSymbol {
root: "C".to_owned(),
kind: "major".to_owned(),
bass: None,
placement: None,
extender: true,
harmonic_degree: None,
harmony_function: None,
harmony_type: None,
chord_ref: None,
range_end: None,
degrees: Vec::new(),
});
let mut engine = ScoreEngine::new();
engine.replace_score(score);
let command = Command::SetHarmonyRange(SetHarmonyRangeCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
note_index: 0,
end: Some(NoteAddr {
part: 0,
staff: 0,
measure: 0,
voice: 0,
note: 0,
}),
});
let json = serde_json::to_string(&command).unwrap();
let restored: Command = serde_json::from_str(&json).unwrap();
engine.apply(restored).unwrap();
assert!(
engine.score.parts[0].staves[0].measures[0].voices[0][0]
.chord_symbol
.as_ref()
.and_then(|chord| chord.range_end.as_ref())
.is_some()
);
engine.undo().unwrap();
assert!(
engine.score.parts[0].staves[0].measures[0].voices[0][0]
.chord_symbol
.as_ref()
.is_some_and(|chord| chord.range_end.is_none())
);
}
#[test]
fn set_harp_pedal_diagrams_is_undoable_and_json_compatible() {
let mut engine = ScoreEngine::new();
let mut diagram = HarpPedalDiagram::default();
diagram.positions[0] = super::super::score::HarpPedalPosition::Flat;
diagram.positions[6] = super::super::score::HarpPedalPosition::Sharp;
let command = Command::SetHarpPedalDiagrams(SetHarpPedalDiagramsCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
diagrams: vec![diagram.clone()],
});
let restored: Command =
serde_json::from_str(&serde_json::to_string(&command).unwrap()).unwrap();
engine.apply(restored).unwrap();
assert_eq!(
engine.score.parts[0].staves[0].measures[0].harp_pedal_diagrams,
vec![diagram]
);
engine.undo().unwrap();
assert!(
engine.score.parts[0].staves[0].measures[0]
.harp_pedal_diagrams
.is_empty()
);
}
#[test]
fn set_note_placement_is_undoable_and_rejects_non_finite_values() {
let mut score = default_engine_score();
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
let mut engine = ScoreEngine::new();
engine.replace_score(score);
let command = Command::SetNotePlacement(SetNotePlacementCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
note_index: 0,
offset_x: Some(12.5),
offset_y: Some(-3.0),
relative_x: Some(1.25),
relative_y: Some(-0.5),
});
engine.apply(command).unwrap();
let note = &engine.score.parts[0].staves[0].measures[0].voices[0][0];
assert_eq!(note.offset_x, Some(12.5));
assert_eq!(note.relative_y, Some(-0.5));
engine.undo().unwrap();
assert_eq!(
engine.score.parts[0].staves[0].measures[0].voices[0][0].offset_x,
None
);
let invalid = Command::SetNotePlacement(SetNotePlacementCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
note_index: 0,
offset_x: Some(f64::NAN),
offset_y: None,
relative_x: None,
relative_y: None,
});
assert!(engine.apply(invalid).is_err());
assert_eq!(
engine.score.parts[0].staves[0].measures[0].voices[0][0].offset_x,
None
);
}
#[test]
fn set_guitar_bend_alter_updates_note_and_can_clear() {
let mut score = default_engine_score();
apply_command(
&Command::AddNote(AddNoteCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
position: 0,
pitch: Some(Pitch::new(Step::G, 4)),
duration: Duration::Quarter,
dot_count: 0,
is_rest: false,
tuplet: None,
}),
&mut score,
)
.unwrap();
let location = SetGuitarBendAlterCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
note_index: 0,
alter_cents: Some(200),
};
apply_command(&Command::SetGuitarBendAlter(location.clone()), &mut score).unwrap();
assert_eq!(
score.parts[0].staves[0].measures[0].voices[0][0].guitar_bend_alter_cents,
Some(200)
);
let mut cleared = location;
cleared.alter_cents = None;
apply_command(&Command::SetGuitarBendAlter(cleared), &mut score).unwrap();
assert_eq!(
score.parts[0].staves[0].measures[0].voices[0][0].guitar_bend_alter_cents,
None
);
}
#[test]
fn set_guitar_bend_curve_is_undoable_and_json_compatible() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
let command = Command::SetGuitarBendCurve(SetGuitarBendCurveCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
note_index: 0,
points: vec![
crate::GuitarBendPoint {
position_per_mille: 0,
alter_cents: 0,
},
crate::GuitarBendPoint {
position_per_mille: 500,
alter_cents: 200,
},
crate::GuitarBendPoint {
position_per_mille: 1000,
alter_cents: 0,
},
],
});
let decoded: Command =
serde_json::from_str(&serde_json::to_string(&command).unwrap()).unwrap();
let mut stack = CommandStack::new(16);
stack.execute(decoded, &mut score).unwrap();
assert_eq!(
score.parts[0].staves[0].measures[0].voices[0][0]
.guitar_bend_curve
.len(),
3
);
stack.undo(&mut score).unwrap();
assert!(
score.parts[0].staves[0].measures[0].voices[0][0]
.guitar_bend_curve
.is_empty()
);
}
#[test]
fn set_duration_updates_note_and_preserves_measure_capacity() {
let mut score = default_engine_score();
apply_command(
&Command::AddNote(AddNoteCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
position: 0,
pitch: Some(Pitch::new(Step::C, 4)),
duration: Duration::Quarter,
dot_count: 0,
is_rest: false,
tuplet: None,
}),
&mut score,
)
.unwrap();
apply_command(
&Command::SetDuration(SetDurationCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
note_index: 0,
duration: Duration::Half,
dot_count: 1,
}),
&mut score,
)
.unwrap();
let voice = &score.parts[0].staves[0].measures[0].voices[0];
assert_eq!(voice[0].duration, Duration::Half);
assert_eq!(voice[0].dot_count, 1);
assert!((voice.iter().map(|note| note.beats()).sum::<f64>() - 4.0).abs() < 1e-9);
}
#[test]
fn set_tempo_updates_score() {
let mut score = default_engine_score();
let cmd = Command::SetTempo(SetTempoCmd { bpm: 160 });
apply_command(&cmd, &mut score).unwrap();
assert_eq!(score.settings.tempo_bpm, 160);
}
#[test]
fn add_measure_increases_count() {
let mut score = default_engine_score();
let before = score.measure_count();
apply_command(
&Command::AddMeasure(AddMeasureCmd { after_index: 0 }),
&mut score,
)
.unwrap();
assert_eq!(score.measure_count(), before + 1);
}
#[test]
fn delete_measure_decreases_count() {
let mut score = default_engine_score();
let before = score.measure_count();
apply_command(
&Command::DeleteMeasure(DeleteMeasureCmd { measure_index: 0 }),
&mut score,
)
.unwrap();
assert_eq!(score.measure_count(), before - 1);
}
#[test]
fn undo_restores_score() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
let before = score.settings.tempo_bpm;
stack
.execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
.unwrap();
assert_eq!(score.settings.tempo_bpm, 200);
stack.undo(&mut score).unwrap();
assert_eq!(score.settings.tempo_bpm, before);
}
#[test]
fn redo_reapplies_command() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
stack
.execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
.unwrap();
stack.undo(&mut score).unwrap();
stack.redo(&mut score).unwrap();
assert_eq!(score.settings.tempo_bpm, 200);
}
#[test]
fn undo_nothing_returns_error() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
assert!(stack.undo(&mut score).is_err());
}
#[test]
fn add_part_appends_part() {
let mut score = default_engine_score();
let before = score.parts.len();
apply_command(
&Command::AddPart(AddPartCmd {
name: "Violin".into(),
short_name: "Vln.".into(),
clefs: vec!["Treble".into()],
midi_channel: 0,
midi_program: 0,
}),
&mut score,
)
.unwrap();
assert_eq!(score.parts.len(), before + 1);
}
#[test]
fn delete_part_removes_part() {
let mut score = default_engine_score();
apply_command(
&Command::AddPart(AddPartCmd {
name: "Violin".into(),
short_name: "V.".into(),
clefs: vec!["Treble".into()],
midi_channel: 0,
midi_program: 0,
}),
&mut score,
)
.unwrap();
let before = score.parts.len();
apply_command(
&Command::DeletePart(DeletePartCmd { part_index: 0 }),
&mut score,
)
.unwrap();
assert_eq!(score.parts.len(), before - 1);
}
#[test]
fn delete_part_out_of_range_returns_err() {
let mut score = default_engine_score();
assert!(
apply_command(
&Command::DeletePart(DeletePartCmd { part_index: 99 }),
&mut score
)
.is_err()
);
}
#[test]
fn delete_part_undo_restores_part() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
apply_command(
&Command::AddPart(AddPartCmd {
name: "Violin".into(),
short_name: "V.".into(),
clefs: vec!["Treble".into()],
midi_channel: 0,
midi_program: 0,
}),
&mut score,
)
.unwrap();
let before = score.parts.len();
stack
.execute(
Command::DeletePart(DeletePartCmd { part_index: 0 }),
&mut score,
)
.unwrap();
assert_eq!(score.parts.len(), before - 1);
stack.undo(&mut score).unwrap();
assert_eq!(score.parts.len(), before);
}
#[test]
fn reorder_parts_remaps_linked_views_and_preserves_undo() {
let mut engine = crate::ScoreEngine::new();
engine
.apply(Command::AddPart(AddPartCmd {
name: "Flute".into(),
short_name: "Fl.".into(),
clefs: vec!["Treble".into()],
midi_channel: 1,
midi_program: 73,
}))
.unwrap();
engine
.score
.views
.push(ScoreView::linked_part("flute", "Flute", 1));
engine.score.part_groups.push(PartGroup {
first_part: 0,
last_part: 1,
symbol: super::super::score::PartGroupSymbol::Bracket,
barlines_connect: false,
});
engine
.apply(Command::ReorderParts(ReorderPartsCmd { order: vec![1, 0] }))
.unwrap();
assert_eq!(engine.score.parts[0].name, "Flute");
assert_eq!(engine.score.views[0].parts, vec![0]);
engine.undo().unwrap();
assert_eq!(engine.score.parts[0].name, "Piano");
assert_eq!(engine.score.views[0].parts, vec![1]);
}
#[test]
fn reorder_parts_rejects_splitting_a_part_group_without_mutation() {
let mut score = Score::template(ScoreTemplate::StringQuartet);
score.part_groups.push(PartGroup {
first_part: 0,
last_part: 1,
symbol: super::super::score::PartGroupSymbol::Bracket,
barlines_connect: false,
});
let before = serde_json::to_value(&score).unwrap();
assert!(
apply_command(
&Command::ReorderParts(ReorderPartsCmd {
order: vec![0, 2, 1, 3],
}),
&mut score,
)
.is_err()
);
assert_eq!(serde_json::to_value(&score).unwrap(), before);
}
#[test]
fn set_metadata_updates_title() {
let mut score = default_engine_score();
apply_command(
&Command::SetMetadata(SetMetadataCmd {
title: Some("New Title".into()),
..Default::default()
}),
&mut score,
)
.unwrap();
assert_eq!(score.metadata.title, "New Title");
}
#[test]
fn set_metadata_none_fields_skipped() {
let mut score = default_engine_score();
let original_composer = score.metadata.composer.clone();
apply_command(
&Command::SetMetadata(SetMetadataCmd {
title: Some("X".into()),
..Default::default()
}),
&mut score,
)
.unwrap();
assert_eq!(score.metadata.composer, original_composer);
}
#[test]
fn set_volta_sets_bracket() {
use crate::model::score::VoltaBracket;
let mut score = default_engine_score();
let volta = VoltaBracket {
number: 1,
kind: "begin_end".into(),
};
apply_command(
&Command::SetVolta(SetVoltaCmd {
measure_index: 0,
volta: Some(volta.clone()),
}),
&mut score,
)
.unwrap();
assert!(score.parts[0].staves[0].measures[0].volta.is_some());
}
#[test]
fn set_volta_none_clears_bracket() {
use crate::model::score::VoltaBracket;
let mut score = default_engine_score();
score.parts[0].staves[0].measures[0].volta = Some(VoltaBracket {
number: 1,
kind: "begin_end".into(),
});
apply_command(
&Command::SetVolta(SetVoltaCmd {
measure_index: 0,
volta: None,
}),
&mut score,
)
.unwrap();
assert!(score.parts[0].staves[0].measures[0].volta.is_none());
}
#[test]
fn set_volta_undo_restores_old() {
use crate::model::score::VoltaBracket;
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
stack
.execute(
Command::SetVolta(SetVoltaCmd {
measure_index: 0,
volta: Some(VoltaBracket {
number: 1,
kind: "begin_end".into(),
}),
}),
&mut score,
)
.unwrap();
stack.undo(&mut score).unwrap();
assert!(score.parts[0].staves[0].measures[0].volta.is_none());
}
#[test]
fn set_clef_updates_staff_clef() {
use crate::model::notation::Clef;
let mut score = default_engine_score();
apply_command(
&Command::SetClef(SetClefCmd {
part_index: 0,
staff_index: 0,
clef: Clef::Bass,
}),
&mut score,
)
.unwrap();
assert_eq!(score.parts[0].staves[0].clef, Clef::Bass);
}
#[test]
fn set_clef_out_of_range_returns_err() {
use crate::model::notation::Clef;
let mut score = default_engine_score();
assert!(
apply_command(
&Command::SetClef(SetClefCmd {
part_index: 99,
staff_index: 0,
clef: Clef::Bass,
}),
&mut score
)
.is_err()
);
}
#[test]
fn set_part_name_updates_name() {
let mut score = default_engine_score();
apply_command(
&Command::SetPartName(SetPartNameCmd {
part_index: 0,
name: "Violin".into(),
short_name: "Vln.".into(),
}),
&mut score,
)
.unwrap();
assert_eq!(score.parts[0].name, "Violin");
assert_eq!(score.parts[0].short_name, "Vln.");
}
#[test]
fn set_part_name_undo_restores_old() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
let original = score.parts[0].name.clone();
stack
.execute(
Command::SetPartName(SetPartNameCmd {
part_index: 0,
name: "Flute".into(),
short_name: "Fl.".into(),
}),
&mut score,
)
.unwrap();
stack.undo(&mut score).unwrap();
assert_eq!(score.parts[0].name, original);
}
#[test]
fn set_metadata_undo_restores_old_title() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
let original = score.metadata.title.clone();
stack
.execute(
Command::SetMetadata(SetMetadataCmd {
title: Some("Changed".into()),
..Default::default()
}),
&mut score,
)
.unwrap();
assert_ne!(score.metadata.title, original);
stack.undo(&mut score).unwrap();
assert_eq!(score.metadata.title, original);
}
#[test]
fn set_midi_instrument_updates_channel_and_program() {
let mut score = default_engine_score();
apply_command(
&Command::SetMidiInstrument(SetMidiInstrumentCmd {
part_index: 0,
midi_channel: 2,
midi_program: 40,
}),
&mut score,
)
.unwrap();
assert_eq!(score.parts[0].midi_channel, 2);
assert_eq!(score.parts[0].midi_program, 40);
}
#[test]
fn set_midi_instrument_clamps_channel_to_15() {
let mut score = default_engine_score();
apply_command(
&Command::SetMidiInstrument(SetMidiInstrumentCmd {
part_index: 0,
midi_channel: 20,
midi_program: 0,
}),
&mut score,
)
.unwrap();
assert_eq!(score.parts[0].midi_channel, 15);
}
#[test]
fn set_midi_instrument_undo_restores_old() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
score.parts[0].midi_channel = 3;
score.parts[0].midi_program = 10;
stack
.execute(
Command::SetMidiInstrument(SetMidiInstrumentCmd {
part_index: 0,
midi_channel: 9,
midi_program: 114,
}),
&mut score,
)
.unwrap();
stack.undo(&mut score).unwrap();
assert_eq!(score.parts[0].midi_channel, 3);
assert_eq!(score.parts[0].midi_program, 10);
}
#[test]
fn set_transpose_updates_staff() {
let mut score = default_engine_score();
apply_command(
&Command::SetTranspose(SetTransposeCmd {
part_index: 0,
staff_index: 0,
semitones: -2,
}),
&mut score,
)
.unwrap();
assert_eq!(score.parts[0].staves[0].transpose_semitones, -2);
}
#[test]
fn set_transpose_out_of_range_returns_err() {
let mut score = default_engine_score();
assert!(
apply_command(
&Command::SetTranspose(SetTransposeCmd {
part_index: 99,
staff_index: 0,
semitones: -2,
}),
&mut score
)
.is_err()
);
}
#[test]
fn transpose_staff_region_is_undoable_and_json_compatible() {
let mut stack = CommandStack::new(50);
let mut score = Score::new("Region", 120, 4, 4, 0, 2);
for measure in &mut score.parts[0].staves[0].measures {
measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
}
let command = Command::TransposeStaffRegion(TransposeStaffRegionCmd {
part_index: 0,
staff_index: 0,
start_measure: 1,
end_measure: 2,
semitones: 2,
target: RegionalTranspositionTarget::Written,
});
let json = serde_json::to_string(&command).expect("command serializes");
let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
assert_eq!(command_key(&decoded), "TransposeStaffRegion");
stack.execute(decoded, &mut score).expect("region applies");
assert_eq!(
score.parts[0].staves[0].measures[0].voices[0][0].pitches[0].to_midi(),
60
);
assert_eq!(
score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].to_midi(),
62
);
stack.undo(&mut score).expect("region undo applies");
assert_eq!(
score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].to_midi(),
60
);
}
#[test]
fn set_tempo_at_measure_sets_tempo() {
let mut score = default_engine_score();
apply_command(
&Command::SetTempoAtMeasure(SetTempoAtMeasureCmd {
measure_index: 0,
bpm: Some(80),
}),
&mut score,
)
.unwrap();
assert_eq!(score.parts[0].staves[0].measures[0].tempo, Some(80));
}
#[test]
fn set_tempo_at_measure_none_clears_tempo() {
let mut score = default_engine_score();
score.parts[0].staves[0].measures[0].tempo = Some(120);
apply_command(
&Command::SetTempoAtMeasure(SetTempoAtMeasureCmd {
measure_index: 0,
bpm: None,
}),
&mut score,
)
.unwrap();
assert!(score.parts[0].staves[0].measures[0].tempo.is_none());
}
#[test]
fn set_tempo_ramp_at_measure_applies() {
let mut score = default_engine_score();
apply_command(
&Command::SetTempoRampAtMeasure(SetTempoRampAtMeasureCmd {
measure_index: 0,
target_bpm: Some(72),
}),
&mut score,
)
.expect("tempo ramp applies");
assert_eq!(score.parts[0].staves[0].measures[0].tempo_ramp_to, Some(72));
}
#[test]
fn batch_execute_two_commands_single_undo() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
let original_bpm = score.settings.tempo_bpm;
stack
.batch_execute(
vec![
Command::SetTempo(SetTempoCmd { bpm: 160 }),
Command::SetTempo(SetTempoCmd { bpm: 180 }),
],
&mut score,
)
.unwrap();
assert_eq!(score.settings.tempo_bpm, 180);
stack.undo(&mut score).unwrap();
assert_eq!(score.settings.tempo_bpm, original_bpm);
}
#[test]
fn batch_execute_partial_failure_rollback() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
let original_bpm = score.settings.tempo_bpm;
let result = stack.batch_execute(
vec![
Command::SetTempo(SetTempoCmd { bpm: 160 }),
Command::DeleteNote(DeleteNoteCmd {
note_id: "nonexistent".into(),
part_index: 99,
staff_index: 0,
measure_index: 0,
voice: 0,
}),
],
&mut score,
);
assert!(result.is_err());
assert_eq!(score.settings.tempo_bpm, original_bpm);
}
#[test]
fn execute_rejects_invalid_candidate_without_mutating_or_recording_history() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
score.parts[0].staves[0].tablature = Some(crate::TablatureConfig {
lines: 6,
tuning_midi: vec![40, 45, 50, 55, 59, 64],
capo: 0,
});
stack
.execute(
Command::AddNote(AddNoteCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
position: 0,
pitch: Some(Pitch::new(Step::C, 4)),
duration: Duration::Quarter,
dot_count: 0,
is_rest: false,
tuplet: None,
}),
&mut score,
)
.expect("valid pitched note should be accepted");
let original = score.clone();
let result = stack.execute(
Command::SetTabPosition(SetTabPositionCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
note_index: 0,
position: Some(crate::TabPosition { string: 7, fret: 0 }),
}),
&mut score,
);
assert!(matches!(result, Err(Error::InvalidScore)));
let note = &score.parts[0].staves[0].measures[0].voices[0][0];
let original_note = &original.parts[0].staves[0].measures[0].voices[0][0];
assert_eq!(note.pitches, original_note.pitches);
assert_eq!(note.duration, original_note.duration);
assert_eq!(note.tab_position, original_note.tab_position);
assert_eq!(note.tab_positions, original_note.tab_positions);
assert!(stack.can_undo());
}
#[test]
fn set_tablature_config_is_undoable_and_json_compatible() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
let config = crate::TablatureConfig {
lines: 6,
tuning_midi: vec![40, 45, 50, 55, 59, 64],
capo: 2,
};
let command = Command::SetTablatureConfig(SetTablatureConfigCmd {
part_index: 0,
staff_index: 0,
config: Some(config.clone()),
});
let json = serde_json::to_string(&command).expect("command should serialize");
let decoded: Command = serde_json::from_str(&json).expect("command should deserialize");
assert_eq!(command_key(&decoded), "SetTablatureConfig");
stack
.execute(decoded, &mut score)
.expect("config should apply");
assert_eq!(score.parts[0].staves[0].tablature, Some(config));
assert_eq!(
score.parts[0].staves[0].presentation.kind,
StaffKind::Tablature
);
stack.undo(&mut score).expect("config undo should apply");
assert!(score.parts[0].staves[0].tablature.is_none());
assert_eq!(
score.parts[0].staves[0].presentation.kind,
StaffKind::Standard
);
stack.redo(&mut score).expect("config redo should apply");
assert_eq!(
score.parts[0].staves[0]
.tablature
.as_ref()
.map(|tab| tab.capo),
Some(2)
);
}
#[test]
fn set_staff_presentation_is_undoable_and_requires_tablature_config() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
let tab = StaffPresentation {
kind: StaffKind::Tablature,
lines: 6,
line_distance: 1.25,
small: true,
cutaway: false,
visible: true,
notehead_scheme: super::super::score::StaffNoteheadScheme::Standard,
tablature_rhythm_display: super::super::score::TablatureRhythmDisplay::FretOnly,
tablature_fret_mark_style: super::super::score::TablatureFretMarkStyle::Arabic,
};
let rejected = Command::SetStaffPresentation(SetStaffPresentationCmd {
part_index: 0,
staff_index: 0,
presentation: tab.clone(),
});
let before = score.clone();
assert!(stack.execute(rejected, &mut score).is_err());
assert_eq!(
score.parts[0].staves[0].presentation,
before.parts[0].staves[0].presentation
);
assert!(!stack.can_undo());
stack
.execute(
Command::SetTablatureConfig(SetTablatureConfigCmd {
part_index: 0,
staff_index: 0,
config: Some(crate::TablatureConfig {
lines: 6,
tuning_midi: vec![40, 45, 50, 55, 59, 64],
capo: 0,
}),
}),
&mut score,
)
.expect("tablature configuration should apply");
stack
.execute(
Command::SetStaffPresentation(SetStaffPresentationCmd {
part_index: 0,
staff_index: 0,
presentation: tab.clone(),
}),
&mut score,
)
.expect("staff presentation should apply");
assert_eq!(stack.undo_key().as_deref(), Some("SetStaffPresentation"));
assert_eq!(score.parts[0].staves[0].presentation, tab);
stack
.undo(&mut score)
.expect("presentation undo should apply");
assert_eq!(
score.parts[0].staves[0].presentation.kind,
StaffKind::Tablature
);
stack
.redo(&mut score)
.expect("presentation redo should apply");
assert_eq!(
score.parts[0].staves[0].presentation.kind,
StaffKind::Tablature
);
}
#[test]
fn set_instrument_definition_is_undoable_and_rejects_invalid_ranges() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
let mut invalid = InstrumentDefinition::new("violin", "Violin");
invalid.written_range = Some(InstrumentRange {
lowest: 100,
highest: 55,
});
let before = score.clone();
assert!(
stack
.execute(
Command::SetInstrumentDefinition(SetInstrumentDefinitionCmd {
part_index: 0,
definition: Some(invalid),
}),
&mut score,
)
.is_err()
);
assert_eq!(score.parts[0].instrument, before.parts[0].instrument);
let mut definition = InstrumentDefinition::new("violin", "Violin");
definition.short_name = "Vln.".to_string();
definition.family = Some("strings".to_string());
definition.written_range = Some(InstrumentRange {
lowest: 55,
highest: 103,
});
definition.sounding_range = definition.written_range;
definition.default_clefs = vec![Clef::Treble];
definition.midi_program = 40;
let command = Command::SetInstrumentDefinition(SetInstrumentDefinitionCmd {
part_index: 0,
definition: Some(definition.clone()),
});
let json = serde_json::to_string(&command).expect("command should serialize");
let decoded: Command = serde_json::from_str(&json).expect("command should deserialize");
assert_eq!(command_key(&decoded), "SetInstrumentDefinition");
stack
.execute(decoded, &mut score)
.expect("definition applies");
assert_eq!(score.parts[0].instrument, Some(definition));
stack.undo(&mut score).expect("definition undo applies");
assert!(score.parts[0].instrument.is_none());
stack.redo(&mut score).expect("definition redo applies");
assert_eq!(
score.parts[0]
.instrument
.as_ref()
.map(|value| value.id.as_str()),
Some("violin")
);
}
#[test]
fn set_percussion_kit_is_undoable_and_rejects_invalid_entries() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
let invalid = Command::SetPercussionKit(SetPercussionKitCmd {
part_index: 0,
instruments: vec![PercussionInstrument {
id: "snare".to_string(),
name: None,
midi_unpitched: Some(38),
staff_position: Some(33),
notehead: None,
preferred_voice: None,
techniques: Vec::new(),
}],
});
assert!(stack.execute(invalid, &mut score).is_err());
assert!(score.parts[0].percussion_instruments.is_empty());
let kit = vec![PercussionInstrument {
id: "snare".to_string(),
name: Some("Acoustic Snare".to_string()),
midi_unpitched: Some(38),
staff_position: Some(0),
notehead: Some(NoteHead::Cross),
preferred_voice: Some(1),
techniques: vec!["rim-shot".to_string()],
}];
let command = Command::SetPercussionKit(SetPercussionKitCmd {
part_index: 0,
instruments: kit.clone(),
});
let json = serde_json::to_string(&command).expect("command serializes");
let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
assert_eq!(command_key(&decoded), "SetPercussionKit");
stack.execute(decoded, &mut score).expect("kit applies");
assert_eq!(score.parts[0].percussion_instruments, kit);
stack.undo(&mut score).expect("kit undo applies");
assert!(score.parts[0].percussion_instruments.is_empty());
stack.redo(&mut score).expect("kit redo applies");
assert_eq!(score.parts[0].percussion_instruments, kit);
}
#[test]
fn set_measure_instrument_change_is_undoable_and_json_compatible() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
let mut definition = InstrumentDefinition::new("clarinet-bb", "B-flat Clarinet");
definition.transpose_semitones = -2;
definition.midi_program = 71;
let command = Command::SetMeasureInstrumentChange(SetMeasureInstrumentChangeCmd {
part_index: 0,
staff_index: 0,
measure_index: 2,
definition: Some(definition.clone()),
});
let json = serde_json::to_string(&command).expect("command should serialize");
let decoded: Command = serde_json::from_str(&json).expect("command should deserialize");
assert_eq!(command_key(&decoded), "SetMeasureInstrumentChange");
stack.execute(decoded, &mut score).expect("change applies");
assert_eq!(
score.parts[0].staves[0].measures[2].instrument_change,
Some(definition)
);
stack.undo(&mut score).expect("undo applies");
assert!(
score.parts[0].staves[0].measures[2]
.instrument_change
.is_none()
);
}
#[test]
fn set_measure_tablature_change_is_undoable_and_uses_base_line_count() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
let base = crate::TablatureConfig {
lines: 6,
tuning_midi: vec![40, 45, 50, 55, 59, 64],
capo: 0,
};
score.parts[0].staves[0].tablature = Some(base.clone());
let mut changed = base;
changed.tuning_midi[0] = 38;
changed.capo = 2;
let command = Command::SetMeasureTablatureChange(SetMeasureTablatureChangeCmd {
part_index: 0,
staff_index: 0,
measure_index: 2,
config: Some(changed.clone()),
});
let json = serde_json::to_string(&command).expect("command serializes");
let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
assert_eq!(command_key(&decoded), "SetMeasureTablatureChange");
stack.execute(decoded, &mut score).expect("change applies");
assert_eq!(
score.parts[0].staves[0].tablature_at(1),
score.parts[0].staves[0].tablature
);
assert_eq!(score.parts[0].staves[0].tablature_at(2), Some(changed));
stack.undo(&mut score).expect("change undo applies");
assert!(
score.parts[0].staves[0].measures[2]
.tablature_change
.is_none()
);
let invalid = Command::SetMeasureTablatureChange(SetMeasureTablatureChangeCmd {
part_index: 0,
staff_index: 0,
measure_index: 1,
config: Some(crate::TablatureConfig {
lines: 7,
tuning_midi: vec![40, 45, 50, 55, 59, 64, 69],
capo: 0,
}),
});
assert!(stack.execute(invalid, &mut score).is_err());
}
#[test]
fn batch_execute_empty_is_noop() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
stack.batch_execute(vec![], &mut score).unwrap();
assert!(!stack.can_undo());
}
#[test]
fn batch_label_used_as_command_key() {
let cmd = Command::Batch(BatchCmd {
commands: vec![],
label: Some("ApplyAI".to_string()),
});
assert_eq!(command_key(&cmd), "ApplyAI");
}
#[test]
fn batch_no_label_key_is_batch() {
let cmd = Command::Batch(BatchCmd {
commands: vec![],
label: None,
});
assert_eq!(command_key(&cmd), "Batch");
}
#[test]
fn batch_label_survives_json_roundtrip() {
let cmd = Command::Batch(BatchCmd {
commands: vec![Command::SetTempo(SetTempoCmd { bpm: 120 })],
label: Some("PasteSelection".to_string()),
});
let json = serde_json::to_string(&cmd).unwrap();
let cmd2: Command = serde_json::from_str(&json).unwrap();
assert_eq!(command_key(&cmd2), "PasteSelection");
}
#[test]
fn batch_label_in_undo_key() {
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
let cmd = Command::Batch(BatchCmd {
commands: vec![Command::SetTempo(SetTempoCmd { bpm: 140 })],
label: Some("ApplyAI".to_string()),
});
stack.execute(cmd, &mut score).unwrap();
assert_eq!(stack.undo_key(), Some("ApplyAI".to_string()));
}
#[test]
fn undo_returns_change_hint() {
use crate::model::change_hint::ChangeScope;
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
stack
.execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
.unwrap();
let hint = stack.undo(&mut score).unwrap();
assert_eq!(hint.scope, ChangeScope::Global);
assert!(hint.playback_dirty);
}
#[test]
fn redo_returns_change_hint() {
use crate::model::change_hint::ChangeScope;
let mut stack = CommandStack::new(50);
let mut score = default_engine_score();
stack
.execute(Command::SetTempo(SetTempoCmd { bpm: 200 }), &mut score)
.unwrap();
stack.undo(&mut score).unwrap();
let hint = stack.redo(&mut score).unwrap();
assert_eq!(hint.scope, ChangeScope::Global);
assert!(hint.playback_dirty);
}
#[test]
fn toggle_slur_sets_start_and_end() {
let mut score = default_engine_score();
let cmd = Command::AddNote(AddNoteCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
position: 0,
pitch: Some(Pitch::new(Step::C, 4)),
duration: Duration::Quarter,
dot_count: 0,
is_rest: false,
tuplet: None,
});
apply_command(&cmd, &mut score).unwrap();
apply_command(
&Command::AddNote(AddNoteCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
position: 1,
pitch: Some(Pitch::new(Step::D, 4)),
duration: Duration::Quarter,
dot_count: 0,
is_rest: false,
tuplet: None,
}),
&mut score,
)
.unwrap();
let start = NoteAddr {
part: 0,
staff: 0,
measure: 0,
voice: 0,
note: 0,
};
let end = NoteAddr {
part: 0,
staff: 0,
measure: 0,
voice: 0,
note: 1,
};
apply_command(
&Command::ToggleSlur(ToggleSlurCmd {
start: start.clone(),
end: end.clone(),
}),
&mut score,
)
.unwrap();
assert!(score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
assert!(score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
apply_command(
&Command::ToggleSlur(ToggleSlurCmd { start, end }),
&mut score,
)
.unwrap();
assert!(!score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
assert!(!score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
}
#[test]
fn add_staff_appends_staff_with_correct_measure_count() {
let mut score = default_engine_score();
let before = score.parts[0].staves.len();
let measure_count = score.parts[0].staves[0].measures.len();
apply_command(
&Command::AddStaff(AddStaffCmd {
part_index: 0,
clef: Clef::Bass,
}),
&mut score,
)
.unwrap();
assert_eq!(score.parts[0].staves.len(), before + 1);
let new_staff = score.parts[0].staves.last().unwrap();
assert_eq!(new_staff.measures.len(), measure_count);
}
#[test]
fn add_staff_out_of_range_returns_err() {
let mut score = default_engine_score();
let result = apply_command(
&Command::AddStaff(AddStaffCmd {
part_index: 99,
clef: Clef::Treble,
}),
&mut score,
);
assert!(result.is_err());
}
#[test]
fn delete_staff_removes_extra_staff() {
let mut score = default_engine_score();
apply_command(
&Command::AddStaff(AddStaffCmd {
part_index: 0,
clef: Clef::Bass,
}),
&mut score,
)
.unwrap();
assert_eq!(score.parts[0].staves.len(), 2);
apply_command(
&Command::DeleteStaff(DeleteStaffCmd {
part_index: 0,
staff_index: 1,
}),
&mut score,
)
.unwrap();
assert_eq!(score.parts[0].staves.len(), 1);
}
#[test]
fn delete_last_staff_returns_err() {
let mut score = default_engine_score();
assert_eq!(score.parts[0].staves.len(), 1);
let result = apply_command(
&Command::DeleteStaff(DeleteStaffCmd {
part_index: 0,
staff_index: 0,
}),
&mut score,
);
assert!(result.is_err());
}
#[test]
fn set_tuplet_assigns_and_clears() {
use crate::model::notation::TupletInfo;
let mut score = default_engine_score();
apply_command(
&Command::AddNote(AddNoteCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
position: 0,
pitch: Some(Pitch::new(Step::C, 4)),
duration: Duration::Quarter,
dot_count: 0,
is_rest: false,
tuplet: None,
}),
&mut score,
)
.unwrap();
let ti = TupletInfo {
actual_notes: 3,
normal_notes: 2,
};
apply_command(
&Command::SetTuplet(SetTupletCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice_index: 0,
note_index: 0,
tuplet: Some(ti.clone()),
}),
&mut score,
)
.unwrap();
assert_eq!(
score.parts[0].staves[0].measures[0].voices[0][0].tuplet,
Some(ti)
);
apply_command(
&Command::SetTuplet(SetTupletCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice_index: 0,
note_index: 0,
tuplet: None,
}),
&mut score,
)
.unwrap();
assert!(
score.parts[0].staves[0].measures[0].voices[0][0]
.tuplet
.is_none()
);
}
#[test]
fn respell_score_cmd_changes_all_pitches() {
use crate::model::pitch::Step;
let mut score = default_engine_score();
apply_command(
&Command::AddNote(AddNoteCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
position: 0,
pitch: Some(Pitch::with_alter(Step::C, 4, 1)), duration: Duration::Quarter,
dot_count: 0,
is_rest: false,
tuplet: None,
}),
&mut score,
)
.unwrap();
apply_command(
&Command::RespellScore(RespellScoreCmd { prefer_flat: true }),
&mut score,
)
.unwrap();
let pitch = &score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
assert_eq!(pitch.step, Step::D);
assert_eq!(pitch.alter, -1); }
fn respell_fixture(measures: u32) -> Score {
use crate::model::pitch::Step;
let mut score = Score::new("Respell", 120, 4, 4, 0, measures);
for measure in &mut score.parts[0].staves[0].measures {
measure.voices[0] = vec![Note::new(Pitch::with_alter(Step::C, 4, 1), Duration::Whole)];
}
score
}
fn spelled(score: &Score, measure: usize) -> (crate::model::pitch::Step, i8) {
let pitch = &score.parts[0].staves[0].measures[measure].voices[0][0].pitches[0];
(pitch.step.clone(), pitch.alter)
}
#[test]
fn respell_staff_region_changes_only_the_selection_and_is_undoable() {
use crate::model::pitch::Step;
let mut score = respell_fixture(4);
let mut stack = CommandStack::new(50);
let command = Command::RespellStaffRegion(RespellStaffRegionCmd {
part_index: 0,
staff_index: 0,
start_measure: 1,
end_measure: 3,
policy: RespellPolicy::Flat,
});
let json = serde_json::to_string(&command).expect("command serializes");
assert!(json.contains("\"policy\":\"flat\""));
let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
assert_eq!(command_key(&decoded), "RespellStaffRegion");
assert_eq!(command_label(&decoded), "Respell Pitches (flat)");
stack.execute(decoded, &mut score).expect("region respells");
assert_eq!(spelled(&score, 0), (Step::C, 1));
assert_eq!(spelled(&score, 1), (Step::D, -1));
assert_eq!(spelled(&score, 2), (Step::D, -1));
assert_eq!(spelled(&score, 3), (Step::C, 1));
stack.undo(&mut score).expect("region respell undoes");
assert!((0..4).all(|measure| spelled(&score, measure) == (Step::C, 1)));
stack.redo(&mut score).expect("region respell redoes");
assert_eq!(spelled(&score, 2), (Step::D, -1));
}
#[test]
fn respell_staff_region_keeps_ties_across_the_boundary_in_one_spelling() {
use crate::model::pitch::Step;
let mut score = respell_fixture(4);
{
let measures = &mut score.parts[0].staves[0].measures;
measures[0].voices[0][0].tie_start = true;
measures[1].voices[0][0].tie_end = true;
measures[1].voices[0][0].tie_start = true;
measures[2].voices[0][0].tie_end = true;
}
let changed = respell_staff_region(&mut score, 0, 0, 1, 2, RespellPolicy::Flat)
.expect("tied region respells");
assert_eq!(changed, (0, 2));
assert_eq!(spelled(&score, 0), (Step::D, -1));
assert_eq!(spelled(&score, 1), (Step::D, -1));
assert_eq!(spelled(&score, 2), (Step::D, -1));
assert_eq!(spelled(&score, 3), (Step::C, 1));
}
#[test]
fn respell_staff_region_follows_local_keys_and_skips_unpitched_notes() {
use crate::model::notation::KeySignature;
use crate::model::pitch::Step;
let mut score = respell_fixture(4);
for measure in &mut score.parts[0].staves[0].measures {
measure.voices[0][0].pitches[0] = Pitch::with_alter(Step::D, 4, -1);
}
score.parts[0].staves[0].measures[2].key_sig = Some(KeySignature {
fifths: -3,
mode: "major".into(),
});
score.parts[0].staves[0].measures[1].voices[0][0].is_unpitched = true;
respell_staff_region(&mut score, 0, 0, 0, 4, RespellPolicy::Key).expect("key respell");
assert_eq!(spelled(&score, 0), (Step::C, 1));
assert_eq!(
spelled(&score, 1),
(Step::D, -1),
"unpitched staff position is kept"
);
assert_eq!(spelled(&score, 2), (Step::D, -1));
assert_eq!(spelled(&score, 3), (Step::D, -1));
assert!(matches!(
respell_staff_region(&mut score, 0, 0, 2, 2, RespellPolicy::Sharp),
Err(Error::InvalidCommand(_))
));
assert!(matches!(
respell_staff_region(&mut score, 0, 5, 0, 1, RespellPolicy::Sharp),
Err(Error::StaffNotFound(5))
));
assert!(matches!(
respell_staff_region(&mut score, 0, 0, 0, 5, RespellPolicy::Sharp),
Err(Error::InvalidCommand(_))
));
}
#[test]
fn pickup_measure_length_limits_editing_and_survives_time_signature_changes() {
use crate::model::score::MeasureLength;
let mut score = Score::new("Pickup", 120, 4, 4, 0, 2);
score.parts[0].staves[0].measures[0].actual_length = Some(MeasureLength {
numerator: 1,
denominator: 4,
});
score.parts[0].staves[0].measures[0].voices[0] = vec![Note::rest(Duration::Quarter)];
assert!(validate(&score).is_valid());
assert_eq!(
crate::measure_beats_remaining(&score, 0, 0, 0, 0).expect("capacity"),
0.0
);
let mut stack = CommandStack::new(50);
stack
.execute(
Command::AddNote(AddNoteCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
position: 0,
pitch: Some(Pitch::new(Step::G, 4)),
duration: Duration::Quarter,
dot_count: 0,
is_rest: false,
tuplet: None,
}),
&mut score,
)
.expect("note fits the pickup after trimming");
let pickup = &score.parts[0].staves[0].measures[0].voices[0];
assert_eq!(pickup.len(), 1);
assert!(!pickup[0].is_rest);
stack
.execute(
Command::SetTimeSignature(SetTimeSignatureCmd {
numerator: 3,
denominator: 4,
}),
&mut score,
)
.expect("time signature changes");
let beats: f64 = score.parts[0].staves[0].measures[0].voices[0]
.iter()
.map(|note| note.beats())
.sum();
assert!((beats - 1.0).abs() < 1e-9, "the pickup keeps one beat");
score.parts[0].staves[0].measures[1].actual_length = Some(MeasureLength {
numerator: 0,
denominator: 4,
});
assert!(validate(&score).errors.iter().any(|error| matches!(
error,
crate::ValidationError::InvalidMeasureLength { measure: 1, .. }
)));
}
#[test]
fn set_lyric_addresses_verses_without_touching_verse_one() {
use crate::model::notation::VerseLyric;
let mut score = Score::new("Verses", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
let lyric = |text: &str| Lyric {
text: text.into(),
syllabic: "single".into(),
};
let set = |verse: Option<u8>, text: Option<&str>| {
Command::SetLyric(SetLyricCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
note_index: 0,
lyric: text.map(lyric),
verse,
})
};
let mut stack = CommandStack::new(50);
stack
.execute(set(None, Some("one")), &mut score)
.expect("verse 1");
stack
.execute(set(Some(3), Some("three")), &mut score)
.expect("verse 3");
stack
.execute(set(Some(2), Some("two")), &mut score)
.expect("verse 2");
stack
.execute(set(Some(2), Some("TWO")), &mut score)
.expect("replace verse 2");
let note = |score: &Score| score.parts[0].staves[0].measures[0].voices[0][0].clone();
assert_eq!(note(&score).lyric, Some(lyric("one")));
assert_eq!(
note(&score).additional_lyrics,
vec![
VerseLyric {
verse: 2,
lyric: lyric("TWO")
},
VerseLyric {
verse: 3,
lyric: lyric("three")
}
]
);
stack
.execute(set(Some(3), None), &mut score)
.expect("clear verse 3");
assert_eq!(note(&score).additional_lyrics.len(), 1);
stack.undo(&mut score).expect("clear undoes");
assert_eq!(note(&score).additional_lyrics.len(), 2);
let current = serde_json::to_string(&set(None, None)).expect("SetLyric serializes");
assert!(current.contains("\"verse\":null"));
let legacy: Command = serde_json::from_str(¤t.replace(",\"verse\":null", ""))
.expect("legacy SetLyric JSON");
stack
.execute(legacy, &mut score)
.expect("legacy clears verse 1");
assert_eq!(note(&score).lyric, None);
assert_eq!(note(&score).additional_lyrics.len(), 2);
assert!(matches!(
apply_command(&set(Some(0), Some("zero")), &mut score),
Err(Error::InvalidCommand(_))
));
assert!(matches!(
apply_command(&set(Some(33), Some("too far")), &mut score),
Err(Error::InvalidCommand(_))
));
score.parts[0].staves[0].measures[0].voices[0][0]
.additional_lyrics
.push(VerseLyric {
verse: 2,
lyric: lyric("duplicate"),
});
assert!(validate(&score).errors.iter().any(|error| matches!(
error,
crate::ValidationError::InvalidLyricVerse { verse: 2, .. }
)));
}
#[test]
fn cycle_enharmonic_spelling_targets_one_chord_member_and_undoes() {
let mut score = Score::new("Enharmonic", 120, 4, 4, 0, 1);
let mut chord = Note::new(Pitch::with_alter(Step::C, 4, 1), Duration::Whole);
chord.pitches.push(Pitch::with_alter(Step::G, 4, 1));
score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
let mut stack = CommandStack::new(50);
let command = Command::CycleEnharmonicSpelling(CycleEnharmonicSpellingCmd {
part_index: 0,
staff_index: 0,
measure_index: 0,
voice: 0,
note_index: 0,
pitch_index: Some(1),
});
let json = serde_json::to_string(&command).expect("command serializes");
let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
assert_eq!(command_key(&decoded), "CycleEnharmonicSpelling");
stack.execute(decoded, &mut score).expect("spelling cycles");
let pitches = |score: &Score| {
score.parts[0].staves[0].measures[0].voices[0][0]
.pitches
.clone()
};
assert_eq!(
pitches(&score),
vec![
Pitch::with_alter(Step::C, 4, 1),
Pitch::with_alter(Step::A, 4, -1)
]
);
stack.undo(&mut score).expect("spelling undoes");
assert_eq!(pitches(&score)[1], Pitch::with_alter(Step::G, 4, 1));
let mut rest_score = Score::new("Rest", 120, 4, 4, 0, 1);
rest_score.parts[0].staves[0].measures[0].voices[0] = vec![Note::rest(Duration::Whole)];
assert!(matches!(
apply_command(&command, &mut rest_score),
Err(Error::InvalidCommand(_))
));
}
#[test]
fn resequence_rehearsal_marks_continues_the_first_sequence() {
let mut score = Score::new("Marks", 120, 4, 4, 0, 30);
let mut stack = CommandStack::new(50);
for (index, text) in [(0, "A"), (4, "C"), (9, "C"), (12, "Q")] {
for_each_measure_at(&mut score, index, |measure| {
measure.rehearsal = Some(text.into())
});
}
let command = Command::ResequenceRehearsalMarks(ResequenceRehearsalMarksCmd::default());
let json = serde_json::to_string(&command).expect("command serializes");
let decoded: Command = serde_json::from_str(&json).expect("command deserializes");
assert_eq!(command_key(&decoded), "ResequenceRehearsalMarks");
stack
.execute(decoded, &mut score)
.expect("marks resequence");
let marks = |score: &Score| -> Vec<Option<String>> {
[0, 4, 9, 12]
.iter()
.map(|&index| score.parts[0].staves[0].measures[index].rehearsal.clone())
.collect()
};
assert_eq!(
marks(&score),
["A", "B", "C", "D"].map(|text| Some(text.to_string()))
);
stack.undo(&mut score).expect("resequence undoes");
assert_eq!(marks(&score)[1].as_deref(), Some("C"));
let sequence = RehearsalSequence::detect("Z", 1).expect("letters");
assert_eq!(sequence.nth(1, 1), "AA");
assert_eq!(sequence.nth(2, 1), "AB");
let lower = RehearsalSequence::detect("y", 1).expect("lower letters");
assert_eq!(lower.nth(2, 1), "aa");
assert_eq!(
RehearsalSequence::detect("7", 3)
.expect("numbers")
.nth(2, 9),
"9"
);
assert_eq!(
RehearsalSequence::detect("5", 5)
.expect("measure numbers")
.nth(3, 17),
"17"
);
assert!(RehearsalSequence::detect("Intro", 1).is_none());
for_each_measure_at(&mut score, 0, |measure| {
measure.rehearsal = Some("Intro".into())
});
assert!(matches!(
apply_command(&command, &mut score),
Err(Error::InvalidCommand(_))
));
}
#[test]
fn system_break_interval_replaces_line_breaks_only() {
let mut score = Score::new("Breaks", 120, 4, 4, 0, 10);
score.parts[0].staves[0].measures[1].system_break = true;
score.parts[0].staves[0].measures[2].page_break = true;
apply_command(
&Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
interval: 4,
start_measure: None,
end_measure: None,
}),
&mut score,
)
.expect("breaks every four measures");
let breaks: Vec<usize> = score.parts[0].staves[0]
.measures
.iter()
.enumerate()
.filter(|(_, measure)| measure.system_break)
.map(|(index, _)| index)
.collect();
assert_eq!(breaks, vec![3, 7]);
assert!(score.parts[0].staves[0].measures[2].page_break);
let mut exact = Score::new("Exact", 120, 4, 4, 0, 8);
apply_command(
&Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
interval: 4,
start_measure: None,
end_measure: None,
}),
&mut exact,
)
.expect("no break after the final measure");
assert!(!exact.parts[0].staves[0].measures[7].system_break);
assert!(exact.parts[0].staves[0].measures[3].system_break);
apply_command(
&Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
interval: 0,
start_measure: Some(0),
end_measure: Some(6),
}),
&mut score,
)
.expect("breaks removed in range");
assert!(!score.parts[0].staves[0].measures[3].system_break);
assert!(score.parts[0].staves[0].measures[7].system_break);
assert!(matches!(
apply_command(
&Command::SetSystemBreakInterval(SetSystemBreakIntervalCmd {
interval: 2,
start_measure: Some(4),
end_measure: Some(11),
}),
&mut score,
),
Err(Error::InvalidCommand(_))
));
}
#[test]
fn remove_trailing_empty_measures_keeps_content_and_final_barline() {
let mut score = Score::new("Trailing", 120, 4, 4, 0, 6);
let mut stack = CommandStack::new(50);
score.parts[0].staves[0].measures[1].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
score.parts[0].staves[0].measures[5].barline_right = Barline::Final;
let mut fermata_rest = Note::rest(Duration::Whole);
fermata_rest.articulations = vec![Articulation::Fermata];
score.parts[0].staves[0].measures[3].voices[0] = vec![fermata_rest];
stack
.execute(
Command::RemoveTrailingEmptyMeasures(RemoveTrailingEmptyMeasuresCmd {}),
&mut score,
)
.expect("trailing measures removed");
let staff = &score.parts[0].staves[0];
assert_eq!(staff.measures.len(), 4, "a rest with a fermata is content");
assert!(matches!(staff.measures[3].barline_right, Barline::Final));
assert_eq!(staff.measures[3].number, 4);
stack.undo(&mut score).expect("removal undoes");
assert_eq!(score.parts[0].staves[0].measures.len(), 6);
let mut empty = Score::new("Empty", 120, 4, 4, 0, 3);
apply_command(
&Command::RemoveTrailingEmptyMeasures(RemoveTrailingEmptyMeasuresCmd {}),
&mut empty,
)
.expect("empty score keeps one measure");
assert_eq!(empty.parts[0].staves[0].measures.len(), 1);
}
#[test]
fn typed_spanner_commands_are_atomic_and_undoable() {
use crate::model::score::{NotationSpanner, NotationSpannerKind};
let mut score = default_engine_score();
let address = NoteAddr {
part: 0,
staff: 0,
measure: 0,
voice: 0,
note: 0,
};
let spanner = NotationSpanner {
id: "slur-1".to_string(),
kind: NotationSpannerKind::Slur,
start: address.clone(),
end: address,
number: Some(1),
line_type: Some("dashed".to_string()),
text: None,
placement: Some("above".to_string()),
ottava_size: None,
ottava_type: None,
};
let mut stack = CommandStack::new(8);
stack
.execute(Command::AddSpanner(AddSpannerCmd { spanner }), &mut score)
.unwrap();
assert_eq!(score.spanners.len(), 1);
assert_eq!(stack.undo_key().as_deref(), Some("AddSpanner"));
let before_duplicate = score.clone();
let duplicate = score.spanners[0].clone();
assert!(
stack
.execute(
Command::AddSpanner(AddSpannerCmd { spanner: duplicate }),
&mut score,
)
.is_err()
);
assert_eq!(score.spanners, before_duplicate.spanners);
let mut updated = score.spanners[0].clone();
updated.number = Some(2);
stack
.execute(
Command::UpdateSpanner(UpdateSpannerCmd { spanner: updated }),
&mut score,
)
.unwrap();
assert_eq!(score.spanners[0].number, Some(2));
stack.undo(&mut score).unwrap();
assert_eq!(score.spanners[0].number, Some(1));
stack.redo(&mut score).unwrap();
assert_eq!(score.spanners[0].number, Some(2));
stack
.execute(
Command::RemoveSpanner(RemoveSpannerCmd {
id: "slur-1".to_string(),
}),
&mut score,
)
.unwrap();
assert!(score.spanners.is_empty());
stack.undo(&mut score).unwrap();
assert_eq!(score.spanners.len(), 1);
}
#[test]
fn string_quartet_linked_views_roundtrip_and_undo_redo_as_one_contract() {
let mut score = Score::template(ScoreTemplate::StringQuartet);
let mut stack = CommandStack::new(16);
for (part, id, name) in [
(0, "violin-1", "Violin I"),
(1, "violin-2", "Violin II"),
(2, "viola", "Viola"),
(3, "cello", "Cello"),
] {
stack
.execute(
Command::UpsertScoreView(UpsertScoreViewCmd {
view: ScoreView::linked_part(id, name, part),
}),
&mut score,
)
.expect("linked part view applies");
}
assert_eq!(score.parts.len(), 4);
assert_eq!(score.views.len(), 4);
for (part, view) in score.views.iter().enumerate() {
assert_eq!(view.parts, vec![part]);
assert_eq!(score.resolve_view(&view.id).unwrap().parts.len(), 1);
}
let restored: Score =
serde_json::from_str(&serde_json::to_string(&score).expect("quartet score serializes"))
.expect("quartet score deserializes");
assert_eq!(restored.views, score.views);
for _ in 0..4 {
stack.undo(&mut score).expect("view undo applies");
}
assert!(score.views.is_empty());
for _ in 0..4 {
stack.redo(&mut score).expect("view redo applies");
}
assert_eq!(score.views, restored.views);
}
#[test]
fn section_break_is_atomic_undoable_and_synced_across_staves() {
let mut score = Score::template(ScoreTemplate::Piano);
let original = score.clone();
let mut stack = CommandStack::new(4);
stack
.execute(
Command::SetSectionBreak(SetSectionBreakCmd {
measure_index: 2,
value: true,
}),
&mut score,
)
.expect("section break applies");
assert!(
score
.parts
.iter()
.flat_map(|part| part.staves.iter())
.all(|staff| staff.measures[2].section_break)
);
stack.undo(&mut score).expect("section break undoes");
assert_eq!(
serde_json::to_value(&score).unwrap(),
serde_json::to_value(&original).unwrap()
);
assert!(
stack
.execute(
Command::SetSectionBreak(SetSectionBreakCmd {
measure_index: 99,
value: true,
}),
&mut score,
)
.is_err()
);
assert_eq!(
serde_json::to_value(&score).unwrap(),
serde_json::to_value(&original).unwrap()
);
}
}