#[derive(Debug, Clone, Copy)]
pub(super) struct RowSpec {
pub height: f32,
pub group: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct RowPlacement {
pub dy: f32,
pub visible: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct Solved {
pub(crate) content_height: f32,
pub scroll: f32,
pub(crate) thumb_frac: f32,
pub(crate) thumb_offset_frac: f32,
}
impl Solved {
pub(super) fn scrollable(&self) -> bool {
self.thumb_frac < 1.0
}
}
pub(super) fn solve_into(
rows: &[RowSpec],
collapsed: &[bool],
band_height: f32,
scroll: f32,
out: &mut Vec<RowPlacement>,
) -> Solved {
let is_hidden = |r: &RowSpec| {
r.group
.is_some_and(|g| collapsed.get(g).copied().unwrap_or(false))
};
let content_height: f32 = rows
.iter()
.filter(|r| !is_hidden(r))
.map(|r| r.height)
.sum();
let max_scroll = (content_height - band_height).max(0.0);
let scroll = scroll.clamp(0.0, max_scroll);
out.clear();
out.reserve(rows.len());
let mut base_top = 0.0_f32;
let mut laid_top = 0.0_f32;
for r in rows {
if is_hidden(r) {
out.push(RowPlacement {
dy: 0.0,
visible: false,
});
} else {
let dy = laid_top - base_top - scroll;
out.push(RowPlacement { dy, visible: true });
laid_top += r.height;
}
base_top += r.height;
}
let (thumb_frac, thumb_offset_frac) = if content_height <= 0.0 {
(1.0, 0.0)
} else {
let frac = (band_height / content_height).min(1.0);
let offset = (scroll / content_height).clamp(0.0, 1.0 - frac);
(frac, offset)
};
Solved {
content_height,
scroll,
thumb_frac,
thumb_offset_frac,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(height: f32, group: Option<usize>) -> RowSpec {
RowSpec { height, group }
}
struct FullSolve {
rows: Vec<RowPlacement>,
content_height: f32,
scroll: f32,
thumb_frac: f32,
thumb_offset_frac: f32,
}
impl FullSolve {
fn scrollable(&self) -> bool {
self.thumb_frac < 1.0
}
}
fn solve(rows: &[RowSpec], collapsed: &[bool], band_height: f32, scroll: f32) -> FullSolve {
let mut placements = Vec::new();
let s = solve_into(rows, collapsed, band_height, scroll, &mut placements);
FullSolve {
rows: placements,
content_height: s.content_height,
scroll: s.scroll,
thumb_frac: s.thumb_frac,
thumb_offset_frac: s.thumb_offset_frac,
}
}
#[test]
fn solve_into_clears_the_reused_buffer_before_placing() {
let rows = [row(50.0, None), row(50.0, None)];
let mut out = Vec::new();
solve_into(&rows, &[], 300.0, 0.0, &mut out);
solve_into(&rows, &[], 300.0, 0.0, &mut out);
assert_eq!(out.len(), 2, "a reused buffer holds one solve, not two");
}
#[test]
fn fits_in_band_no_scroll_no_offset() {
let rows = [row(50.0, None), row(50.0, None), row(50.0, None)];
let s = solve(&rows, &[], 300.0, 0.0);
assert_eq!(s.content_height, 150.0);
assert!(!s.scrollable());
assert_eq!(s.thumb_frac, 1.0);
assert_eq!(s.scroll, 0.0);
for p in &s.rows {
assert!(p.visible);
assert_eq!(p.dy, 0.0);
}
}
#[test]
fn overflow_clamps_scroll_and_shifts_rows_up() {
let rows: Vec<RowSpec> = (0..10).map(|_| row(50.0, None)).collect();
let s = solve(&rows, &[], 200.0, 1000.0);
assert_eq!(s.content_height, 500.0);
assert_eq!(s.scroll, 300.0, "scroll clamps to content - band");
assert!(s.scrollable());
for p in &s.rows {
assert!(p.visible);
assert_eq!(p.dy, -300.0);
}
assert!((s.thumb_frac - 0.4).abs() < 1e-5);
assert!((s.thumb_offset_frac - 0.6).abs() < 1e-5);
}
#[test]
fn collapsed_group_hides_body_and_pulls_rows_below_up() {
let rows = [
row(40.0, None),
row(40.0, Some(0)),
row(40.0, Some(0)),
row(40.0, None),
];
let open = solve(&rows, &[false], 1000.0, 0.0);
assert_eq!(open.content_height, 160.0);
for p in &open.rows {
assert!(p.visible);
assert_eq!(p.dy, 0.0);
}
let shut = solve(&rows, &[true], 1000.0, 0.0);
assert_eq!(shut.content_height, 80.0);
assert!(shut.rows[0].visible && shut.rows[0].dy == 0.0); assert!(!shut.rows[1].visible); assert!(!shut.rows[2].visible); assert!(shut.rows[3].visible);
assert_eq!(
shut.rows[3].dy, -80.0,
"footer rises by the collapsed height"
);
}
#[test]
fn collapse_then_scroll_compose() {
let mut rows = vec![row(50.0, None)];
rows.extend((0..6).map(|_| row(50.0, Some(0))));
rows.push(row(50.0, None));
let s = solve(&rows, &[true], 200.0, 500.0);
assert_eq!(s.content_height, 100.0);
assert_eq!(s.scroll, 0.0);
assert!(!s.scrollable());
assert_eq!(s.rows.last().unwrap().dy, -300.0);
}
#[test]
fn empty_panel_is_stable() {
let s = solve(&[], &[], 200.0, 10.0);
assert_eq!(s.content_height, 0.0);
assert_eq!(s.scroll, 0.0);
assert_eq!(s.thumb_frac, 1.0);
assert!(s.rows.is_empty());
}
#[test]
fn out_of_range_group_index_treated_as_expanded() {
let rows = [row(50.0, Some(5))];
let s = solve(&rows, &[false], 200.0, 0.0);
assert!(s.rows[0].visible);
}
}