use teksilo_canvas::EdgeInsets;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum WidthPolicy {
Fixed(f32),
Count(usize),
Adaptive { min: f32, max: Option<f32> },
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ColumnGeometry {
policy: WidthPolicy,
col_gap: f32,
inset: EdgeInsets,
max_columns: Option<usize>,
}
impl ColumnGeometry {
pub(crate) fn from_policy(policy: WidthPolicy, col_gap: f32, inset: EdgeInsets) -> Self {
Self {
policy,
col_gap: col_gap.max(0.0),
inset,
max_columns: None,
}
}
pub(crate) fn with_max_columns(mut self, max: Option<usize>) -> Self {
self.max_columns = max.map(|m| m.max(1));
self
}
pub(crate) fn available_width(&self, viewport_width: f32) -> f32 {
(viewport_width - self.inset.horizontal()).max(0.0)
}
pub(crate) fn column_count(&self, viewport_width: f32) -> usize {
let fit = match self.policy {
WidthPolicy::Count(n) => n.max(1),
WidthPolicy::Fixed(w) | WidthPolicy::Adaptive { min: w, .. } => {
let avail = self.available_width(viewport_width);
if w <= 0.0 {
return 1;
}
const EPSILON: f32 = 1e-4;
let n = ((avail + self.col_gap) / (w + self.col_gap) + EPSILON).floor() as i64;
n.max(1) as usize
}
};
match self.max_columns {
Some(max) => fit.min(max).max(1),
None => fit,
}
}
pub(crate) fn column_width(&self, viewport_width: f32) -> f32 {
let cols = self.column_count(viewport_width).max(1);
let avail = self.available_width(viewport_width);
let stretched = (avail - self.col_gap * (cols as f32 - 1.0)) / cols as f32;
match self.policy {
WidthPolicy::Fixed(w) => w,
WidthPolicy::Count(_) => stretched,
WidthPolicy::Adaptive { max, .. } => match max {
Some(mx) => stretched.min(mx),
None => stretched,
},
}
.max(0.0)
}
pub(crate) fn column_x(&self, col: usize, viewport_width: f32) -> (f32, f32) {
let col_w = self.column_width(viewport_width);
let x = self.inset.leading + col as f32 * (col_w + self.col_gap);
(x, col_w)
}
pub(crate) fn used_width(&self, viewport_width: f32) -> f32 {
let cols = self.column_count(viewport_width).max(1);
let col_w = self.column_width(viewport_width);
col_w * cols as f32 + self.col_gap * (cols as f32 - 1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn adaptive(min: f32, max: Option<f32>, gap: f32) -> ColumnGeometry {
ColumnGeometry::from_policy(WidthPolicy::Adaptive { min, max }, gap, EdgeInsets::ZERO)
}
#[test]
fn adaptive_count_is_css_auto_fill_formula() {
assert_eq!(adaptive(240.0, None, 0.0).column_count(1000.0), 4);
assert_eq!(adaptive(240.0, None, 16.0).column_count(1000.0), 3);
}
#[test]
fn adaptive_count_floors_at_one() {
assert_eq!(adaptive(240.0, None, 0.0).column_count(10.0), 1);
assert_eq!(adaptive(240.0, None, 0.0).column_count(0.0), 1);
}
#[test]
fn zero_min_width_does_not_divide_by_zero() {
assert_eq!(adaptive(0.0, None, 0.0).column_count(1000.0), 1);
}
#[test]
fn exact_fit_is_stable_under_float_rounding() {
let g = adaptive(240.0, None, 0.0);
let avail = 4.0 * 240.0 - 0.0001;
assert_eq!(
g.column_count(avail),
4,
"an exact (up to float noise) fit must not drop a column"
);
}
#[test]
fn one_pixel_under_still_drops_a_column() {
let g = adaptive(240.0, None, 0.0);
let avail = 4.0 * 240.0 - 1.0;
assert_eq!(g.column_count(avail), 3);
}
#[test]
fn negative_gap_is_clamped() {
assert_eq!(adaptive(240.0, None, -50.0).column_count(1000.0), 4);
}
#[test]
fn adaptive_columns_stretch_to_fill() {
let g = adaptive(300.0, None, 20.0);
assert_eq!(g.column_count(1000.0), 3);
let (x0, w0) = g.column_x(0, 1000.0);
let (x1, _) = g.column_x(1, 1000.0);
assert!((w0 - 320.0).abs() < 0.01);
assert!((x0 - 0.0).abs() < 0.01);
assert!((x1 - 340.0).abs() < 0.01);
}
#[test]
fn adaptive_max_clamps_column_width() {
let g = adaptive(240.0, Some(300.0), 0.0);
assert_eq!(g.column_count(1000.0), 4);
let (_, w) = g.column_x(0, 1000.0);
assert!((w - 250.0).abs() < 0.01, "stretched share 250 is under max");
let g = adaptive(400.0, Some(300.0), 0.0);
assert_eq!(g.column_count(1000.0), 2);
let (_, w) = g.column_x(0, 1000.0);
assert!((w - 300.0).abs() < 0.01, "share 500 clamped to max 300");
}
#[test]
fn used_width_reports_leftover_when_max_clamps() {
let g = adaptive(400.0, Some(300.0), 0.0);
assert!((g.used_width(1000.0) - 600.0).abs() < 0.01);
assert!((g.available_width(1000.0) - 1000.0).abs() < 0.01);
}
#[test]
fn used_width_equals_available_when_unclamped() {
let g = adaptive(300.0, None, 20.0);
assert!((g.used_width(1000.0) - 1000.0).abs() < 0.01);
}
#[test]
fn insets_reduce_available_width_and_offset_columns() {
let g = ColumnGeometry::from_policy(
WidthPolicy::Adaptive {
min: 200.0,
max: None,
},
0.0,
EdgeInsets {
leading: 10.0,
trailing: 10.0,
top: 0.0,
bottom: 0.0,
},
);
assert_eq!(g.column_count(1000.0), 4);
let (x0, w0) = g.column_x(0, 1000.0);
assert!((x0 - 10.0).abs() < 0.01);
assert!((w0 - 245.0).abs() < 0.01);
}
#[test]
fn max_columns_caps_the_count_and_widens_the_columns() {
let g = adaptive(200.0, None, 0.0).with_max_columns(Some(2));
assert_eq!(g.column_count(1000.0), 2, "5 would fit; cap wins");
assert!(
(g.column_width(1000.0) - 500.0).abs() < 0.01,
"columns share the width between the CAPPED count, got {}",
g.column_width(1000.0)
);
assert!((g.used_width(1000.0) - 1000.0).abs() < 0.01);
}
#[test]
fn max_columns_does_not_raise_the_count() {
let g = adaptive(400.0, None, 0.0).with_max_columns(Some(5));
assert_eq!(g.column_count(1000.0), 2, "only 2 fit; cap is a ceiling");
}
#[test]
fn max_columns_floors_at_one() {
let g = adaptive(200.0, None, 0.0).with_max_columns(Some(0));
assert_eq!(g.column_count(1000.0), 1);
}
#[test]
fn max_columns_none_is_uncapped() {
let g = adaptive(200.0, None, 0.0).with_max_columns(None);
assert_eq!(g.column_count(1000.0), 5);
}
#[test]
fn fixed_count_ignores_width() {
let g = ColumnGeometry::from_policy(WidthPolicy::Count(3), 0.0, EdgeInsets::ZERO);
assert_eq!(g.column_count(1000.0), 3);
assert_eq!(g.column_count(50.0), 3);
let (_, w) = g.column_x(0, 900.0);
assert!((w - 300.0).abs() < 0.01);
}
#[test]
fn fixed_count_floors_at_one() {
let g = ColumnGeometry::from_policy(WidthPolicy::Count(0), 0.0, EdgeInsets::ZERO);
assert_eq!(g.column_count(1000.0), 1);
}
#[test]
fn fixed_width_does_not_stretch() {
let g = ColumnGeometry::from_policy(WidthPolicy::Fixed(150.0), 0.0, EdgeInsets::ZERO);
assert_eq!(g.column_count(1000.0), 6);
let (_, w) = g.column_x(0, 1000.0);
assert!((w - 150.0).abs() < 0.01, "fixed width never stretches");
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
fn arb_width() -> impl Strategy<Value = f32> {
prop_oneof![Just(0.0_f32), Just(1.0_f32), 1.0f32..4000.0_f32,]
}
fn arb_min_width() -> impl Strategy<Value = f32> {
prop_oneof![Just(0.0_f32), 1.0f32..600.0_f32,]
}
fn arb_gap() -> impl Strategy<Value = f32> {
prop_oneof![
Just(0.0_f32),
Just(-25.0_f32),
0.0f32..80.0_f32,
Just(10_000.0_f32),
]
}
fn arb_max_columns() -> impl Strategy<Value = Option<usize>> {
prop_oneof![Just(None), (1usize..6usize).prop_map(Some),]
}
fn arb_max_column_width() -> impl Strategy<Value = Option<f32>> {
prop_oneof![Just(None), (1.0f32..600.0_f32).prop_map(Some),]
}
fn adaptive(min: f32, gap: f32, max_columns: Option<usize>) -> ColumnGeometry {
ColumnGeometry::from_policy(
WidthPolicy::Adaptive { min, max: None },
gap,
EdgeInsets::ZERO,
)
.with_max_columns(max_columns)
}
proptest! {
#[test]
fn column_count_never_exceeds_max_columns(
width in arb_width(), min in arb_min_width(), gap in arb_gap(), max in 1usize..6usize,
) {
let g = adaptive(min, gap, Some(max));
let count = g.column_count(width);
prop_assert!(
count <= max,
"column_count {} exceeds max_columns {} at width {}", count, max, width
);
}
}
proptest! {
#[test]
fn column_count_is_always_at_least_one(
width in arb_width(), min in arb_min_width(), gap in arb_gap(), max in arb_max_columns(),
) {
let g = adaptive(min, gap, max);
prop_assert!(
g.column_count(width) >= 1,
"column_count returned 0 at width {} min {} gap {}", width, min, gap
);
}
}
proptest! {
#[test]
fn adaptive_count_matches_the_documented_floor_formula(
width in 1.0f32..4000.0_f32, min in 1.0f32..600.0_f32, gap in 0.0f32..80.0_f32,
) {
const EPSILON: f32 = 1e-4;
let g = adaptive(min, gap, None);
let expected = (((width + gap) / (min + gap) + EPSILON).floor() as i64).max(1) as usize;
prop_assert_eq!(
g.column_count(width), expected,
"formula mismatch at width {} min {} gap {}", width, min, gap
);
}
}
proptest! {
#[test]
fn column_count_is_monotone_nondecreasing_in_width(
min in arb_min_width(), gap in arb_gap(), max in arb_max_columns(),
narrow in 0.0f32..2000.0_f32, extra in 0.0f32..2000.0_f32,
) {
let g = adaptive(min, gap, max);
let wide = narrow + extra;
let (count_narrow, count_wide) = (g.column_count(narrow), g.column_count(wide));
prop_assert!(
count_wide >= count_narrow,
"count dropped from {} to {} when width grew from {} to {}",
count_narrow, count_wide, narrow, wide
);
}
}
proptest! {
#[test]
fn negative_gap_is_equivalent_to_zero_gap(
width in arb_width(), min in arb_min_width(), neg_gap in -500.0f32..0.0_f32, max in arb_max_columns(),
) {
let with_negative = adaptive(min, neg_gap, max);
let with_zero = adaptive(min, 0.0, max);
prop_assert_eq!(
with_negative.column_count(width), with_zero.column_count(width),
"negative gap {} was not clamped the same as zero", neg_gap
);
prop_assert!(
(with_negative.column_width(width) - with_zero.column_width(width)).abs() < 0.01,
"negative gap {} produced a different column width than zero", neg_gap
);
}
}
proptest! {
#[test]
fn used_width_never_exceeds_available_width(
width in arb_width(), min in arb_min_width(), gap in arb_gap(),
max_w in arb_max_column_width(), max_columns in arb_max_columns(),
) {
let g = ColumnGeometry::from_policy(WidthPolicy::Adaptive { min, max: max_w }, gap, EdgeInsets::ZERO)
.with_max_columns(max_columns);
let used = g.used_width(width);
let available = g.available_width(width);
prop_assert!(
used <= available + 0.05,
"used_width {} exceeds available_width {} at viewport {} (min {} gap {} max_w {:?} max_columns {:?})",
used, available, width, min, gap, max_w, max_columns
);
}
}
proptest! {
#[test]
fn column_width_never_exceeds_the_configured_max_when_set(
width in arb_width(), min in arb_min_width(), gap in arb_gap(), max_w in 1.0f32..600.0_f32,
) {
let g = ColumnGeometry::from_policy(
WidthPolicy::Adaptive { min, max: Some(max_w) }, gap, EdgeInsets::ZERO,
);
let w = g.column_width(width);
prop_assert!(
w <= max_w + 0.01,
"column_width {} exceeds configured max_column_width {} (viewport {} min {} gap {})",
w, max_w, width, min, gap
);
}
}
}