Skip to main content

acorde_layout/
engine.rs

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