use std::collections::HashMap;
use teksilo_canvas::Rect;
use super::PaneBoundaries;
use super::column::{Column, ColumnWidth};
pub(crate) struct ColumnSolver;
impl ColumnSolver {
#[cfg(test)]
pub(crate) fn resolve<T: 'static>(
columns: &[Column<T>],
available_width: f32,
min_width_default: f32,
overrides: &HashMap<String, f32>,
) -> Vec<f32> {
let order: Vec<usize> = (0..columns.len()).collect();
Self::resolve_in_order(
columns,
&order,
available_width,
min_width_default,
overrides,
)
}
pub(crate) fn resolve_in_order<T: 'static>(
columns: &[Column<T>],
display_order: &[usize],
available_width: f32,
min_width_default: f32,
overrides: &HashMap<String, f32>,
) -> Vec<f32> {
if display_order.is_empty() {
return Vec::new();
}
let mut widths = vec![0.0_f32; display_order.len()];
let mut flex_total: f32 = 0.0;
let mut consumed: f32 = 0.0;
for (slot, &col_idx) in display_order.iter().enumerate() {
let col = &columns[col_idx];
let floor = col.min_width.unwrap_or(min_width_default);
if let Some(&override_w) = overrides.get(&col.id) {
let clamped = clamp(override_w, floor, col.max_width);
widths[slot] = clamped;
consumed += clamped;
continue;
}
match col.width {
ColumnWidth::Fixed(px) => {
let clamped = clamp(px, floor, col.max_width);
widths[slot] = clamped;
consumed += clamped;
}
ColumnWidth::Auto => {
let clamped = clamp(floor, floor, col.max_width);
widths[slot] = clamped;
consumed += clamped;
}
ColumnWidth::Flex(factor) => {
flex_total += factor.max(0.0);
}
}
}
let leftover = (available_width - consumed).max(0.0);
if flex_total > 0.0 {
struct FlexSlot {
slot: usize,
factor: f32,
floor: f32,
max: Option<f32>,
}
let mut pool: Vec<FlexSlot> = display_order
.iter()
.enumerate()
.filter_map(|(slot, &col_idx)| {
let col = &columns[col_idx];
if overrides.contains_key(&col.id) {
return None;
}
match col.width {
ColumnWidth::Flex(factor) => Some(FlexSlot {
slot,
factor: factor.max(0.0),
floor: col.min_width.unwrap_or(min_width_default),
max: col.max_width,
}),
_ => None,
}
})
.collect();
let mut pool_leftover = leftover;
let mut pool_flex_total: f32 = pool.iter().map(|s| s.factor).sum();
while !pool.is_empty() {
if pool_flex_total <= 0.0 {
for slot in &pool {
widths[slot.slot] = slot.floor;
}
break;
}
let round_leftover = pool_leftover;
let round_flex_total = pool_flex_total;
let mut next_pool = Vec::with_capacity(pool.len());
let mut any_clamped = false;
for slot in pool {
let share = round_leftover * (slot.factor / round_flex_total);
let violates = share < slot.floor || slot.max.is_some_and(|m| share > m);
if violates {
let clamped = clamp(share, slot.floor, slot.max);
widths[slot.slot] = clamped;
pool_leftover -= clamped;
pool_flex_total -= slot.factor;
any_clamped = true;
} else {
next_pool.push(slot);
}
}
if !any_clamped {
for slot in &next_pool {
let share = pool_leftover * (slot.factor / pool_flex_total);
widths[slot.slot] = share;
}
break;
}
pool_leftover = pool_leftover.max(0.0);
pool = next_pool;
}
}
widths
}
#[allow(dead_code)]
pub(crate) fn total_width(widths: &[f32]) -> f32 {
widths.iter().sum()
}
#[allow(dead_code)]
pub(crate) fn x_offset(widths: &[f32], i: usize) -> f32 {
widths.iter().take(i).sum()
}
}
fn clamp(value: f32, min: f32, max: Option<f32>) -> f32 {
let m = max.unwrap_or(f32::INFINITY);
value.max(min).min(m)
}
fn sum_range(widths: &[f32], range: std::ops::Range<usize>) -> f32 {
let start = range.start.min(widths.len());
let end = range.end.min(widths.len()).max(start);
widths[start..end].iter().sum()
}
pub(crate) fn pane_widths(widths: &[f32], boundaries: PaneBoundaries) -> (f32, f32, f32) {
let leading = sum_range(widths, 0..boundaries.leading_count);
let middle = sum_range(widths, boundaries.leading_count..boundaries.middle_end);
let trailing = sum_range(widths, boundaries.middle_end..widths.len());
(leading, middle, trailing)
}
pub(crate) fn middle_viewport_width(
band_width: f32,
widths: &[f32],
boundaries: PaneBoundaries,
) -> f32 {
let (leading, _, trailing) = pane_widths(widths, boundaries);
(band_width - leading - trailing).max(0.0)
}
pub(crate) fn max_scroll_x(band_width: f32, widths: &[f32], boundaries: PaneBoundaries) -> f32 {
let (_, middle_content, _) = pane_widths(widths, boundaries);
let viewport = middle_viewport_width(band_width, widths, boundaries);
(middle_content - viewport).max(0.0)
}
pub(crate) fn band_rects(
bounds: Rect,
widths: &[f32],
boundaries: PaneBoundaries,
rtl: bool,
) -> (Rect, Rect, Rect) {
let (leading_w, _, trailing_w) = pane_widths(widths, boundaries);
let middle_w = middle_viewport_width(bounds.width, widths, boundaries);
if rtl {
let leading = Rect::new(
bounds.right() - leading_w,
bounds.y,
leading_w,
bounds.height,
);
let trailing = Rect::new(bounds.x, bounds.y, trailing_w, bounds.height);
let middle = Rect::new(bounds.x + trailing_w, bounds.y, middle_w, bounds.height);
(leading, middle, trailing)
} else {
let leading = Rect::new(bounds.x, bounds.y, leading_w, bounds.height);
let middle = Rect::new(bounds.x + leading_w, bounds.y, middle_w, bounds.height);
let trailing = Rect::new(
bounds.x + bounds.width - trailing_w,
bounds.y,
trailing_w,
bounds.height,
);
(leading, middle, trailing)
}
}
pub(crate) fn column_logical_x(
widths: &[f32],
boundaries: PaneBoundaries,
scroll_x: f32,
band_width: f32,
slot: usize,
) -> Option<f32> {
if slot >= widths.len() {
return None;
}
if slot < boundaries.leading_count {
return Some(sum_range(widths, 0..slot));
}
let (leading_w, _, trailing_w) = pane_widths(widths, boundaries);
if slot < boundaries.middle_end {
let within = sum_range(widths, boundaries.leading_count..slot);
return Some(leading_w - scroll_x + within);
}
let within = sum_range(widths, boundaries.middle_end..slot);
Some(band_width - trailing_w + within)
}
pub(crate) fn insertion_slot_at_x(
widths: &[f32],
boundaries: PaneBoundaries,
scroll_x: f32,
band_width: f32,
x: f32,
) -> usize {
let leading_end = boundaries.leading_count.min(widths.len());
let mut cursor = 0.0;
for i in 0..leading_end {
let w = widths[i];
if x < cursor + w * 0.5 {
return i;
}
cursor += w;
}
let (leading_w, _, trailing_w) = pane_widths(widths, boundaries);
let middle_end = boundaries.middle_end.min(widths.len()).max(leading_end);
let mut cursor = leading_w - scroll_x;
for i in leading_end..middle_end {
let w = widths[i];
if x < cursor + w * 0.5 {
return i;
}
cursor += w;
}
let mut cursor = band_width - trailing_w;
for i in middle_end..widths.len() {
let w = widths[i];
if x < cursor + w * 0.5 {
return i;
}
cursor += w;
}
widths.len()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::TextWidget;
use crate::table_view::column::{CellContext, Column};
use teksilo_i18n::lit;
fn col(id: &str, w: ColumnWidth) -> Column<&'static str> {
Column::<&str>::new(id, lit!("h"), |_, _: &CellContext| {
Box::new(TextWidget::new(lit!("x")))
})
.width(w)
}
#[test]
fn fixed_widths_pass_through() {
let cols = vec![
col("a", ColumnWidth::Fixed(80.0)),
col("b", ColumnWidth::Fixed(120.0)),
];
let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
assert_eq!(widths, vec![80.0, 120.0]);
}
#[test]
fn flex_columns_split_leftover() {
let cols = vec![
col("a", ColumnWidth::Fixed(100.0)),
col("b", ColumnWidth::Flex(1.0)),
col("c", ColumnWidth::Flex(2.0)),
];
let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
assert_eq!(widths[0], 100.0);
assert!((widths[1] - 100.0).abs() < 0.01);
assert!((widths[2] - 200.0).abs() < 0.01);
}
#[test]
fn flex_clamps_to_min_width() {
let cols = vec![
col("a", ColumnWidth::Fixed(380.0)),
col("b", ColumnWidth::Flex(1.0)).min_width(60.0),
];
let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
assert_eq!(widths[1], 60.0);
}
#[test]
fn flex_clamps_to_max_width() {
let cols = vec![col("a", ColumnWidth::Flex(1.0)).max_width(120.0)];
let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
assert_eq!(widths[0], 120.0);
}
#[test]
fn fixed_clamps_to_min_when_below() {
let cols = vec![col("a", ColumnWidth::Fixed(10.0)).min_width(60.0)];
let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
assert_eq!(widths[0], 60.0);
}
#[test]
fn fixed_clamps_to_max_when_above() {
let cols = vec![col("a", ColumnWidth::Fixed(500.0)).max_width(180.0)];
let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
assert_eq!(widths[0], 180.0);
}
#[test]
fn auto_falls_back_to_min_default() {
let cols = vec![col("a", ColumnWidth::Auto)];
let widths = ColumnSolver::resolve(&cols, 400.0, 48.0, &HashMap::new());
assert_eq!(widths[0], 48.0);
}
#[test]
fn auto_with_min_uses_min() {
let cols = vec![col("a", ColumnWidth::Auto).min_width(100.0)];
let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
assert_eq!(widths[0], 100.0);
}
#[test]
fn no_flex_no_overflow() {
let cols = vec![
col("a", ColumnWidth::Fixed(80.0)),
col("b", ColumnWidth::Fixed(120.0)),
];
let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
assert_eq!(ColumnSolver::total_width(&widths), 200.0);
}
#[test]
fn x_offset_walks_widths() {
let widths = vec![80.0, 120.0, 60.0];
assert_eq!(ColumnSolver::x_offset(&widths, 0), 0.0);
assert_eq!(ColumnSolver::x_offset(&widths, 1), 80.0);
assert_eq!(ColumnSolver::x_offset(&widths, 2), 200.0);
assert_eq!(ColumnSolver::x_offset(&widths, 3), 260.0);
}
#[test]
fn empty_columns_returns_empty() {
let cols: Vec<Column<&'static str>> = vec![];
let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
assert!(widths.is_empty());
}
#[test]
fn override_pins_column_regardless_of_width_policy() {
let cols = vec![
col("a", ColumnWidth::Flex(1.0)),
col("b", ColumnWidth::Flex(1.0)),
];
let mut over = HashMap::new();
over.insert("a".to_string(), 250.0);
let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &over);
assert_eq!(widths[0], 250.0);
assert!((widths[1] - 150.0).abs() < 0.01, "got {}", widths[1]);
}
#[test]
fn override_clamps_to_min_max() {
let cols = vec![
col("a", ColumnWidth::Flex(1.0))
.min_width(80.0)
.max_width(200.0),
];
let mut over = HashMap::new();
over.insert("a".to_string(), 5.0); let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &over);
assert_eq!(widths[0], 80.0);
let mut over = HashMap::new();
over.insert("a".to_string(), 999.0); let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &over);
assert_eq!(widths[0], 200.0);
}
#[test]
fn negative_leftover_keeps_min() {
let cols = vec![
col("a", ColumnWidth::Fixed(500.0)),
col("b", ColumnWidth::Flex(1.0)).min_width(50.0),
];
let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
assert_eq!(widths[1], 50.0);
}
#[test]
fn zero_flex_factor_treated_as_zero_share() {
let cols = vec![
col("a", ColumnWidth::Flex(0.0)).min_width(40.0),
col("b", ColumnWidth::Flex(1.0)),
];
let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
assert_eq!(widths[0], 40.0);
assert_eq!(widths[1], 360.0);
}
#[test]
fn flex_min_width_redistributes_to_siblings() {
let cols = vec![
col("fixed", ColumnWidth::Fixed(100.0)),
col("a", ColumnWidth::Flex(1.0)),
col("b", ColumnWidth::Flex(1.0)).min_width(200.0),
];
let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
assert_eq!(widths[0], 100.0);
assert_eq!(widths[1], 100.0);
assert_eq!(widths[2], 200.0);
assert_eq!(ColumnSolver::total_width(&widths), 400.0);
}
#[test]
fn flex_min_widths_that_oversubscribe_the_pane_still_overflow() {
let cols = vec![
col("a", ColumnWidth::Flex(1.0)).min_width(300.0),
col("b", ColumnWidth::Flex(1.0)).min_width(300.0),
];
let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
assert_eq!(widths[0], 300.0);
assert_eq!(widths[1], 300.0);
}
#[test]
fn pane_widths_splits_leading_middle_trailing() {
let widths = [50.0, 60.0, 70.0, 80.0, 90.0];
let b = PaneBoundaries::new(1, 4);
assert_eq!(pane_widths(&widths, b), (50.0, 210.0, 90.0));
}
#[test]
fn pane_widths_all_middle_when_unpinned() {
let widths = [50.0, 60.0, 70.0];
let b = PaneBoundaries::new(0, 3);
assert_eq!(pane_widths(&widths, b), (0.0, 180.0, 0.0));
}
#[test]
fn middle_viewport_width_is_band_minus_pinned_panes() {
let widths = [60.0, 100.0, 100.0, 100.0, 60.0];
let b = PaneBoundaries::new(1, 4);
assert_eq!(middle_viewport_width(400.0, &widths, b), 280.0);
}
#[test]
fn middle_viewport_width_floors_at_zero_when_pinned_panes_overflow() {
let widths = [300.0, 100.0, 300.0];
let b = PaneBoundaries::new(1, 2);
assert_eq!(middle_viewport_width(400.0, &widths, b), 0.0);
}
#[test]
fn max_scroll_x_is_zero_when_content_fits() {
let widths = [60.0, 100.0, 60.0];
let b = PaneBoundaries::new(1, 2);
assert_eq!(max_scroll_x(400.0, &widths, b), 0.0);
}
#[test]
fn max_scroll_x_clamps_after_a_pane_shrink() {
let widths = [60.0, 500.0, 60.0];
let b = PaneBoundaries::new(1, 2);
assert_eq!(max_scroll_x(400.0, &widths, b), 500.0 - 280.0);
let narrower = max_scroll_x(300.0, &widths, b);
assert_eq!(narrower, 500.0 - (300.0 - 120.0));
assert!(narrower > 0.0);
assert_eq!(max_scroll_x(50.0, &widths, b), 500.0);
}
#[test]
fn band_rects_ltr_places_leading_left_middle_center_trailing_right() {
let widths = [60.0, 200.0, 60.0];
let b = PaneBoundaries::new(1, 2);
let bounds = Rect::new(10.0, 20.0, 400.0, 30.0);
let (leading, middle, trailing) = band_rects(bounds, &widths, b, false);
assert_eq!(leading, Rect::new(10.0, 20.0, 60.0, 30.0));
assert_eq!(middle, Rect::new(70.0, 20.0, 280.0, 30.0));
assert_eq!(trailing, Rect::new(350.0, 20.0, 60.0, 30.0));
}
#[test]
fn band_rects_rtl_mirrors_leading_to_the_physical_right() {
let widths = [60.0, 200.0, 60.0];
let b = PaneBoundaries::new(1, 2);
let bounds = Rect::new(10.0, 20.0, 400.0, 30.0);
let (leading, middle, trailing) = band_rects(bounds, &widths, b, true);
assert_eq!(leading, Rect::new(350.0, 20.0, 60.0, 30.0));
assert_eq!(trailing, Rect::new(10.0, 20.0, 60.0, 30.0));
assert_eq!(middle, Rect::new(70.0, 20.0, 280.0, 30.0));
}
#[test]
fn column_logical_x_pinned_columns_ignore_scroll() {
let widths = [60.0, 80.0, 200.0, 60.0];
let b = PaneBoundaries::new(1, 3);
for scroll in [0.0, 40.0, 999.0] {
assert_eq!(
column_logical_x(&widths, b, scroll, 400.0, 0),
Some(0.0),
"leading column never moves"
);
assert_eq!(
column_logical_x(&widths, b, scroll, 400.0, 3),
Some(400.0 - 60.0),
"trailing column never moves"
);
}
}
#[test]
fn column_logical_x_middle_column_shifts_left_by_scroll() {
let widths = [60.0, 80.0, 200.0, 60.0];
let b = PaneBoundaries::new(1, 3);
assert_eq!(column_logical_x(&widths, b, 0.0, 400.0, 1), Some(60.0));
assert_eq!(column_logical_x(&widths, b, 25.0, 400.0, 1), Some(35.0));
assert_eq!(
column_logical_x(&widths, b, 25.0, 400.0, 2),
Some(60.0 - 25.0 + 80.0)
);
}
#[test]
fn column_logical_x_out_of_range_is_none() {
let widths = [60.0, 80.0];
let b = PaneBoundaries::new(0, 2);
assert_eq!(column_logical_x(&widths, b, 0.0, 400.0, 2), None);
}
#[test]
fn insertion_slot_at_x_finds_pinned_and_scrolled_columns() {
let widths = [60.0, 80.0, 200.0, 60.0];
let b = PaneBoundaries::new(1, 3);
assert_eq!(insertion_slot_at_x(&widths, b, 0.0, 400.0, 0.0), 0);
assert_eq!(insertion_slot_at_x(&widths, b, 0.0, 400.0, 10_000.0), 4);
assert_eq!(insertion_slot_at_x(&widths, b, 0.0, 400.0, 65.0), 1);
assert_eq!(insertion_slot_at_x(&widths, b, 70.0, 400.0, 65.0), 2);
}
}