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