Skip to main content

acorde_layout/
engine.rs

1use crate::{
2    AccidentalMark, BeamGroup, ConcertKeyOverride, CourtesyAccidental, LayoutConfig, LayoutResult,
3    RowLayout, SourceVoiceAddress, SpanMark, TupletGroup,
4};
5use acorde_core::{BeamState, HairpinKind, NoteAddr, OttavaKind, Score, Step, TupletInfo};
6use std::collections::HashMap;
7
8/// Shift `fifths` (circle-of-fifths key index) by `semitones` semitones.
9///
10/// Uses the modular relationship: 7 semitones = +1 fifth (sharp).
11/// Maps result to [-7, 7].
12fn transpose_key_fifths(fifths: i8, semitones: i8) -> i8 {
13    let raw = (semitones as i16 * 7).rem_euclid(12) as i8;
14    let delta = if raw > 6 { raw - 12 } else { raw };
15    (fifths + delta).clamp(-7, 7)
16}
17
18/// Compute a `LayoutResult` for the given score and configuration.
19///
20/// The result is independent of pixel dimensions so any renderer can consume it.
21pub fn compute_layout(score: &Score, config: &LayoutConfig) -> LayoutResult {
22    let per_row = config.measures_per_row.max(1);
23    let first_row = config.first_row_measures.map(|n| n.max(1));
24    let vis_slots = build_vis_slots(score);
25    let rows = build_rows(score, &vis_slots, per_row, first_row);
26    // Spans authored only as typed spanners pair up like their legacy note flags.
27    let spans = resolve_spans(&score.with_legacy_spanner_flags());
28
29    let mut concert_key_overrides = Vec::new();
30    if config.concert_pitch {
31        let base_fifths = score.settings.key_signature.fifths;
32        for (pi, part) in score.parts.iter().enumerate() {
33            for (si, staff) in part.staves.iter().enumerate() {
34                if staff.transpose_semitones != 0 {
35                    concert_key_overrides.push(ConcertKeyOverride {
36                        part_index: pi,
37                        staff_index: si,
38                        fifths: transpose_key_fifths(base_fifths, staff.transpose_semitones),
39                    });
40                }
41            }
42        }
43    }
44
45    let beam_groups = collect_beam_groups(score);
46    let tuplet_groups = collect_tuplet_groups(score);
47    let courtesy_accidentals = collect_courtesy_accidentals(score);
48    let accidentals = collect_accidental_marks(score);
49    let source_voice_addresses = collect_source_voice_addresses(score);
50
51    LayoutResult {
52        vis_slots,
53        rows,
54        spans,
55        typed_spanners: score.spanners.clone(),
56        concert_key_overrides,
57        beam_groups,
58        tuplet_groups,
59        courtesy_accidentals,
60        accidentals,
61        source_voice_addresses,
62    }
63}
64
65fn collect_source_voice_addresses(score: &Score) -> Vec<SourceVoiceAddress> {
66    let mut addresses = Vec::new();
67    for (part, part_value) in score.parts.iter().enumerate() {
68        for (staff, staff_value) in part_value.staves.iter().enumerate() {
69            for (measure, measure_value) in staff_value.measures.iter().enumerate() {
70                for (voice, source_voice_number) in
71                    measure_value.source_voice_numbers.iter().enumerate()
72                {
73                    if let Some(source_voice_number) = source_voice_number {
74                        addresses.push(SourceVoiceAddress {
75                            part,
76                            staff,
77                            measure,
78                            voice,
79                            source_voice_number: *source_voice_number,
80                        });
81                    }
82                }
83            }
84        }
85    }
86    addresses
87}
88
89// ── vis_slots ─────────────────────────────────────────────────────────────────
90
91fn build_vis_slots(score: &Score) -> Vec<usize> {
92    let measure_count = score
93        .parts
94        .first()
95        .and_then(|p| p.staves.first())
96        .map(|s| s.measures.len())
97        .unwrap_or(0);
98
99    let mut slots = Vec::new();
100    for phys in 0..measure_count {
101        let count = multi_rest_count(score, phys).max(1);
102        for _ in 0..count {
103            slots.push(phys);
104        }
105    }
106    slots
107}
108
109fn multi_rest_count(score: &Score, measure_index: usize) -> usize {
110    score
111        .parts
112        .iter()
113        .flat_map(|p| p.staves.iter())
114        .filter_map(|s| s.measures.get(measure_index))
115        .filter_map(|m| m.multi_rest_count)
116        .map(|c| c as usize)
117        .max()
118        .unwrap_or(1)
119}
120
121// ── rows ──────────────────────────────────────────────────────────────────────
122
123fn force_break_after(score: &Score, phys: usize) -> bool {
124    score
125        .parts
126        .iter()
127        .flat_map(|p| p.staves.iter())
128        .filter_map(|s| s.measures.get(phys))
129        .any(|m| m.system_break || m.page_break)
130}
131
132fn build_rows(
133    score: &Score,
134    vis_slots: &[usize],
135    per_row: usize,
136    first_row_limit: Option<usize>,
137) -> Vec<RowLayout> {
138    if vis_slots.is_empty() {
139        return vec![RowLayout {
140            measure_indices: Vec::new(),
141        }];
142    }
143
144    let mut rows: Vec<RowLayout> = Vec::new();
145    let mut current: Vec<usize> = Vec::new();
146    let mut current_width = 0usize;
147    let mut last_phys = usize::MAX;
148
149    for &phys in vis_slots {
150        if phys != last_phys {
151            let limit = if rows.is_empty() {
152                first_row_limit.unwrap_or(per_row)
153            } else {
154                per_row
155            };
156            let span_width = multi_rest_count(score, phys).max(1);
157            if !current.is_empty() && current_width.saturating_add(span_width) > limit {
158                rows.push(RowLayout {
159                    measure_indices: std::mem::take(&mut current),
160                });
161                current_width = 0;
162            }
163            current.push(phys);
164            current_width = current_width.saturating_add(span_width);
165            if force_break_after(score, phys) {
166                rows.push(RowLayout {
167                    measure_indices: std::mem::take(&mut current),
168                });
169                current_width = 0;
170            }
171        }
172        last_phys = phys;
173    }
174
175    if !current.is_empty() {
176        rows.push(RowLayout {
177            measure_indices: current,
178        });
179    }
180    rows
181}
182
183// ── span resolution ───────────────────────────────────────────────────────────
184
185fn resolve_spans(score: &Score) -> Vec<SpanMark> {
186    let mut spans = Vec::new();
187
188    for (pi, part) in score.parts.iter().enumerate() {
189        for (si, staff) in part.staves.iter().enumerate() {
190            for voice in 0..4usize {
191                let mut open_hairpin: Option<(NoteAddr, HairpinKind)> = None;
192                let mut open_ottava: Option<(NoteAddr, OttavaKind)> = None;
193                let mut open_pedal: Option<NoteAddr> = None;
194                let mut open_slur: Option<NoteAddr> = None;
195                let mut open_trill_line: Option<NoteAddr> = None;
196                let mut open_glissando: Option<NoteAddr> = None;
197
198                for (mi, measure) in staff.measures.iter().enumerate() {
199                    for (ni, note) in measure.voices[voice].iter().enumerate() {
200                        let addr = NoteAddr {
201                            part: pi,
202                            staff: si,
203                            measure: mi,
204                            voice,
205                            note: ni,
206                        };
207
208                        // A note can end one hairpin and start the next (cresc. into
209                        // dim.): close the earlier one first.
210                        let ends_earlier = note.hairpin_end && open_hairpin.is_some();
211                        if ends_earlier && let Some((start, kind)) = open_hairpin.take() {
212                            spans.push(SpanMark::Hairpin {
213                                kind,
214                                start,
215                                end: addr.clone(),
216                            });
217                        }
218                        if let Some(kind) = note.hairpin_start {
219                            open_hairpin = Some((addr.clone(), kind));
220                        }
221                        if note.hairpin_end
222                            && !ends_earlier
223                            && let Some((start, kind)) = open_hairpin.take()
224                        {
225                            spans.push(SpanMark::Hairpin {
226                                kind,
227                                start,
228                                end: addr.clone(),
229                            });
230                        }
231
232                        if let Some(kind) = note.ottava_start {
233                            open_ottava = Some((addr.clone(), kind));
234                        }
235                        if note.ottava_end
236                            && let Some((start, kind)) = open_ottava.take()
237                        {
238                            spans.push(SpanMark::Ottava {
239                                kind,
240                                start,
241                                end: addr.clone(),
242                            });
243                        }
244
245                        if note.pedal_start {
246                            open_pedal = Some(addr.clone());
247                        }
248                        if note.pedal_end
249                            && let Some(start) = open_pedal.take()
250                        {
251                            spans.push(SpanMark::Pedal {
252                                start,
253                                end: addr.clone(),
254                            });
255                        }
256
257                        if note.slur_start {
258                            open_slur = Some(addr.clone());
259                        }
260                        if note.slur_end
261                            && let Some(start) = open_slur.take()
262                        {
263                            spans.push(SpanMark::Slur {
264                                start,
265                                end: addr.clone(),
266                            });
267                        }
268
269                        if note.trill_line_start {
270                            open_trill_line = Some(addr.clone());
271                        }
272                        if note.trill_line_end
273                            && let Some(start) = open_trill_line.take()
274                        {
275                            spans.push(SpanMark::TrillLine {
276                                start,
277                                end: addr.clone(),
278                            });
279                        }
280
281                        if note.glissando_start {
282                            open_glissando = Some(addr.clone());
283                        }
284                        if note.glissando_end
285                            && let Some(start) = open_glissando.take()
286                        {
287                            spans.push(SpanMark::Glissando {
288                                start,
289                                end: addr.clone(),
290                            });
291                        }
292
293                        if let Some(chord) = &note.chord_symbol
294                            && let Some(end) = &chord.range_end
295                        {
296                            spans.push(SpanMark::Harmony {
297                                label: chord.display_text(),
298                                start: addr.clone(),
299                                end: end.clone(),
300                            });
301                        }
302                    }
303                }
304            }
305        }
306    }
307    spans
308}
309
310// ── beam groups ───────────────────────────────────────────────────────────────
311
312fn collect_beam_groups(score: &Score) -> Vec<BeamGroup> {
313    let mut groups = Vec::new();
314    for (pi, part) in score.parts.iter().enumerate() {
315        for (si, staff) in part.staves.iter().enumerate() {
316            for (mi, measure) in staff.measures.iter().enumerate() {
317                for vi in 0..4usize {
318                    let voice = &measure.voices[vi];
319                    let mut current: Option<Vec<usize>> = None;
320
321                    for (ni, note) in voice.iter().enumerate() {
322                        match note.beam {
323                            BeamState::Begin => {
324                                current = Some(vec![ni]);
325                            }
326                            BeamState::BeginEnd => {
327                                groups.push(BeamGroup {
328                                    part: pi,
329                                    staff: si,
330                                    measure: mi,
331                                    voice: vi,
332                                    note_indices: vec![ni],
333                                });
334                            }
335                            BeamState::Continue
336                            | BeamState::ForwardHook
337                            | BeamState::BackwardHook => {
338                                if let Some(ref mut g) = current {
339                                    g.push(ni);
340                                }
341                            }
342                            BeamState::End => {
343                                if let Some(mut g) = current.take() {
344                                    g.push(ni);
345                                    groups.push(BeamGroup {
346                                        part: pi,
347                                        staff: si,
348                                        measure: mi,
349                                        voice: vi,
350                                        note_indices: g,
351                                    });
352                                }
353                            }
354                            BeamState::None => {
355                                current = None;
356                            }
357                        }
358                    }
359                }
360            }
361        }
362    }
363    groups
364}
365
366// ── tuplet groups ─────────────────────────────────────────────────────────────
367
368fn collect_tuplet_groups(score: &Score) -> Vec<TupletGroup> {
369    let mut groups = Vec::new();
370    for (pi, part) in score.parts.iter().enumerate() {
371        for (si, staff) in part.staves.iter().enumerate() {
372            for (mi, measure) in staff.measures.iter().enumerate() {
373                for vi in 0..4usize {
374                    let voice = &measure.voices[vi];
375                    let mut current_indices: Vec<usize> = Vec::new();
376                    let mut current_info: Option<TupletInfo> = None;
377
378                    let flush = |indices: &mut Vec<usize>,
379                                 info: &mut Option<TupletInfo>,
380                                 groups: &mut Vec<TupletGroup>| {
381                        if indices.len() >= 2
382                            && let Some(ti) = info.take()
383                        {
384                            groups.push(TupletGroup {
385                                part: pi,
386                                staff: si,
387                                measure: mi,
388                                voice: vi,
389                                note_indices: std::mem::take(indices),
390                                actual_notes: ti.actual_notes,
391                                normal_notes: ti.normal_notes,
392                            });
393                            return;
394                        }
395                        indices.clear();
396                        info.take();
397                    };
398
399                    for (ni, note) in voice.iter().enumerate() {
400                        match &note.tuplet {
401                            Some(ti) => {
402                                let same_group = current_info.as_ref().is_some_and(|prev| {
403                                    prev.actual_notes == ti.actual_notes
404                                        && prev.normal_notes == ti.normal_notes
405                                });
406                                if !same_group {
407                                    flush(&mut current_indices, &mut current_info, &mut groups);
408                                    current_info = Some(ti.clone());
409                                }
410                                current_indices.push(ni);
411                            }
412                            None => {
413                                flush(&mut current_indices, &mut current_info, &mut groups);
414                            }
415                        }
416                    }
417                    flush(&mut current_indices, &mut current_info, &mut groups);
418                }
419            }
420        }
421    }
422    groups
423}
424
425fn step_idx(step: &Step) -> u8 {
426    match step {
427        Step::C => 0,
428        Step::D => 1,
429        Step::E => 2,
430        Step::F => 3,
431        Step::G => 4,
432        Step::A => 5,
433        Step::B => 6,
434    }
435}
436
437/// Accidental implied by a key signature for a given step: 0, 1, or -1.
438fn key_alter(fifths: i8, step: &Step) -> i8 {
439    const SHARPS: [Step; 7] = [
440        Step::F,
441        Step::C,
442        Step::G,
443        Step::D,
444        Step::A,
445        Step::E,
446        Step::B,
447    ];
448    const FLATS: [Step; 7] = [
449        Step::B,
450        Step::E,
451        Step::A,
452        Step::D,
453        Step::G,
454        Step::C,
455        Step::F,
456    ];
457    if fifths > 0 && SHARPS[..fifths as usize].contains(step) {
458        1
459    } else if fifths < 0 && FLATS[..(-fifths) as usize].contains(step) {
460        -1
461    } else {
462        0
463    }
464}
465
466fn collect_courtesy_accidentals(score: &Score) -> Vec<CourtesyAccidental> {
467    let mut result = Vec::new();
468    let base_fifths = score.settings.key_signature.fifths;
469
470    for (pi, part) in score.parts.iter().enumerate() {
471        for (si, staff) in part.staves.iter().enumerate() {
472            // Maps (step_index, octave) -> alter from the previous measure.
473            let mut prev_alters: HashMap<(u8, i8), i8> = HashMap::new();
474            let mut current_fifths = base_fifths;
475
476            for (mi, measure) in staff.measures.iter().enumerate() {
477                if let Some(key_sig) = measure.key_sig.as_ref() {
478                    current_fifths = key_sig.fifths;
479                    prev_alters.clear();
480                }
481
482                // Phase 1: check this measure's notes against prev_alters.
483                for vi in 0..4usize {
484                    for (ni, note) in measure.voices[vi].iter().enumerate() {
485                        if note.tie_end {
486                            continue;
487                        }
488                        for (pitch_idx, pitch) in note.pitches.iter().enumerate() {
489                            let key = (step_idx(&pitch.step), pitch.octave);
490                            if prev_alters.contains_key(&key) {
491                                result.push(CourtesyAccidental {
492                                    part: pi,
493                                    staff: si,
494                                    measure: mi,
495                                    voice: vi,
496                                    note_index: ni,
497                                    pitch_index: pitch_idx,
498                                    alter: pitch.alter,
499                                });
500                            }
501                        }
502                    }
503                }
504
505                // Phase 2: rebuild prev_alters from this measure's chromatically altered pitches.
506                prev_alters.clear();
507                for vi in 0..4usize {
508                    for note in measure.voices[vi].iter() {
509                        for pitch in note.pitches.iter() {
510                            if pitch.alter != key_alter(current_fifths, &pitch.step) {
511                                prev_alters
512                                    .insert((step_idx(&pitch.step), pitch.octave), pitch.alter);
513                            }
514                        }
515                    }
516                }
517            }
518        }
519    }
520    result
521}
522
523// ── mandatory accidentals ────────────────────────────────────────────────────
524
525/// Collect mandatory (non-courtesy) accidental marks.
526///
527/// State is scoped per `(part, staff)` and reset every measure to the key signature's
528/// implied alteration — accidentals never carry across a barline. Within a measure, all
529/// voices on the staff share one accidental context (mirrors [`collect_courtesy_accidentals`]'s
530/// voice-major scan order). Tied continuations (`tie_end`) never need a fresh accidental.
531fn collect_accidental_marks(score: &Score) -> Vec<AccidentalMark> {
532    let mut result = Vec::new();
533    let base_fifths = score.settings.key_signature.fifths;
534
535    for (pi, part) in score.parts.iter().enumerate() {
536        for (si, staff) in part.staves.iter().enumerate() {
537            let mut current_fifths = base_fifths;
538
539            for (mi, measure) in staff.measures.iter().enumerate() {
540                if let Some(key_sig) = measure.key_sig.as_ref() {
541                    current_fifths = key_sig.fifths;
542                }
543
544                // Active alteration per (step, octave), established within this measure only.
545                let mut active: HashMap<(u8, i8), i8> = HashMap::new();
546
547                for vi in 0..4usize {
548                    for (ni, note) in measure.voices[vi].iter().enumerate() {
549                        for (pitch_idx, pitch) in note.pitches.iter().enumerate() {
550                            let key = (step_idx(&pitch.step), pitch.octave);
551                            let baseline = active
552                                .get(&key)
553                                .copied()
554                                .unwrap_or_else(|| key_alter(current_fifths, &pitch.step));
555                            if pitch.alter != baseline {
556                                if !note.tie_end {
557                                    result.push(AccidentalMark {
558                                        part: pi,
559                                        staff: si,
560                                        measure: mi,
561                                        voice: vi,
562                                        note_index: ni,
563                                        pitch_index: pitch_idx,
564                                        alter: pitch.alter,
565                                    });
566                                }
567                                active.insert(key, pitch.alter);
568                            }
569                        }
570                    }
571                }
572            }
573        }
574    }
575    result
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use acorde_core::{Clef, Measure, Part, Score, Staff};
582
583    fn score_with_measures(n: usize) -> Score {
584        let mut score = Score::default();
585        score.parts.clear();
586        let mut staff = Staff::new(Clef::Treble);
587        for i in 0..n {
588            let mut m = Measure::empty(4, 4);
589            m.number = i as u32 + 1;
590            staff.measures.push(m);
591        }
592        let mut part = Part::new("P1", "P1");
593        part.staves.push(staff);
594        score.parts.push(part);
595        score
596    }
597
598    #[test]
599    fn default_score_has_one_row_of_four() {
600        // Score::default() ships with 4 measures; default measures_per_row=4
601        let score = Score::default();
602        let result = compute_layout(&score, &LayoutConfig::default());
603        assert_eq!(result.rows.len(), 1);
604        assert_eq!(result.rows[0].measure_indices, vec![0, 1, 2, 3]);
605        assert_eq!(result.vis_slots.len(), 4);
606        assert!(result.spans.is_empty());
607    }
608
609    #[test]
610    fn vis_slots_expand_multi_rest() {
611        let mut score = score_with_measures(1);
612        score.parts[0].staves[0].measures[0].multi_rest_count = Some(4);
613
614        let result = compute_layout(
615            &score,
616            &LayoutConfig {
617                measures_per_row: 8,
618                concert_pitch: false,
619                first_row_measures: None,
620            },
621        );
622        assert_eq!(result.vis_slots.len(), 4);
623        assert!(result.vis_slots.iter().all(|&v| v == 0));
624    }
625
626    #[test]
627    fn measures_split_into_rows() {
628        let score = score_with_measures(6);
629        let result = compute_layout(
630            &score,
631            &LayoutConfig {
632                measures_per_row: 4,
633                concert_pitch: false,
634                first_row_measures: None,
635            },
636        );
637        assert_eq!(result.rows.len(), 2);
638        assert_eq!(result.rows[0].measure_indices, vec![0, 1, 2, 3]);
639        assert_eq!(result.rows[1].measure_indices, vec![4, 5]);
640    }
641
642    #[test]
643    fn single_measure_single_row() {
644        let score = score_with_measures(1);
645        let result = compute_layout(
646            &score,
647            &LayoutConfig {
648                measures_per_row: 4,
649                concert_pitch: false,
650                first_row_measures: None,
651            },
652        );
653        assert_eq!(result.rows.len(), 1);
654        assert_eq!(result.rows[0].measure_indices, vec![0]);
655    }
656
657    #[test]
658    fn exactly_full_rows() {
659        let score = score_with_measures(8);
660        let result = compute_layout(
661            &score,
662            &LayoutConfig {
663                measures_per_row: 4,
664                concert_pitch: false,
665                first_row_measures: None,
666            },
667        );
668        assert_eq!(result.rows.len(), 2);
669        assert_eq!(result.rows[0].measure_indices.len(), 4);
670        assert_eq!(result.rows[1].measure_indices.len(), 4);
671    }
672
673    #[test]
674    fn concert_pitch_bb_clarinet_transposes_key() {
675        // Bb clarinet: transpose_semitones=-2, written key C major (fifths=0)
676        // Concert key = Bb major (fifths=-2)
677        let mut score = score_with_measures(1);
678        score.parts[0].staves[0].transpose_semitones = -2;
679        let result = compute_layout(
680            &score,
681            &LayoutConfig {
682                measures_per_row: 4,
683                concert_pitch: true,
684                first_row_measures: None,
685            },
686        );
687        assert_eq!(result.concert_key_overrides.len(), 1);
688        assert_eq!(result.concert_key_overrides[0].part_index, 0);
689        assert_eq!(result.concert_key_overrides[0].staff_index, 0);
690        assert_eq!(result.concert_key_overrides[0].fifths, -2);
691    }
692
693    #[test]
694    fn concert_pitch_false_no_override() {
695        let mut score = score_with_measures(1);
696        score.parts[0].staves[0].transpose_semitones = -2;
697        let result = compute_layout(
698            &score,
699            &LayoutConfig {
700                measures_per_row: 4,
701                concert_pitch: false,
702                first_row_measures: None,
703            },
704        );
705        assert!(result.concert_key_overrides.is_empty());
706    }
707
708    #[test]
709    fn concert_pitch_zero_transpose_no_override() {
710        // transpose_semitones=0 means concert pitch equals written pitch; no override needed.
711        let score = score_with_measures(1);
712        let result = compute_layout(
713            &score,
714            &LayoutConfig {
715                measures_per_row: 4,
716                concert_pitch: true,
717                first_row_measures: None,
718            },
719        );
720        assert!(result.concert_key_overrides.is_empty());
721    }
722
723    #[test]
724    fn transpose_key_formula_eb_alto_sax() {
725        // Eb alto sax: transpose_semitones=-9, written key C major (fifths=0)
726        // Concert key = Eb major (fifths=-3): delta = (-9*7) rem_euclid 12 = 9 → 9-12=-3
727        assert_eq!(transpose_key_fifths(0, -9), -3);
728    }
729
730    #[test]
731    fn transpose_key_formula_perfect_fifth_up() {
732        // +7 semitones = P5 up → +1 sharp (G major = fifths=1)
733        assert_eq!(transpose_key_fifths(0, 7), 1);
734    }
735
736    #[test]
737    fn system_break_splits_row() {
738        // 3 measures; measure[1] has system_break → rows: [0,1] and [2]
739        let mut score = score_with_measures(3);
740        score.parts[0].staves[0].measures[1].system_break = true;
741        let result = compute_layout(
742            &score,
743            &LayoutConfig {
744                measures_per_row: 4,
745                concert_pitch: false,
746                first_row_measures: None,
747            },
748        );
749        assert_eq!(result.rows.len(), 2);
750        assert_eq!(result.rows[0].measure_indices, vec![0, 1]);
751        assert_eq!(result.rows[1].measure_indices, vec![2]);
752    }
753
754    #[test]
755    fn page_break_splits_row() {
756        let mut score = score_with_measures(3);
757        score.parts[0].staves[0].measures[0].page_break = true;
758        let result = compute_layout(
759            &score,
760            &LayoutConfig {
761                measures_per_row: 4,
762                concert_pitch: false,
763                first_row_measures: None,
764            },
765        );
766        assert_eq!(result.rows.len(), 2);
767        assert_eq!(result.rows[0].measure_indices, vec![0]);
768        assert_eq!(result.rows[1].measure_indices, vec![1, 2]);
769    }
770
771    #[test]
772    fn system_break_overrides_per_row() {
773        // per_row=4 but system_break after measure[1] → row ends early
774        let mut score = score_with_measures(5);
775        score.parts[0].staves[0].measures[1].system_break = true;
776        let result = compute_layout(
777            &score,
778            &LayoutConfig {
779                measures_per_row: 4,
780                concert_pitch: false,
781                first_row_measures: None,
782            },
783        );
784        assert_eq!(result.rows.len(), 2);
785        assert_eq!(result.rows[0].measure_indices, vec![0, 1]);
786        assert_eq!(result.rows[1].measure_indices, vec![2, 3, 4]);
787    }
788
789    #[test]
790    fn no_break_unchanged() {
791        let score = score_with_measures(3);
792        let result = compute_layout(
793            &score,
794            &LayoutConfig {
795                measures_per_row: 4,
796                concert_pitch: false,
797                first_row_measures: None,
798            },
799        );
800        assert_eq!(result.rows.len(), 1);
801        assert_eq!(result.rows[0].measure_indices, vec![0, 1, 2]);
802    }
803
804    // ── first_row_measures ────────────────────────────────────────────────────
805
806    #[test]
807    fn first_row_measures_limits_first_row() {
808        // 5 measures, per_row=4, first_row_measures=2 → row[0]=2, row[1]=3
809        let score = score_with_measures(5);
810        let result = compute_layout(
811            &score,
812            &LayoutConfig {
813                measures_per_row: 4,
814                concert_pitch: false,
815                first_row_measures: Some(2),
816            },
817        );
818        assert_eq!(result.rows.len(), 2);
819        assert_eq!(result.rows[0].measure_indices, vec![0, 1]);
820        assert_eq!(result.rows[1].measure_indices, vec![2, 3, 4]);
821    }
822
823    #[test]
824    fn first_row_measures_none_unchanged() {
825        // first_row_measures=None → same as using per_row for all rows
826        let score = score_with_measures(5);
827        let without = compute_layout(
828            &score,
829            &LayoutConfig {
830                measures_per_row: 4,
831                concert_pitch: false,
832                first_row_measures: None,
833            },
834        );
835        let with_none = compute_layout(&score, &LayoutConfig::default());
836        assert_eq!(without.rows.len(), with_none.rows.len());
837        for (a, b) in without.rows.iter().zip(with_none.rows.iter()) {
838            assert_eq!(a.measure_indices, b.measure_indices);
839        }
840    }
841
842    #[test]
843    fn beam_groups_empty_for_default_score() {
844        let score = Score::default();
845        let result = compute_layout(&score, &LayoutConfig::default());
846        assert!(result.beam_groups.is_empty());
847    }
848
849    #[test]
850    fn layout_exposes_source_voice_number_for_canonical_slot() {
851        let mut score = score_with_measures(1);
852        score.parts[0].staves[0].measures[0].source_voice_numbers[1] = Some(5);
853
854        let result = compute_layout(&score, &LayoutConfig::default());
855        assert_eq!(
856            result.source_voice_addresses,
857            vec![SourceVoiceAddress {
858                part: 0,
859                staff: 0,
860                measure: 0,
861                voice: 1,
862                source_voice_number: 5,
863            }]
864        );
865    }
866
867    #[test]
868    fn beam_groups_begin_end_collected() {
869        use acorde_core::{BeamState, Duration, Note, Pitch, Step};
870        let mut score = score_with_measures(1);
871        let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Eighth);
872        n1.beam = BeamState::Begin;
873        let mut n2 = Note::new(Pitch::new(Step::D, 4), Duration::Eighth);
874        n2.beam = BeamState::Continue;
875        let mut n3 = Note::new(Pitch::new(Step::E, 4), Duration::Eighth);
876        n3.beam = BeamState::End;
877        score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2, n3];
878
879        let result = compute_layout(&score, &LayoutConfig::default());
880        assert_eq!(result.beam_groups.len(), 1);
881        assert_eq!(result.beam_groups[0].note_indices, vec![0, 1, 2]);
882        assert_eq!(result.beam_groups[0].part, 0);
883        assert_eq!(result.beam_groups[0].measure, 0);
884        assert_eq!(result.beam_groups[0].voice, 0);
885    }
886
887    #[test]
888    fn beam_groups_beginend_is_standalone_group() {
889        use acorde_core::{BeamState, Duration, Note, Pitch, Step};
890        let mut score = score_with_measures(1);
891        let mut n = Note::new(Pitch::new(Step::C, 4), Duration::Eighth);
892        n.beam = BeamState::BeginEnd;
893        score.parts[0].staves[0].measures[0].voices[0] = vec![n];
894
895        let result = compute_layout(&score, &LayoutConfig::default());
896        assert_eq!(result.beam_groups.len(), 1);
897        assert_eq!(result.beam_groups[0].note_indices, vec![0]);
898    }
899
900    #[test]
901    fn tuplet_groups_empty_for_default_score() {
902        let score = Score::default();
903        let result = compute_layout(&score, &LayoutConfig::default());
904        assert!(result.tuplet_groups.is_empty());
905    }
906
907    #[test]
908    fn tuplet_groups_triplet_collected() {
909        use acorde_core::{Duration, Note, Pitch, Step, TupletInfo};
910        let ti = TupletInfo {
911            actual_notes: 3,
912            normal_notes: 2,
913        };
914        let mut score = score_with_measures(1);
915        let notes: Vec<Note> = (0..3)
916            .map(|i| {
917                let step = [Step::C, Step::D, Step::E][i].clone();
918                let mut n = Note::new(Pitch::new(step, 4), Duration::Quarter);
919                n.tuplet = Some(ti.clone());
920                n
921            })
922            .collect();
923        score.parts[0].staves[0].measures[0].voices[0] = notes;
924
925        let result = compute_layout(&score, &LayoutConfig::default());
926        assert_eq!(result.tuplet_groups.len(), 1);
927        assert_eq!(result.tuplet_groups[0].note_indices, vec![0, 1, 2]);
928        assert_eq!(result.tuplet_groups[0].actual_notes, 3);
929        assert_eq!(result.tuplet_groups[0].normal_notes, 2);
930        assert_eq!(result.tuplet_groups[0].part, 0);
931        assert_eq!(result.tuplet_groups[0].measure, 0);
932    }
933
934    #[test]
935    fn tuplet_groups_no_group_for_single_tuplet_note() {
936        use acorde_core::{Duration, Note, Pitch, Step, TupletInfo};
937        let mut score = score_with_measures(1);
938        let mut n = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
939        n.tuplet = Some(TupletInfo {
940            actual_notes: 3,
941            normal_notes: 2,
942        });
943        score.parts[0].staves[0].measures[0].voices[0] = vec![n];
944
945        let result = compute_layout(&score, &LayoutConfig::default());
946        // Single-note tuplet: not enough to form a group (need >= 2)
947        assert!(result.tuplet_groups.is_empty());
948    }
949
950    #[test]
951    fn hairpin_span_resolved() {
952        use acorde_core::{Duration, HairpinKind, Note, Pitch, Step};
953        let mut score = score_with_measures(1);
954        let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
955        n1.hairpin_start = Some(HairpinKind::Crescendo);
956        let mut n2 = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
957        n2.hairpin_end = true;
958        score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2];
959
960        let result = compute_layout(&score, &LayoutConfig::default());
961        assert_eq!(result.spans.len(), 1);
962        if let SpanMark::Hairpin { kind, start, end } = &result.spans[0] {
963            assert_eq!(*kind, HairpinKind::Crescendo);
964            assert_eq!(start.note, 0);
965            assert_eq!(end.note, 1);
966        } else {
967            panic!("expected Hairpin span");
968        }
969    }
970
971    #[test]
972    fn slur_span_resolved() {
973        use acorde_core::{Duration, Note, Pitch, Step};
974        let mut score = score_with_measures(1);
975        let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
976        n1.slur_start = true;
977        let mut n2 = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
978        n2.slur_end = true;
979        score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2];
980
981        let result = compute_layout(&score, &LayoutConfig::default());
982        let slur = result
983            .spans
984            .iter()
985            .find(|s| matches!(s, SpanMark::Slur { .. }));
986        assert!(slur.is_some(), "expected a Slur span");
987        if let Some(SpanMark::Slur { start, end }) = slur {
988            assert_eq!(start.note, 0);
989            assert_eq!(end.note, 1);
990        }
991    }
992
993    #[test]
994    fn typed_spanners_keep_stable_identity_in_layout_result() {
995        use acorde_core::{Duration, NotationSpanner, NotationSpannerKind, Note, Pitch, Step};
996        let mut score = score_with_measures(1);
997        score.parts[0].staves[0].measures[0].voices[0] = vec![
998            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
999            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
1000        ];
1001        score.spanners.push(NotationSpanner {
1002            id: "typed-slur-7".to_string(),
1003            kind: NotationSpannerKind::Slur,
1004            start: NoteAddr {
1005                part: 0,
1006                staff: 0,
1007                measure: 0,
1008                voice: 0,
1009                note: 0,
1010            },
1011            end: NoteAddr {
1012                part: 0,
1013                staff: 0,
1014                measure: 0,
1015                voice: 0,
1016                note: 1,
1017            },
1018            number: Some(7),
1019            line_type: Some("dashed".to_string()),
1020            text: None,
1021            placement: Some("above".to_string()),
1022            ottava_size: None,
1023            ottava_type: None,
1024        });
1025
1026        let result = compute_layout(&score, &LayoutConfig::default());
1027        assert_eq!(result.typed_spanners.len(), 1);
1028        assert_eq!(result.typed_spanners[0].id, "typed-slur-7");
1029        assert_eq!(result.typed_spanners[0].number, Some(7));
1030    }
1031
1032    #[test]
1033    fn harmony_range_span_resolved_from_typed_end_address() {
1034        use acorde_core::{ChordSymbol, Duration, Note, NoteAddr, Pitch, Step};
1035        let mut score = score_with_measures(2);
1036        score.parts[0].staves[0].measures[0].voices[0] =
1037            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1038        score.parts[0].staves[0].measures[1].voices[0] =
1039            vec![Note::new(Pitch::new(Step::G, 4), Duration::Whole)];
1040        score.parts[0].staves[0].measures[0].voices[0][0].chord_symbol = Some(ChordSymbol {
1041            root: "C".to_owned(),
1042            kind: "major".to_owned(),
1043            bass: None,
1044            placement: None,
1045            extender: true,
1046            harmonic_degree: None,
1047            harmony_function: None,
1048            harmony_type: None,
1049            chord_ref: None,
1050            range_end: Some(NoteAddr {
1051                part: 0,
1052                staff: 0,
1053                measure: 1,
1054                voice: 0,
1055                note: 0,
1056            }),
1057            degrees: Vec::new(),
1058        });
1059
1060        let result = compute_layout(&score, &LayoutConfig::default());
1061        assert!(matches!(
1062            result.spans.as_slice(),
1063            [SpanMark::Harmony { label, start, end }]
1064                if label == "C" && start.measure == 0 && end.measure == 1
1065        ));
1066    }
1067
1068    // ── CourtesyAccidental ────────────────────────────────────────────────────
1069
1070    #[test]
1071    fn courtesy_none_for_clean_score() {
1072        // Default score has no chromatic alterations — no courtesy accidentals.
1073        let score = Score::default();
1074        let result = compute_layout(&score, &LayoutConfig::default());
1075        assert!(result.courtesy_accidentals.is_empty());
1076    }
1077
1078    #[test]
1079    fn courtesy_after_chromatic_alteration() {
1080        // Measure 0: F# (alter=1). Measure 1: F natural (alter=0).
1081        // The F in measure 1 should get a courtesy accidental.
1082        use acorde_core::{Duration, Note, Pitch, Step};
1083        let mut score = score_with_measures(2);
1084
1085        let mut fsharp = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1086        fsharp.pitches[0].alter = 1;
1087        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp];
1088
1089        let fnat = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1090        // alter=0 is default; key signature is C major (fifths=0) so F natural has key_alter=0
1091        score.parts[0].staves[0].measures[1].voices[0] = vec![fnat];
1092
1093        let result = compute_layout(&score, &LayoutConfig::default());
1094        assert_eq!(result.courtesy_accidentals.len(), 1);
1095        let ca = &result.courtesy_accidentals[0];
1096        assert_eq!(ca.measure, 1);
1097        assert_eq!(ca.alter, 0); // natural sign
1098    }
1099
1100    #[test]
1101    fn courtesy_same_alteration_no_courtesy() {
1102        // Measure 0: F#. Measure 1: F# again.
1103        // F# in measure 1 does NOT need a courtesy — no prior alteration conflict.
1104        // (prev_alters records F# alter=1; measure 1 F# has same alter=1 → NOT in prev_alters
1105        // because phase 2 only records notes that differ from key_alter.)
1106        // Actually: F# IS in prev_alters → courtesy IS emitted, alter=1.
1107        use acorde_core::{Duration, Note, Pitch, Step};
1108        let mut score = score_with_measures(2);
1109
1110        let mut fsharp1 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1111        fsharp1.pitches[0].alter = 1;
1112        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp1];
1113
1114        let mut fsharp2 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1115        fsharp2.pitches[0].alter = 1;
1116        score.parts[0].staves[0].measures[1].voices[0] = vec![fsharp2];
1117
1118        let result = compute_layout(&score, &LayoutConfig::default());
1119        // F# appears in prev_alters → courtesy emitted with alter=1
1120        assert_eq!(result.courtesy_accidentals.len(), 1);
1121        assert_eq!(result.courtesy_accidentals[0].alter, 1);
1122    }
1123
1124    #[test]
1125    fn courtesy_tied_note_excluded() {
1126        // A note with tie_end=true should never get a courtesy accidental.
1127        use acorde_core::{Duration, Note, Pitch, Step};
1128        let mut score = score_with_measures(2);
1129
1130        let mut fsharp = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1131        fsharp.pitches[0].alter = 1;
1132        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp];
1133
1134        let mut tied = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1135        tied.pitches[0].alter = 1;
1136        tied.tie_end = true;
1137        score.parts[0].staves[0].measures[1].voices[0] = vec![tied];
1138
1139        let result = compute_layout(&score, &LayoutConfig::default());
1140        assert!(result.courtesy_accidentals.is_empty());
1141    }
1142
1143    #[test]
1144    fn courtesy_key_change_resets() {
1145        // Measure 0: F# (alter=1). Measure 1: key changes to G major (fifths=1, F# implied).
1146        // The key change clears prev_alters, so F in measure 1 won't get a courtesy.
1147        use acorde_core::{Duration, KeySignature, Note, Pitch, Step};
1148        let mut score = score_with_measures(2);
1149
1150        let mut fsharp = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1151        fsharp.pitches[0].alter = 1;
1152        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp];
1153
1154        // Set key signature on measure 1 to G major (1 sharp = F#)
1155        score.parts[0].staves[0].measures[1].key_sig = Some(KeySignature {
1156            fifths: 1,
1157            mode: "major".to_string(),
1158        });
1159        // F# is now implied by the key — alter=1 == key_alter(1, F) → not in prev_alters
1160        let mut fsharp2 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1161        fsharp2.pitches[0].alter = 1;
1162        score.parts[0].staves[0].measures[1].voices[0] = vec![fsharp2];
1163
1164        let result = compute_layout(&score, &LayoutConfig::default());
1165        // key change clears prev_alters before phase 1 → no courtesy for measure 1
1166        assert!(result.courtesy_accidentals.is_empty());
1167    }
1168
1169    // ── AccidentalMark ────────────────────────────────────────────────────────
1170
1171    #[test]
1172    fn accidental_none_for_clean_score() {
1173        let score = Score::default();
1174        let result = compute_layout(&score, &LayoutConfig::default());
1175        assert!(result.accidentals.is_empty());
1176    }
1177
1178    #[test]
1179    fn accidental_first_chromatic_note_in_measure() {
1180        // C major, single F# in measure 0 → one mandatory accidental (sharp).
1181        use acorde_core::{Duration, Note, Pitch, Step};
1182        let mut score = score_with_measures(1);
1183        let mut fsharp = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1184        fsharp.pitches[0].alter = 1;
1185        score.parts[0].staves[0].measures[0].voices[0] = vec![fsharp];
1186
1187        let result = compute_layout(&score, &LayoutConfig::default());
1188        assert_eq!(result.accidentals.len(), 1);
1189        assert_eq!(result.accidentals[0].alter, 1);
1190        assert_eq!(result.accidentals[0].measure, 0);
1191    }
1192
1193    #[test]
1194    fn accidental_repeat_same_alter_in_measure_not_marked_again() {
1195        // Two F#s in the same measure: only the first needs an accidental.
1196        use acorde_core::{Duration, Note, Pitch, Step};
1197        let mut score = score_with_measures(1);
1198        let mut f1 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1199        f1.pitches[0].alter = 1;
1200        let mut f2 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1201        f2.pitches[0].alter = 1;
1202        score.parts[0].staves[0].measures[0].voices[0] = vec![f1, f2];
1203
1204        let result = compute_layout(&score, &LayoutConfig::default());
1205        assert_eq!(result.accidentals.len(), 1);
1206    }
1207
1208    #[test]
1209    fn accidental_reverts_to_natural_within_same_measure() {
1210        // F# then F natural, same measure: the natural needs its own mark too.
1211        use acorde_core::{Duration, Note, Pitch, Step};
1212        let mut score = score_with_measures(1);
1213        let mut f1 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1214        f1.pitches[0].alter = 1;
1215        let f2 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter); // alter=0
1216        score.parts[0].staves[0].measures[0].voices[0] = vec![f1, f2];
1217
1218        let result = compute_layout(&score, &LayoutConfig::default());
1219        assert_eq!(result.accidentals.len(), 2);
1220        assert_eq!(result.accidentals[0].alter, 1);
1221        assert_eq!(result.accidentals[1].alter, 0);
1222    }
1223
1224    #[test]
1225    fn accidental_does_not_carry_across_barline() {
1226        // F# in measure 0; measure 1's F# needs its own mark (accidentals reset per measure).
1227        // This is the case that would otherwise double up with a CourtesyAccidental —
1228        // callers must prefer `accidentals` over `courtesy_accidentals` when both exist.
1229        use acorde_core::{Duration, Note, Pitch, Step};
1230        let mut score = score_with_measures(2);
1231        let mut f1 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1232        f1.pitches[0].alter = 1;
1233        score.parts[0].staves[0].measures[0].voices[0] = vec![f1];
1234        let mut f2 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1235        f2.pitches[0].alter = 1;
1236        score.parts[0].staves[0].measures[1].voices[0] = vec![f2];
1237
1238        let result = compute_layout(&score, &LayoutConfig::default());
1239        assert_eq!(result.accidentals.len(), 2);
1240        assert!(result.accidentals.iter().any(|a| a.measure == 1));
1241        // The same address also produced a courtesy mark — renderer precedence resolves it.
1242        assert_eq!(result.courtesy_accidentals.len(), 1);
1243        assert_eq!(result.courtesy_accidentals[0].measure, 1);
1244    }
1245
1246    #[test]
1247    fn accidental_tied_note_excluded() {
1248        use acorde_core::{Duration, Note, Pitch, Step};
1249        let mut score = score_with_measures(1);
1250        let mut tied = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1251        tied.pitches[0].alter = 1;
1252        tied.tie_end = true;
1253        score.parts[0].staves[0].measures[0].voices[0] = vec![tied];
1254
1255        let result = compute_layout(&score, &LayoutConfig::default());
1256        assert!(result.accidentals.is_empty());
1257    }
1258
1259    #[test]
1260    fn accidental_shared_across_voices_on_same_staff() {
1261        // Voice 0 introduces F#; voice 1's F# later in the same measure shares the context.
1262        use acorde_core::{Duration, Note, Pitch, Step};
1263        let mut score = score_with_measures(1);
1264        let mut f_v0 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1265        f_v0.pitches[0].alter = 1;
1266        score.parts[0].staves[0].measures[0].voices[0] = vec![f_v0];
1267        let mut f_v1 = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1268        f_v1.pitches[0].alter = 1;
1269        score.parts[0].staves[0].measures[0].voices[1] = vec![f_v1];
1270
1271        let result = compute_layout(&score, &LayoutConfig::default());
1272        assert_eq!(result.accidentals.len(), 1);
1273    }
1274
1275    #[test]
1276    fn accidental_double_sharp_marked() {
1277        use acorde_core::{Duration, Note, Pitch, Step};
1278        let mut score = score_with_measures(1);
1279        let mut n = Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
1280        n.pitches[0].alter = 2;
1281        score.parts[0].staves[0].measures[0].voices[0] = vec![n];
1282
1283        let result = compute_layout(&score, &LayoutConfig::default());
1284        assert_eq!(result.accidentals.len(), 1);
1285        assert_eq!(result.accidentals[0].alter, 2);
1286    }
1287}