Skip to main content

acorde_core/model/
fragment.rs

1//! Versioned, host-neutral score fragments for copy and paste workflows.
2//!
3//! Clipboard transport and selection UI belong to a host.  This module keeps
4//! the musical snapshot, source voice numbers, and typed-spanner boundaries in
5//! the score model so a host does not need to flatten notation into renderer
6//! objects before copying it.
7
8use super::notation::{
9    Barline, Clef, FiguredBassFigure, KeySignature, StyledText, TablatureConfig, TimeSignature,
10};
11use super::score::{
12    HarpPedalDiagram, InstrumentDefinition, Measure, NotationSpanner, Note, NoteAddr, Score,
13    VoltaBracket,
14};
15use crate::Error;
16use serde::{Deserialize, Serialize};
17use std::collections::BTreeSet;
18
19/// Current schema version for [`ScoreFragment`].
20pub const SCORE_FRAGMENT_CONTRACT_VERSION: u16 = 3;
21/// Oldest fragment version accepted by the current paste contract.
22pub const MIN_SUPPORTED_SCORE_FRAGMENT_CONTRACT_VERSION: u16 = 1;
23
24/// One inclusive, whole-measure voice range to extract.
25///
26/// The note indexes are retained as source provenance, but range boundaries
27/// are measure based.  Partial-note selection is a host/UI concern and cannot
28/// be represented without splitting rhythmic values.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct ScoreFragmentSelection {
31    pub start: NoteAddr,
32    pub end: NoteAddr,
33}
34
35/// A portable score snapshot.  All addresses in `voices` and `spanners` are
36/// relative to the selection's lowest part, staff and measure indexes.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct ScoreFragment {
39    pub contract_version: u16,
40    #[serde(default)]
41    pub voices: Vec<ScoreFragmentVoice>,
42    #[serde(default)]
43    pub spanners: Vec<NotationSpanner>,
44    #[serde(default)]
45    pub diagnostics: Vec<ScoreFragmentDiagnostic>,
46}
47
48/// Music in one relative voice lane.  The note values remain exact clones of
49/// the score model, retaining lyrics, tuplets, grace/cue state, articulations,
50/// chord symbols and placement data.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ScoreFragmentVoice {
53    pub relative_part: usize,
54    pub relative_staff: usize,
55    pub relative_voice: usize,
56    #[serde(default)]
57    pub measures: Vec<ScoreFragmentMeasure>,
58}
59
60/// One measure in a fragment voice lane.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ScoreFragmentMeasure {
63    pub relative_measure: usize,
64    /// Original positive MusicXML voice number, when imported from a sparse
65    /// source voice.  Keeping this per measure avoids flattening cursor
66    /// semantics on paste.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub source_voice_number: Option<u32>,
69    #[serde(default)]
70    pub notes: Vec<Note>,
71    /// Cross-staff targets expressed relative to this lane's source staff.
72    /// An empty vector denotes a v1 fragment with no remappable targets.
73    #[serde(default)]
74    pub cross_staff_targets: Vec<Option<ScoreFragmentCrossStaffTarget>>,
75    /// Staff-local measure attributes captured alongside this voice lane.
76    /// v1/v2 payloads deserialize with `present: false` and therefore retain
77    /// their historical notes-only paste behavior.
78    #[serde(default)]
79    pub attributes: ScoreFragmentMeasureAttributes,
80}
81
82/// Measure-level semantics carried by a v3 score fragment.
83///
84/// Measure numbers are deliberately not copied: the destination physical
85/// position owns numbering. All other fields map directly to `Measure` so a
86/// host does not need to rebuild staff-local time, key, instrument, or text
87/// state around the pasted music.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
89pub struct ScoreFragmentMeasureAttributes {
90    #[serde(default)]
91    pub present: bool,
92    #[serde(default)]
93    pub time_sig: Option<TimeSignature>,
94    #[serde(default)]
95    pub key_sig: Option<KeySignature>,
96    #[serde(default)]
97    pub clef: Option<Clef>,
98    #[serde(default)]
99    pub tempo: Option<u16>,
100    #[serde(default)]
101    pub tempo_ramp_to: Option<u16>,
102    #[serde(default)]
103    pub instrument_change: Option<InstrumentDefinition>,
104    #[serde(default)]
105    pub tablature_change: Option<TablatureConfig>,
106    #[serde(default)]
107    pub barline_left: Option<Barline>,
108    #[serde(default)]
109    pub barline_right: Option<Barline>,
110    #[serde(default)]
111    pub volta: Option<VoltaBracket>,
112    #[serde(default)]
113    pub tempo_text: Option<String>,
114    #[serde(default)]
115    pub rehearsal: Option<String>,
116    #[serde(default)]
117    pub navigation: Option<String>,
118    #[serde(default)]
119    pub expression_text: Option<String>,
120    #[serde(default)]
121    pub texts: Vec<StyledText>,
122    #[serde(default)]
123    pub figured_bass: Vec<FiguredBassFigure>,
124    #[serde(default)]
125    pub harp_pedal_diagrams: Vec<HarpPedalDiagram>,
126    #[serde(default)]
127    pub multi_rest_count: Option<u8>,
128    #[serde(default)]
129    pub system_break: bool,
130    #[serde(default)]
131    pub page_break: bool,
132    #[serde(default)]
133    pub section_break: bool,
134}
135
136impl ScoreFragmentMeasureAttributes {
137    pub(crate) fn from_measure(measure: &Measure) -> Self {
138        Self {
139            present: true,
140            time_sig: measure.time_sig.clone(),
141            key_sig: measure.key_sig.clone(),
142            clef: measure.clef.clone(),
143            tempo: measure.tempo,
144            tempo_ramp_to: measure.tempo_ramp_to,
145            instrument_change: measure.instrument_change.clone(),
146            tablature_change: measure.tablature_change.clone(),
147            barline_left: Some(measure.barline_left.clone()),
148            barline_right: Some(measure.barline_right.clone()),
149            volta: measure.volta.clone(),
150            tempo_text: measure.tempo_text.clone(),
151            rehearsal: measure.rehearsal.clone(),
152            navigation: measure.navigation.clone(),
153            expression_text: measure.expression_text.clone(),
154            texts: measure.texts.clone(),
155            figured_bass: measure.figured_bass.clone(),
156            harp_pedal_diagrams: measure.harp_pedal_diagrams.clone(),
157            multi_rest_count: measure.multi_rest_count,
158            system_break: measure.system_break,
159            page_break: measure.page_break,
160            section_break: measure.section_break,
161        }
162    }
163
164    /// Apply captured attributes without changing the destination's physical
165    /// measure number, voices, or source voice-number slots.
166    pub fn apply_to_measure(&self, measure: &mut Measure) {
167        if !self.present {
168            return;
169        }
170        measure.time_sig = self.time_sig.clone();
171        measure.key_sig = self.key_sig.clone();
172        measure.clef = self.clef.clone();
173        measure.tempo = self.tempo;
174        measure.tempo_ramp_to = self.tempo_ramp_to;
175        measure.instrument_change = self.instrument_change.clone();
176        measure.tablature_change = self.tablature_change.clone();
177        if let Some(barline) = &self.barline_left {
178            measure.barline_left = barline.clone();
179        }
180        if let Some(barline) = &self.barline_right {
181            measure.barline_right = barline.clone();
182        }
183        measure.volta = self.volta.clone();
184        measure.tempo_text = self.tempo_text.clone();
185        measure.rehearsal = self.rehearsal.clone();
186        measure.navigation = self.navigation.clone();
187        measure.expression_text = self.expression_text.clone();
188        measure.texts = self.texts.clone();
189        measure.figured_bass = self.figured_bass.clone();
190        measure.harp_pedal_diagrams = self.harp_pedal_diagrams.clone();
191        measure.multi_rest_count = self.multi_rest_count;
192        measure.system_break = self.system_break;
193        measure.page_break = self.page_break;
194        measure.section_break = self.section_break;
195    }
196}
197
198/// A cross-staff target carried independently from renderer-oriented note data.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200pub struct ScoreFragmentCrossStaffTarget {
201    pub staff_offset: i64,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub target_voice: Option<usize>,
204}
205
206/// Loss or policy decision made while extracting a fragment.
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "kebab-case")]
209pub enum ScoreFragmentDiagnostic {
210    /// Exactly one endpoint of a typed spanner was selected, so it is not
211    /// copied as an orphaned span.
212    PartialSpanner { id: String },
213}
214
215/// Extract a deterministic multi-voice, multi-staff fragment.
216///
217/// Every selection must cover one voice on one staff, use inclusive measure
218/// bounds, and selections may not overlap.  Fully selected typed spanners are
219/// copied with relative endpoints; partial spanners are diagnosed explicitly.
220pub fn extract_score_fragment(
221    score: &Score,
222    selections: &[ScoreFragmentSelection],
223) -> Result<ScoreFragment, Error> {
224    if selections.is_empty() {
225        return Err(Error::InvalidCommand(
226            "score fragment requires at least one voice selection".into(),
227        ));
228    }
229
230    let base_part = selections
231        .iter()
232        .map(|selection| selection.start.part)
233        .min()
234        .ok_or_else(|| Error::InvalidCommand("score fragment requires selections".into()))?;
235    let base_staff = selections
236        .iter()
237        .map(|selection| selection.start.staff)
238        .min()
239        .ok_or_else(|| Error::InvalidCommand("score fragment requires selections".into()))?;
240    let base_measure = selections
241        .iter()
242        .map(|selection| selection.start.measure.min(selection.end.measure))
243        .min()
244        .ok_or_else(|| Error::InvalidCommand("score fragment requires selections".into()))?;
245    let base_voice = selections
246        .iter()
247        .map(|selection| selection.start.voice)
248        .min()
249        .ok_or_else(|| Error::InvalidCommand("score fragment requires selections".into()))?;
250
251    let mut seen_lanes = BTreeSet::new();
252    let mut source_to_relative = Vec::new();
253    let mut voices = Vec::with_capacity(selections.len());
254    for selection in selections {
255        if selection.start.part != selection.end.part
256            || selection.start.staff != selection.end.staff
257            || selection.start.voice != selection.end.voice
258        {
259            return Err(Error::InvalidCommand(
260                "score fragment selection endpoints must share part, staff, and voice".into(),
261            ));
262        }
263        let lane = (
264            selection.start.part,
265            selection.start.staff,
266            selection.start.voice,
267        );
268        if !seen_lanes.insert(lane) {
269            return Err(Error::InvalidCommand(
270                "score fragment selections must not overlap a voice lane".into(),
271            ));
272        }
273        let part = score
274            .parts
275            .get(selection.start.part)
276            .ok_or(Error::PartNotFound(selection.start.part))?;
277        let staff = part
278            .staves
279            .get(selection.start.staff)
280            .ok_or(Error::StaffNotFound(selection.start.staff))?;
281        if selection.start.voice >= 4 {
282            return Err(Error::VoiceOutOfRange(selection.start.voice));
283        }
284        let from = selection.start.measure.min(selection.end.measure);
285        let to = selection.start.measure.max(selection.end.measure);
286        let mut measures = Vec::with_capacity(to - from + 1);
287        for measure_index in from..=to {
288            let measure = staff
289                .measures
290                .get(measure_index)
291                .ok_or(Error::MeasureNotFound(measure_index))?;
292            let relative = NoteAddr {
293                part: selection.start.part - base_part,
294                staff: selection.start.staff - base_staff,
295                measure: measure_index - base_measure,
296                voice: selection.start.voice - base_voice,
297                note: 0,
298            };
299            for note_index in 0..measure.voices[selection.start.voice].len() {
300                source_to_relative.push((
301                    NoteAddr {
302                        part: selection.start.part,
303                        staff: selection.start.staff,
304                        measure: measure_index,
305                        voice: selection.start.voice,
306                        note: note_index,
307                    },
308                    NoteAddr {
309                        note: note_index,
310                        ..relative.clone()
311                    },
312                ));
313            }
314            let notes = measure.voices[selection.start.voice].clone();
315            let cross_staff_targets = notes
316                .iter()
317                .map(|note| {
318                    note.cross_staff
319                        .as_ref()
320                        .map(|cross_staff| ScoreFragmentCrossStaffTarget {
321                            staff_offset: cross_staff.target_staff as i64
322                                - selection.start.staff as i64,
323                            target_voice: cross_staff.target_voice,
324                        })
325                })
326                .collect();
327            measures.push(ScoreFragmentMeasure {
328                relative_measure: measure_index - base_measure,
329                source_voice_number: measure.source_voice_numbers[selection.start.voice],
330                notes,
331                cross_staff_targets,
332                attributes: ScoreFragmentMeasureAttributes::from_measure(measure),
333            });
334        }
335        voices.push(ScoreFragmentVoice {
336            relative_part: selection.start.part - base_part,
337            relative_staff: selection.start.staff - base_staff,
338            relative_voice: selection.start.voice - base_voice,
339            measures,
340        });
341    }
342    voices.sort_by_key(|voice| {
343        (
344            voice.relative_part,
345            voice.relative_staff,
346            voice.relative_voice,
347        )
348    });
349
350    let mut diagnostics = Vec::new();
351    let mut spanners = Vec::new();
352    for spanner in &score.spanners {
353        let start = source_to_relative
354            .iter()
355            .find(|(source, _)| source == &spanner.start)
356            .map(|(_, relative)| relative);
357        let end = source_to_relative
358            .iter()
359            .find(|(source, _)| source == &spanner.end)
360            .map(|(_, relative)| relative);
361        match (start, end) {
362            (Some(start), Some(end)) => {
363                let mut copied = spanner.clone();
364                copied.start = start.clone();
365                copied.end = end.clone();
366                spanners.push(copied);
367            }
368            (Some(_), None) | (None, Some(_)) => {
369                diagnostics.push(ScoreFragmentDiagnostic::PartialSpanner {
370                    id: spanner.id.clone(),
371                });
372            }
373            (None, None) => {}
374        }
375    }
376    spanners.sort_by(|left, right| left.id.cmp(&right.id));
377    diagnostics.sort_by(|left, right| match (left, right) {
378        (
379            ScoreFragmentDiagnostic::PartialSpanner { id: left },
380            ScoreFragmentDiagnostic::PartialSpanner { id: right },
381        ) => left.cmp(right),
382    });
383
384    Ok(ScoreFragment {
385        contract_version: SCORE_FRAGMENT_CONTRACT_VERSION,
386        voices,
387        spanners,
388        diagnostics,
389    })
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use crate::{Duration, NotationSpannerKind, Pitch, Step};
396
397    fn address(staff: usize, measure: usize, voice: usize, note: usize) -> NoteAddr {
398        NoteAddr {
399            part: 0,
400            staff,
401            measure,
402            voice,
403            note,
404        }
405    }
406
407    #[test]
408    fn extracts_multivoice_fragment_and_keeps_only_complete_spanners() {
409        let mut score = Score::template(crate::ScoreTemplate::Piano);
410        for staff in &mut score.parts[0].staves {
411            for measure in &mut staff.measures {
412                measure.voices[0] =
413                    vec![crate::Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
414            }
415        }
416        score.parts[0].staves[0].measures[0].source_voice_numbers[0] = Some(5);
417        score.spanners = vec![
418            NotationSpanner {
419                id: "complete".into(),
420                kind: NotationSpannerKind::Slur,
421                start: address(0, 0, 0, 0),
422                end: address(1, 1, 0, 0),
423                number: None,
424                placement: None,
425                line_type: None,
426                ottava_size: None,
427                ottava_type: None,
428                text: None,
429            },
430            NotationSpanner {
431                id: "partial".into(),
432                kind: NotationSpannerKind::Slur,
433                start: address(0, 0, 0, 0),
434                end: address(0, 1, 0, 0),
435                number: None,
436                placement: None,
437                line_type: None,
438                ottava_size: None,
439                ottava_type: None,
440                text: None,
441            },
442        ];
443        let fragment = extract_score_fragment(
444            &score,
445            &[
446                ScoreFragmentSelection {
447                    start: address(0, 0, 0, 0),
448                    end: address(0, 0, 0, 0),
449                },
450                ScoreFragmentSelection {
451                    start: address(1, 1, 0, 0),
452                    end: address(1, 1, 0, 0),
453                },
454            ],
455        )
456        .expect("fragment extracts");
457
458        assert_eq!(fragment.contract_version, SCORE_FRAGMENT_CONTRACT_VERSION);
459        assert_eq!(fragment.voices.len(), 2);
460        assert_eq!(fragment.voices[0].measures[0].source_voice_number, Some(5));
461        assert_eq!(fragment.spanners.len(), 1);
462        assert_eq!(fragment.spanners[0].id, "complete");
463        assert_eq!(fragment.spanners[0].start.staff, 0);
464        assert_eq!(fragment.spanners[0].end.staff, 1);
465        assert_eq!(fragment.spanners[0].end.measure, 1);
466        assert_eq!(
467            fragment.diagnostics,
468            vec![ScoreFragmentDiagnostic::PartialSpanner {
469                id: "partial".into()
470            }]
471        );
472    }
473}