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, MidMeasureClef, NotationSpanner, Note,
13    NoteAddr, Score, 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, skip_serializing_if = "Vec::is_empty")]
99    pub mid_clefs: Vec<MidMeasureClef>,
100    #[serde(default)]
101    pub tempo: Option<u16>,
102    #[serde(default)]
103    pub tempo_ramp_to: Option<u16>,
104    #[serde(default)]
105    pub instrument_change: Option<InstrumentDefinition>,
106    #[serde(default)]
107    pub tablature_change: Option<TablatureConfig>,
108    #[serde(default)]
109    pub barline_left: Option<Barline>,
110    #[serde(default)]
111    pub barline_right: Option<Barline>,
112    #[serde(default)]
113    pub volta: Option<VoltaBracket>,
114    #[serde(default)]
115    pub tempo_text: Option<String>,
116    #[serde(default)]
117    pub rehearsal: Option<String>,
118    #[serde(default)]
119    pub navigation: Option<String>,
120    #[serde(default)]
121    pub expression_text: Option<String>,
122    #[serde(default)]
123    pub texts: Vec<StyledText>,
124    #[serde(default)]
125    pub figured_bass: Vec<FiguredBassFigure>,
126    #[serde(default)]
127    pub harp_pedal_diagrams: Vec<HarpPedalDiagram>,
128    #[serde(default)]
129    pub multi_rest_count: Option<u8>,
130    #[serde(default)]
131    pub system_break: bool,
132    #[serde(default)]
133    pub page_break: bool,
134    #[serde(default)]
135    pub section_break: bool,
136}
137
138impl ScoreFragmentMeasureAttributes {
139    pub(crate) fn from_measure(measure: &Measure) -> Self {
140        Self {
141            present: true,
142            time_sig: measure.time_sig.clone(),
143            key_sig: measure.key_sig.clone(),
144            clef: measure.clef.clone(),
145            mid_clefs: measure.mid_clefs.clone(),
146            tempo: measure.tempo,
147            tempo_ramp_to: measure.tempo_ramp_to,
148            instrument_change: measure.instrument_change.clone(),
149            tablature_change: measure.tablature_change.clone(),
150            barline_left: Some(measure.barline_left.clone()),
151            barline_right: Some(measure.barline_right.clone()),
152            volta: measure.volta.clone(),
153            tempo_text: measure.tempo_text.clone(),
154            rehearsal: measure.rehearsal.clone(),
155            navigation: measure.navigation.clone(),
156            expression_text: measure.expression_text.clone(),
157            texts: measure.texts.clone(),
158            figured_bass: measure.figured_bass.clone(),
159            harp_pedal_diagrams: measure.harp_pedal_diagrams.clone(),
160            multi_rest_count: measure.multi_rest_count,
161            system_break: measure.system_break,
162            page_break: measure.page_break,
163            section_break: measure.section_break,
164        }
165    }
166
167    /// Apply captured attributes without changing the destination's physical
168    /// measure number, voices, or source voice-number slots.
169    pub fn apply_to_measure(&self, measure: &mut Measure) {
170        if !self.present {
171            return;
172        }
173        measure.time_sig = self.time_sig.clone();
174        measure.key_sig = self.key_sig.clone();
175        measure.clef = self.clef.clone();
176        measure.mid_clefs = self.mid_clefs.clone();
177        measure.tempo = self.tempo;
178        measure.tempo_ramp_to = self.tempo_ramp_to;
179        measure.instrument_change = self.instrument_change.clone();
180        measure.tablature_change = self.tablature_change.clone();
181        if let Some(barline) = &self.barline_left {
182            measure.barline_left = barline.clone();
183        }
184        if let Some(barline) = &self.barline_right {
185            measure.barline_right = barline.clone();
186        }
187        measure.volta = self.volta.clone();
188        measure.tempo_text = self.tempo_text.clone();
189        measure.rehearsal = self.rehearsal.clone();
190        measure.navigation = self.navigation.clone();
191        measure.expression_text = self.expression_text.clone();
192        measure.texts = self.texts.clone();
193        measure.figured_bass = self.figured_bass.clone();
194        measure.harp_pedal_diagrams = self.harp_pedal_diagrams.clone();
195        measure.multi_rest_count = self.multi_rest_count;
196        measure.system_break = self.system_break;
197        measure.page_break = self.page_break;
198        measure.section_break = self.section_break;
199    }
200}
201
202/// A cross-staff target carried independently from renderer-oriented note data.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct ScoreFragmentCrossStaffTarget {
205    pub staff_offset: i64,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub target_voice: Option<usize>,
208}
209
210/// Loss or policy decision made while extracting a fragment.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(rename_all = "kebab-case")]
213pub enum ScoreFragmentDiagnostic {
214    /// Exactly one endpoint of a typed spanner was selected, so it is not
215    /// copied as an orphaned span.
216    PartialSpanner { id: String },
217}
218
219/// Extract a deterministic multi-voice, multi-staff fragment.
220///
221/// Every selection must cover one voice on one staff, use inclusive measure
222/// bounds, and selections may not overlap.  Fully selected typed spanners are
223/// copied with relative endpoints; partial spanners are diagnosed explicitly.
224pub fn extract_score_fragment(
225    score: &Score,
226    selections: &[ScoreFragmentSelection],
227) -> Result<ScoreFragment, Error> {
228    if selections.is_empty() {
229        return Err(Error::InvalidCommand(
230            "score fragment requires at least one voice selection".into(),
231        ));
232    }
233
234    let base_part = selections
235        .iter()
236        .map(|selection| selection.start.part)
237        .min()
238        .ok_or_else(|| Error::InvalidCommand("score fragment requires selections".into()))?;
239    let base_staff = selections
240        .iter()
241        .map(|selection| selection.start.staff)
242        .min()
243        .ok_or_else(|| Error::InvalidCommand("score fragment requires selections".into()))?;
244    let base_measure = selections
245        .iter()
246        .map(|selection| selection.start.measure.min(selection.end.measure))
247        .min()
248        .ok_or_else(|| Error::InvalidCommand("score fragment requires selections".into()))?;
249    let base_voice = selections
250        .iter()
251        .map(|selection| selection.start.voice)
252        .min()
253        .ok_or_else(|| Error::InvalidCommand("score fragment requires selections".into()))?;
254
255    let mut seen_lanes = BTreeSet::new();
256    let mut source_to_relative = Vec::new();
257    let mut voices = Vec::with_capacity(selections.len());
258    for selection in selections {
259        if selection.start.part != selection.end.part
260            || selection.start.staff != selection.end.staff
261            || selection.start.voice != selection.end.voice
262        {
263            return Err(Error::InvalidCommand(
264                "score fragment selection endpoints must share part, staff, and voice".into(),
265            ));
266        }
267        let lane = (
268            selection.start.part,
269            selection.start.staff,
270            selection.start.voice,
271        );
272        if !seen_lanes.insert(lane) {
273            return Err(Error::InvalidCommand(
274                "score fragment selections must not overlap a voice lane".into(),
275            ));
276        }
277        let part = score
278            .parts
279            .get(selection.start.part)
280            .ok_or(Error::PartNotFound(selection.start.part))?;
281        let staff = part
282            .staves
283            .get(selection.start.staff)
284            .ok_or(Error::StaffNotFound(selection.start.staff))?;
285        if selection.start.voice >= 4 {
286            return Err(Error::VoiceOutOfRange(selection.start.voice));
287        }
288        let from = selection.start.measure.min(selection.end.measure);
289        let to = selection.start.measure.max(selection.end.measure);
290        let mut measures = Vec::with_capacity(to - from + 1);
291        for measure_index in from..=to {
292            let measure = staff
293                .measures
294                .get(measure_index)
295                .ok_or(Error::MeasureNotFound(measure_index))?;
296            let relative = NoteAddr {
297                part: selection.start.part - base_part,
298                staff: selection.start.staff - base_staff,
299                measure: measure_index - base_measure,
300                voice: selection.start.voice - base_voice,
301                note: 0,
302            };
303            for note_index in 0..measure.voices[selection.start.voice].len() {
304                source_to_relative.push((
305                    NoteAddr {
306                        part: selection.start.part,
307                        staff: selection.start.staff,
308                        measure: measure_index,
309                        voice: selection.start.voice,
310                        note: note_index,
311                    },
312                    NoteAddr {
313                        note: note_index,
314                        ..relative.clone()
315                    },
316                ));
317            }
318            let notes = measure.voices[selection.start.voice].clone();
319            let cross_staff_targets = notes
320                .iter()
321                .map(|note| {
322                    note.cross_staff
323                        .as_ref()
324                        .map(|cross_staff| ScoreFragmentCrossStaffTarget {
325                            staff_offset: cross_staff.target_staff as i64
326                                - selection.start.staff as i64,
327                            target_voice: cross_staff.target_voice,
328                        })
329                })
330                .collect();
331            measures.push(ScoreFragmentMeasure {
332                relative_measure: measure_index - base_measure,
333                source_voice_number: measure.source_voice_numbers[selection.start.voice],
334                notes,
335                cross_staff_targets,
336                attributes: ScoreFragmentMeasureAttributes::from_measure(measure),
337            });
338        }
339        voices.push(ScoreFragmentVoice {
340            relative_part: selection.start.part - base_part,
341            relative_staff: selection.start.staff - base_staff,
342            relative_voice: selection.start.voice - base_voice,
343            measures,
344        });
345    }
346    voices.sort_by_key(|voice| {
347        (
348            voice.relative_part,
349            voice.relative_staff,
350            voice.relative_voice,
351        )
352    });
353
354    let mut diagnostics = Vec::new();
355    let mut spanners = Vec::new();
356    for spanner in &score.spanners {
357        let start = source_to_relative
358            .iter()
359            .find(|(source, _)| source == &spanner.start)
360            .map(|(_, relative)| relative);
361        let end = source_to_relative
362            .iter()
363            .find(|(source, _)| source == &spanner.end)
364            .map(|(_, relative)| relative);
365        match (start, end) {
366            (Some(start), Some(end)) => {
367                let mut copied = spanner.clone();
368                copied.start = start.clone();
369                copied.end = end.clone();
370                spanners.push(copied);
371            }
372            (Some(_), None) | (None, Some(_)) => {
373                diagnostics.push(ScoreFragmentDiagnostic::PartialSpanner {
374                    id: spanner.id.clone(),
375                });
376            }
377            (None, None) => {}
378        }
379    }
380    spanners.sort_by(|left, right| left.id.cmp(&right.id));
381    diagnostics.sort_by(|left, right| match (left, right) {
382        (
383            ScoreFragmentDiagnostic::PartialSpanner { id: left },
384            ScoreFragmentDiagnostic::PartialSpanner { id: right },
385        ) => left.cmp(right),
386    });
387
388    Ok(ScoreFragment {
389        contract_version: SCORE_FRAGMENT_CONTRACT_VERSION,
390        voices,
391        spanners,
392        diagnostics,
393    })
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use crate::{Duration, NotationSpannerKind, Pitch, Step};
400
401    fn address(staff: usize, measure: usize, voice: usize, note: usize) -> NoteAddr {
402        NoteAddr {
403            part: 0,
404            staff,
405            measure,
406            voice,
407            note,
408        }
409    }
410
411    #[test]
412    fn extracts_multivoice_fragment_and_keeps_only_complete_spanners() {
413        let mut score = Score::template(crate::ScoreTemplate::Piano);
414        for staff in &mut score.parts[0].staves {
415            for measure in &mut staff.measures {
416                measure.voices[0] =
417                    vec![crate::Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
418            }
419        }
420        score.parts[0].staves[0].measures[0].source_voice_numbers[0] = Some(5);
421        score.spanners = vec![
422            NotationSpanner {
423                id: "complete".into(),
424                kind: NotationSpannerKind::Slur,
425                start: address(0, 0, 0, 0),
426                end: address(1, 1, 0, 0),
427                number: None,
428                placement: None,
429                line_type: None,
430                ottava_size: None,
431                ottava_type: None,
432                text: None,
433            },
434            NotationSpanner {
435                id: "partial".into(),
436                kind: NotationSpannerKind::Slur,
437                start: address(0, 0, 0, 0),
438                end: address(0, 1, 0, 0),
439                number: None,
440                placement: None,
441                line_type: None,
442                ottava_size: None,
443                ottava_type: None,
444                text: None,
445            },
446        ];
447        let fragment = extract_score_fragment(
448            &score,
449            &[
450                ScoreFragmentSelection {
451                    start: address(0, 0, 0, 0),
452                    end: address(0, 0, 0, 0),
453                },
454                ScoreFragmentSelection {
455                    start: address(1, 1, 0, 0),
456                    end: address(1, 1, 0, 0),
457                },
458            ],
459        )
460        .expect("fragment extracts");
461
462        assert_eq!(fragment.contract_version, SCORE_FRAGMENT_CONTRACT_VERSION);
463        assert_eq!(fragment.voices.len(), 2);
464        assert_eq!(fragment.voices[0].measures[0].source_voice_number, Some(5));
465        assert_eq!(fragment.spanners.len(), 1);
466        assert_eq!(fragment.spanners[0].id, "complete");
467        assert_eq!(fragment.spanners[0].start.staff, 0);
468        assert_eq!(fragment.spanners[0].end.staff, 1);
469        assert_eq!(fragment.spanners[0].end.measure, 1);
470        assert_eq!(
471            fragment.diagnostics,
472            vec![ScoreFragmentDiagnostic::PartialSpanner {
473                id: "partial".into()
474            }]
475        );
476    }
477}