Skip to main content

teksilo_widgets/table_view/
layout.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Column-width resolution + per-pane horizontal layout.
5//!
6//! `ColumnSolver` resolves a list of `ColumnWidth` declarations against
7//! the available pane width, in four passes:
8//!
9//! 1. `Fixed(px)` is clamped by `min_width` / `max_width`.
10//! 2. `Auto` evaluates to a fallback width (the table's
11//!    `min_column_width_default`; a future pass will probe the header label
12//!    and visible cells).
13//! 3. Remaining horizontal space is distributed among `Flex` columns
14//!    proportional to their flex factor, iteratively: a column whose
15//!    proportional share would violate its own `min_width`/`max_width`
16//!    is pinned to that bound and drops out of the pool, and the
17//!    leftover + flex-factor total it would have consumed is
18//!    re-shared among the columns still in play. Repeats to a fixed
19//!    point (the same clamp-and-redistribute shape as the framework's
20//!    `LayoutResponse` shrink algorithm) so floor violations on one
21//!    column don't starve or overrun its siblings.
22//! 4. With `stretch_last`, whatever width the three passes left unclaimed
23//!    goes to the last column in display order (Qt's `stretchLastSection`).
24//!    Only the *gap* — a `Flex` column already takes every spare pixel, so
25//!    this is a no-op for it and matters for pixel-sized tables; and the
26//!    last column's own override is ignored while it stretches, since the
27//!    stretch would undo it anyway.
28//!
29//! The output is a parallel `Vec<f32>` of resolved widths matching the
30//! input column order.
31
32use std::collections::HashMap;
33
34use teksilo_canvas::Rect;
35
36use super::PaneBoundaries;
37use super::column::{ColumnSpec, ColumnWidth, HasColumnSpec, PinnedSide};
38
39/// Stateless solver — pure function in struct form so the call site reads
40/// clearly and so future caching can hang off `&mut self` without breaking
41/// the API.
42pub(crate) struct ColumnSolver;
43
44impl ColumnSolver {
45    /// Test-only convenience: resolve in declaration order.
46    #[cfg(test)]
47    pub(crate) fn resolve<C: HasColumnSpec>(
48        columns: &[C],
49        available_width: f32,
50        min_width_default: f32,
51        overrides: &HashMap<String, f32>,
52    ) -> Vec<f32> {
53        let order: Vec<usize> = (0..columns.len()).collect();
54        Self::resolve_in_order(
55            columns,
56            &order,
57            available_width,
58            min_width_default,
59            overrides,
60            false,
61        )
62    }
63
64    /// Test-only convenience: [`Self::resolve`] with `stretch_last` on.
65    #[cfg(test)]
66    pub(crate) fn resolve_stretch_last<C: HasColumnSpec>(
67        columns: &[C],
68        available_width: f32,
69        min_width_default: f32,
70        overrides: &HashMap<String, f32>,
71    ) -> Vec<f32> {
72        let order: Vec<usize> = (0..columns.len()).collect();
73        Self::resolve_in_order(
74            columns,
75            &order,
76            available_width,
77            min_width_default,
78            overrides,
79            true,
80        )
81    }
82
83    /// Resolve widths for the given columns, returning a `Vec<f32>` in
84    /// **display order** (parallel to `display_order`). Each entry is
85    /// the resolved width of `columns[display_order[i]]`.
86    ///
87    /// `stretch_last` is pass 4 of the module docs: the last display slot
88    /// absorbs any width the other passes leave unclaimed, and its own
89    /// override is ignored.
90    pub(crate) fn resolve_in_order<C: HasColumnSpec>(
91        columns: &[C],
92        display_order: &[usize],
93        available_width: f32,
94        min_width_default: f32,
95        overrides: &HashMap<String, f32>,
96        stretch_last: bool,
97    ) -> Vec<f32> {
98        if display_order.is_empty() {
99            return Vec::new();
100        }
101
102        let mut widths = vec![0.0_f32; display_order.len()];
103        let mut flex_total: f32 = 0.0;
104        let mut consumed: f32 = 0.0;
105        let last_slot = display_order.len() - 1;
106        // The stretched column's override is ignored: the stretch would
107        // undo a smaller one on the next pass and a larger one would only
108        // push the pane into overflow, so neither can mean anything.
109        let override_of = |slot: usize, col: &ColumnSpec| -> Option<f32> {
110            if stretch_last && slot == last_slot {
111                None
112            } else {
113                overrides.get(&col.id).copied()
114            }
115        };
116
117        // Pass 1 + 2: Fixed, Auto, and any signal-overridden columns
118        // resolve to concrete widths.
119        for (slot, &col_idx) in display_order.iter().enumerate() {
120            let col = columns[col_idx].column_spec();
121            let floor = col.min_width.unwrap_or(min_width_default);
122            if let Some(override_w) = override_of(slot, col) {
123                let clamped = clamp(override_w, floor, col.max_width);
124                widths[slot] = clamped;
125                consumed += clamped;
126                continue;
127            }
128            match col.width {
129                ColumnWidth::Fixed(px) => {
130                    let clamped = clamp(px, floor, col.max_width);
131                    widths[slot] = clamped;
132                    consumed += clamped;
133                }
134                ColumnWidth::Auto => {
135                    let clamped = clamp(floor, floor, col.max_width);
136                    widths[slot] = clamped;
137                    consumed += clamped;
138                }
139                ColumnWidth::Flex(factor) => {
140                    flex_total += factor.max(0.0);
141                }
142            }
143        }
144
145        // Pass 3: distribute leftover space among un-overridden Flex
146        // columns. See the module doc for the clamp-and-redistribute
147        // shape; a single unclamped pass would let one column's floor
148        // violation either starve its siblings (their share stays
149        // computed against the pre-floor leftover) or, if the floor
150        // exceeds the pre-floor share by only a little, silently push
151        // the resolved total past the pane.
152        let leftover = (available_width - consumed).max(0.0);
153        if flex_total > 0.0 {
154            struct FlexSlot {
155                slot: usize,
156                factor: f32,
157                floor: f32,
158                max: Option<f32>,
159            }
160            let mut pool: Vec<FlexSlot> = display_order
161                .iter()
162                .enumerate()
163                .filter_map(|(slot, &col_idx)| {
164                    let col = columns[col_idx].column_spec();
165                    if override_of(slot, col).is_some() {
166                        return None;
167                    }
168                    match col.width {
169                        ColumnWidth::Flex(factor) => Some(FlexSlot {
170                            slot,
171                            factor: factor.max(0.0),
172                            floor: col.min_width.unwrap_or(min_width_default),
173                            max: col.max_width,
174                        }),
175                        _ => None,
176                    }
177                })
178                .collect();
179
180            let mut pool_leftover = leftover;
181            let mut pool_flex_total: f32 = pool.iter().map(|s| s.factor).sum();
182
183            while !pool.is_empty() {
184                if pool_flex_total <= 0.0 {
185                    // No factor left to key a share off (every
186                    // remaining column has a zero flex factor) —
187                    // whatever's left falls back to each floor.
188                    for slot in &pool {
189                        widths[slot.slot] = slot.floor;
190                    }
191                    break;
192                }
193                // One round: shares are computed against this round's
194                // leftover/total for every still-pooled column before
195                // any of them are removed, so removal order within a
196                // round never biases which columns clamp.
197                let round_leftover = pool_leftover;
198                let round_flex_total = pool_flex_total;
199                let mut next_pool = Vec::with_capacity(pool.len());
200                let mut any_clamped = false;
201                for slot in pool {
202                    let share = round_leftover * (slot.factor / round_flex_total);
203                    let violates = share < slot.floor || slot.max.is_some_and(|m| share > m);
204                    if violates {
205                        let clamped = clamp(share, slot.floor, slot.max);
206                        widths[slot.slot] = clamped;
207                        pool_leftover -= clamped;
208                        pool_flex_total -= slot.factor;
209                        any_clamped = true;
210                    } else {
211                        next_pool.push(slot);
212                    }
213                }
214                if !any_clamped {
215                    // Fixed point: every remaining column's proportional
216                    // share already fits within its bounds.
217                    for slot in &next_pool {
218                        let share = pool_leftover * (slot.factor / pool_flex_total);
219                        widths[slot.slot] = share;
220                    }
221                    break;
222                }
223                pool_leftover = pool_leftover.max(0.0);
224                pool = next_pool;
225            }
226        }
227
228        // Pass 4: the last column takes the gap. Only ever *adds* — once
229        // the others overflow the pane there is nothing to stretch into and
230        // the column keeps the width the passes above gave it.
231        if stretch_last {
232            let gap = available_width - widths.iter().sum::<f32>();
233            if gap > 0.0 {
234                let col = columns[display_order[last_slot]].column_spec();
235                let floor = col.min_width.unwrap_or(min_width_default);
236                widths[last_slot] = clamp(widths[last_slot] + gap, floor, col.max_width);
237            }
238        }
239
240        widths
241    }
242
243    /// Sum of resolved widths. Retained for pane partitioning — no production
244    /// caller today (`pane_widths` sums its own ranges), hence the
245    /// `allow(dead_code)`.
246    #[allow(dead_code)]
247    pub(crate) fn total_width(widths: &[f32]) -> f32 {
248        widths.iter().sum()
249    }
250
251    /// X-offset of column `i` relative to the pane's leading edge.
252    /// Retained for column-resize hit testing — no production caller today;
253    /// the header's grip works from its own cell bounds (`header.rs`,
254    /// `resize_grip`), hence the `allow(dead_code)`.
255    #[allow(dead_code)]
256    pub(crate) fn x_offset(widths: &[f32], i: usize) -> f32 {
257        widths.iter().take(i).sum()
258    }
259}
260
261fn clamp(value: f32, min: f32, max: Option<f32>) -> f32 {
262    let m = max.unwrap_or(f32::INFINITY);
263    value.max(min).min(m)
264}
265
266/// The order columns are displayed in, as indices into `columns`, and the pane
267/// partition that order produces.
268///
269/// Columns are grouped by effective pinning — `pinning`'s entry for the id,
270/// else the declared [`PinnedSide`] — Leading first, then unpinned, then
271/// Trailing. Within a group they follow `order`; columns `order` does not name
272/// come after the ones it does, in declaration order. An id in `order` or
273/// `pinning` that names no column changes nothing.
274///
275/// One function for `TableView`, `TreeTableView` and `TableHeader`, so a view
276/// and the header it composes cannot disagree about where a column is.
277pub(crate) fn display_order<C: HasColumnSpec>(
278    columns: &[C],
279    order: &[String],
280    pinning: &HashMap<String, PinnedSide>,
281) -> (Vec<usize>, PaneBoundaries) {
282    let position: HashMap<&str, usize> = order
283        .iter()
284        .enumerate()
285        .map(|(i, id)| (id.as_str(), i))
286        .collect();
287    let mut leading: Vec<usize> = Vec::new();
288    let mut middle: Vec<usize> = Vec::new();
289    let mut trailing: Vec<usize> = Vec::new();
290    for (i, col) in columns.iter().enumerate() {
291        let spec = col.column_spec();
292        match pinning.get(&spec.id).copied().unwrap_or(spec.pinned) {
293            PinnedSide::Leading => leading.push(i),
294            PinnedSide::None => middle.push(i),
295            PinnedSide::Trailing => trailing.push(i),
296        }
297    }
298    // Explicit positions sort first; a column the list does not name keys on
299    // its declaration index past a huge base, so it sorts after all of them.
300    const FALLBACK_BASE: usize = usize::MAX / 2;
301    let key = |i: usize| {
302        position
303            .get(columns[i].column_spec().id.as_str())
304            .copied()
305            .unwrap_or(FALLBACK_BASE + i)
306    };
307    leading.sort_by_key(|&i| key(i));
308    middle.sort_by_key(|&i| key(i));
309    trailing.sort_by_key(|&i| key(i));
310    let mut out = Vec::with_capacity(columns.len());
311    out.extend(leading);
312    let leading_count = out.len();
313    out.extend(middle);
314    let middle_end = out.len();
315    out.extend(trailing);
316    (out, PaneBoundaries::new(leading_count, middle_end))
317}
318
319// ── Horizontal scroll / pane geometry ───────────────────────────────────────
320//
321// A row's (or the header's) `body_width`-wide band splits into up to three
322// panes by `PaneBoundaries`: Leading-pinned columns anchor at the band's own
323// leading edge, Trailing-pinned columns anchor at its trailing edge, and the
324// columns in between (the Middle pane) scroll horizontally by `scroll_x`,
325// clipped to whatever room the pinned panes leave. Every function below
326// works in **logical** (reading-order) offsets — 0 is always the band's own
327// leading edge — so a single physical mirror step at the call site (`rtl ?
328// band_width - offset - width : offset`, the same convention `BodyRow` /
329// `TableHeader` already use for their flat, unpinned cumulative walk) handles
330// RTL for pinned AND scrolled content alike; nothing here needs its own RTL
331// branch.
332
333/// Sum of the resolved widths in `widths[range]`, defensively clamped to the
334/// slice length (a display-order / widths-vector length mismatch is a
335/// pre-existing tolerated edge case elsewhere in this module — see
336/// `BodyRow`/`TableHeader`'s `fallback_w`).
337fn sum_range(widths: &[f32], range: std::ops::Range<usize>) -> f32 {
338    let start = range.start.min(widths.len());
339    let end = range.end.min(widths.len()).max(start);
340    widths[start..end].iter().sum()
341}
342
343/// `(leading_width, middle_content_width, trailing_width)` — the three
344/// panes' resolved widths. `middle_content_width` is the *unclamped* sum of
345/// the scrollable columns, i.e. the horizontal analogue of
346/// `RowMetrics::total_height` — it can exceed the viewport, which is exactly
347/// what makes scrolling necessary.
348pub(crate) fn pane_widths(widths: &[f32], boundaries: PaneBoundaries) -> (f32, f32, f32) {
349    let leading = sum_range(widths, 0..boundaries.leading_count);
350    let middle = sum_range(widths, boundaries.leading_count..boundaries.middle_end);
351    let trailing = sum_range(widths, boundaries.middle_end..widths.len());
352    (leading, middle, trailing)
353}
354
355/// Width left for the Middle pane's own viewport once the pinned panes take
356/// their share of `band_width`. Floors at 0 (pinned columns alone can
357/// outgrow the band on a very narrow table — same "just overflow" fallback
358/// the rest of this module already accepts for an over-subscribed pane).
359pub(crate) fn middle_viewport_width(
360    band_width: f32,
361    widths: &[f32],
362    boundaries: PaneBoundaries,
363) -> f32 {
364    let (leading, _, trailing) = pane_widths(widths, boundaries);
365    (band_width - leading - trailing).max(0.0)
366}
367
368/// Maximum `scroll_x` — `middle_content_width − middle_viewport_width`,
369/// floored at 0 (content that already fits needs no scroll headroom).
370pub(crate) fn max_scroll_x(band_width: f32, widths: &[f32], boundaries: PaneBoundaries) -> f32 {
371    let (_, middle_content, _) = pane_widths(widths, boundaries);
372    let viewport = middle_viewport_width(band_width, widths, boundaries);
373    (middle_content - viewport).max(0.0)
374}
375
376/// Physical rects for the Leading / Middle / Trailing bands within a header
377/// or body row's own `bounds` — the geometry `BodyRow::place_children` /
378/// `TableHeader::place_children` hand to their `RowBand` children, and that
379/// `TableView`/`TreeTableView`'s own `paint()` re-derives to clip
380/// pane-crossing root-painted decorations (vertical grid lines, the cell
381/// focus ring) to the pane the target column actually belongs to.
382///
383/// A pane with no columns collapses to a zero-width rect at its edge —
384/// harmless, since `RowBand` skips a band whose cell list is empty and a
385/// zero-width clip paints nothing.
386pub(crate) fn band_rects(
387    bounds: Rect,
388    widths: &[f32],
389    boundaries: PaneBoundaries,
390    rtl: bool,
391) -> (Rect, Rect, Rect) {
392    let (leading_w, _, trailing_w) = pane_widths(widths, boundaries);
393    let middle_w = middle_viewport_width(bounds.width, widths, boundaries);
394    if rtl {
395        let leading = Rect::new(
396            bounds.right() - leading_w,
397            bounds.y,
398            leading_w,
399            bounds.height,
400        );
401        let trailing = Rect::new(bounds.x, bounds.y, trailing_w, bounds.height);
402        let middle = Rect::new(bounds.x + trailing_w, bounds.y, middle_w, bounds.height);
403        (leading, middle, trailing)
404    } else {
405        let leading = Rect::new(bounds.x, bounds.y, leading_w, bounds.height);
406        let middle = Rect::new(bounds.x + leading_w, bounds.y, middle_w, bounds.height);
407        let trailing = Rect::new(
408            bounds.x + bounds.width - trailing_w,
409            bounds.y,
410            trailing_w,
411            bounds.height,
412        );
413        (leading, middle, trailing)
414    }
415}
416
417/// Logical x-offset (from the band's own leading edge, pre-RTL-mirror) of
418/// display slot `slot` — Leading columns are anchored at the band start
419/// (unaffected by `scroll_x`), Trailing columns are anchored at the band
420/// end (also unaffected), and Middle columns run in between, shifted by
421/// `-scroll_x`. `None` when `slot` is out of range.
422pub(crate) fn column_logical_x(
423    widths: &[f32],
424    boundaries: PaneBoundaries,
425    scroll_x: f32,
426    band_width: f32,
427    slot: usize,
428) -> Option<f32> {
429    if slot >= widths.len() {
430        return None;
431    }
432    if slot < boundaries.leading_count {
433        return Some(sum_range(widths, 0..slot));
434    }
435    let (leading_w, _, trailing_w) = pane_widths(widths, boundaries);
436    if slot < boundaries.middle_end {
437        let within = sum_range(widths, boundaries.leading_count..slot);
438        return Some(leading_w - scroll_x + within);
439    }
440    let within = sum_range(widths, boundaries.middle_end..slot);
441    Some(band_width - trailing_w + within)
442}
443
444/// Inverse of [`column_logical_x`]: given a **logical** (already
445/// RTL-un-mirrored) drop x, find the display slot whose column midpoint it
446/// falls before — the same "first column whose midpoint exceeds x" rule the
447/// column-reorder drop handler always used, generalized to account for
448/// pinning + the Middle pane's scroll offset. Returns `widths.len()` (append)
449/// when `x` is past every column.
450pub(crate) fn insertion_slot_at_x(
451    widths: &[f32],
452    boundaries: PaneBoundaries,
453    scroll_x: f32,
454    band_width: f32,
455    x: f32,
456) -> usize {
457    let leading_end = boundaries.leading_count.min(widths.len());
458    let mut cursor = 0.0;
459    for i in 0..leading_end {
460        let w = widths[i];
461        if x < cursor + w * 0.5 {
462            return i;
463        }
464        cursor += w;
465    }
466    let (leading_w, _, trailing_w) = pane_widths(widths, boundaries);
467    let middle_end = boundaries.middle_end.min(widths.len()).max(leading_end);
468    let mut cursor = leading_w - scroll_x;
469    for i in leading_end..middle_end {
470        let w = widths[i];
471        if x < cursor + w * 0.5 {
472            return i;
473        }
474        cursor += w;
475    }
476    let mut cursor = band_width - trailing_w;
477    for i in middle_end..widths.len() {
478        let w = widths[i];
479        if x < cursor + w * 0.5 {
480            return i;
481        }
482        cursor += w;
483    }
484    widths.len()
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use crate::primitives::TextWidget;
491    use crate::table_view::column::{CellContext, Column};
492    use teksilo_i18n::lit;
493
494    fn col(id: &str, w: ColumnWidth) -> Column<&'static str> {
495        Column::<&str>::new(id, lit!("h"), |_, _: &CellContext| {
496            Box::new(TextWidget::new(lit!("x")))
497        })
498        .width(w)
499    }
500
501    #[test]
502    fn fixed_widths_pass_through() {
503        let cols = vec![
504            col("a", ColumnWidth::Fixed(80.0)),
505            col("b", ColumnWidth::Fixed(120.0)),
506        ];
507        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
508        assert_eq!(widths, vec![80.0, 120.0]);
509    }
510
511    #[test]
512    fn flex_columns_split_leftover() {
513        let cols = vec![
514            col("a", ColumnWidth::Fixed(100.0)),
515            col("b", ColumnWidth::Flex(1.0)),
516            col("c", ColumnWidth::Flex(2.0)),
517        ];
518        // Leftover = 400 - 100 = 300; split 1:2 = 100 / 200.
519        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
520        assert_eq!(widths[0], 100.0);
521        assert!((widths[1] - 100.0).abs() < 0.01);
522        assert!((widths[2] - 200.0).abs() < 0.01);
523    }
524
525    #[test]
526    fn flex_clamps_to_min_width() {
527        let cols = vec![
528            col("a", ColumnWidth::Fixed(380.0)),
529            col("b", ColumnWidth::Flex(1.0)).min_width(60.0),
530        ];
531        // Leftover only 20 px but min 60 — clamped up.
532        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
533        assert_eq!(widths[1], 60.0);
534    }
535
536    #[test]
537    fn flex_clamps_to_max_width() {
538        let cols = vec![col("a", ColumnWidth::Flex(1.0)).max_width(120.0)];
539        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
540        assert_eq!(widths[0], 120.0);
541    }
542
543    #[test]
544    fn fixed_clamps_to_min_when_below() {
545        let cols = vec![col("a", ColumnWidth::Fixed(10.0)).min_width(60.0)];
546        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
547        assert_eq!(widths[0], 60.0);
548    }
549
550    #[test]
551    fn fixed_clamps_to_max_when_above() {
552        let cols = vec![col("a", ColumnWidth::Fixed(500.0)).max_width(180.0)];
553        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
554        assert_eq!(widths[0], 180.0);
555    }
556
557    #[test]
558    fn auto_falls_back_to_min_default() {
559        let cols = vec![col("a", ColumnWidth::Auto)];
560        let widths = ColumnSolver::resolve(&cols, 400.0, 48.0, &HashMap::new());
561        assert_eq!(widths[0], 48.0);
562    }
563
564    #[test]
565    fn auto_with_min_uses_min() {
566        let cols = vec![col("a", ColumnWidth::Auto).min_width(100.0)];
567        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
568        assert_eq!(widths[0], 100.0);
569    }
570
571    #[test]
572    fn no_flex_no_overflow() {
573        // Total fixed = 200, available = 400, no flex — leftover 200 stays
574        // unallocated; the table pane is wider than the column total, which
575        // is fine.
576        let cols = vec![
577            col("a", ColumnWidth::Fixed(80.0)),
578            col("b", ColumnWidth::Fixed(120.0)),
579        ];
580        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
581        assert_eq!(ColumnSolver::total_width(&widths), 200.0);
582    }
583
584    #[test]
585    fn x_offset_walks_widths() {
586        let widths = vec![80.0, 120.0, 60.0];
587        assert_eq!(ColumnSolver::x_offset(&widths, 0), 0.0);
588        assert_eq!(ColumnSolver::x_offset(&widths, 1), 80.0);
589        assert_eq!(ColumnSolver::x_offset(&widths, 2), 200.0);
590        assert_eq!(ColumnSolver::x_offset(&widths, 3), 260.0);
591    }
592
593    #[test]
594    fn empty_columns_returns_empty() {
595        let cols: Vec<Column<&'static str>> = vec![];
596        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
597        assert!(widths.is_empty());
598    }
599
600    #[test]
601    fn override_pins_column_regardless_of_width_policy() {
602        let cols = vec![
603            col("a", ColumnWidth::Flex(1.0)),
604            col("b", ColumnWidth::Flex(1.0)),
605        ];
606        let mut over = HashMap::new();
607        over.insert("a".to_string(), 250.0);
608        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &over);
609        // a is pinned to 250, b gets the leftover 150.
610        assert_eq!(widths[0], 250.0);
611        assert!((widths[1] - 150.0).abs() < 0.01, "got {}", widths[1]);
612    }
613
614    #[test]
615    fn override_clamps_to_min_max() {
616        let cols = vec![
617            col("a", ColumnWidth::Flex(1.0))
618                .min_width(80.0)
619                .max_width(200.0),
620        ];
621        let mut over = HashMap::new();
622        over.insert("a".to_string(), 5.0); // below min
623        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &over);
624        assert_eq!(widths[0], 80.0);
625
626        let mut over = HashMap::new();
627        over.insert("a".to_string(), 999.0); // above max
628        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &over);
629        assert_eq!(widths[0], 200.0);
630    }
631
632    #[test]
633    fn negative_leftover_keeps_min() {
634        let cols = vec![
635            col("a", ColumnWidth::Fixed(500.0)),
636            col("b", ColumnWidth::Flex(1.0)).min_width(50.0),
637        ];
638        // Available 400, fixed 500 — overflow. Flex still respects min.
639        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
640        assert_eq!(widths[1], 50.0);
641    }
642
643    #[test]
644    fn zero_flex_factor_treated_as_zero_share() {
645        let cols = vec![
646            col("a", ColumnWidth::Flex(0.0)).min_width(40.0),
647            col("b", ColumnWidth::Flex(1.0)),
648        ];
649        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
650        // Flex(0.0) gets a 0 share of the leftover (400), which is below
651        // its min_width (40) — it's pinned to 40 and drops out of the
652        // pool. That 40 px is subtracted from the leftover *before* `b`
653        // (the only remaining pooled column) claims the rest, so the two
654        // resolved widths sum to exactly the pane instead of overflowing
655        // it.
656        assert_eq!(widths[0], 40.0);
657        assert_eq!(widths[1], 360.0);
658    }
659
660    #[test]
661    fn flex_min_width_redistributes_to_siblings() {
662        let cols = vec![
663            col("fixed", ColumnWidth::Fixed(100.0)),
664            col("a", ColumnWidth::Flex(1.0)),
665            col("b", ColumnWidth::Flex(1.0)).min_width(200.0),
666        ];
667        // Leftover after the fixed column is 300, split evenly 1:1 —
668        // 150 apiece — but `b`'s min_width (200) wins its round and
669        // pins it there. The 200 it now consumes (not its 150 share) is
670        // subtracted from the leftover before `a`'s share is
671        // recomputed in the next round, so `a` settles at 100 instead
672        // of its stale first-round share of 150 — and the three
673        // resolved widths sum to exactly the 400 px pane rather than
674        // overflowing it by `b`'s 50 px shortfall.
675        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
676        assert_eq!(widths[0], 100.0);
677        assert_eq!(widths[1], 100.0);
678        assert_eq!(widths[2], 200.0);
679        assert_eq!(ColumnSolver::total_width(&widths), 400.0);
680    }
681
682    #[test]
683    fn flex_min_widths_that_oversubscribe_the_pane_still_overflow() {
684        // When the floors alone exceed the available width, redistribution
685        // can't help — floors win and the resolved total overflows the
686        // pane, same as a single non-iterative clamp would produce.
687        let cols = vec![
688            col("a", ColumnWidth::Flex(1.0)).min_width(300.0),
689            col("b", ColumnWidth::Flex(1.0)).min_width(300.0),
690        ];
691        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
692        assert_eq!(widths[0], 300.0);
693        assert_eq!(widths[1], 300.0);
694    }
695
696    // ── Pane geometry / horizontal scroll ───────────────────────────────
697
698    #[test]
699    fn stretch_last_hands_the_gap_to_the_last_column() {
700        let cols = vec![
701            col("a", ColumnWidth::Fixed(100.0)),
702            col("b", ColumnWidth::Fixed(100.0)),
703        ];
704        let widths = ColumnSolver::resolve_stretch_last(&cols, 400.0, 32.0, &HashMap::new());
705        assert_eq!(widths, vec![100.0, 300.0]);
706        // Off: the 200 px gap stays.
707        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
708        assert_eq!(widths, vec![100.0, 100.0]);
709    }
710
711    #[test]
712    fn stretch_last_is_a_no_op_when_a_flex_column_already_fills_the_pane() {
713        let cols = vec![
714            col("a", ColumnWidth::Flex(1.0)),
715            col("b", ColumnWidth::Fixed(100.0)),
716        ];
717        let stretched = ColumnSolver::resolve_stretch_last(&cols, 400.0, 32.0, &HashMap::new());
718        let plain = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
719        assert_eq!(stretched, plain);
720        assert_eq!(stretched, vec![300.0, 100.0]);
721    }
722
723    #[test]
724    fn stretch_last_ignores_the_last_columns_own_override() {
725        let cols = vec![
726            col("a", ColumnWidth::Fixed(100.0)),
727            col("b", ColumnWidth::Fixed(100.0)),
728        ];
729        let mut over = HashMap::new();
730        over.insert("b".to_string(), 50.0);
731        let widths = ColumnSolver::resolve_stretch_last(&cols, 400.0, 32.0, &over);
732        assert_eq!(widths, vec![100.0, 300.0]);
733        // The same override is honoured when nothing stretches.
734        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &over);
735        assert_eq!(widths, vec![100.0, 50.0]);
736        // …and an override on a *preceding* column reflows the stretched one.
737        over.insert("a".to_string(), 150.0);
738        let widths = ColumnSolver::resolve_stretch_last(&cols, 400.0, 32.0, &over);
739        assert_eq!(widths, vec![150.0, 250.0]);
740    }
741
742    #[test]
743    fn stretch_last_stops_at_the_last_columns_max_width() {
744        let cols = vec![
745            col("a", ColumnWidth::Fixed(100.0)),
746            col("b", ColumnWidth::Fixed(100.0)).max_width(150.0),
747        ];
748        let widths = ColumnSolver::resolve_stretch_last(&cols, 400.0, 32.0, &HashMap::new());
749        assert_eq!(widths, vec![100.0, 150.0]);
750    }
751
752    #[test]
753    fn stretch_last_has_nothing_to_give_once_the_others_overflow() {
754        let cols = vec![
755            col("a", ColumnWidth::Fixed(300.0)),
756            col("b", ColumnWidth::Fixed(200.0)),
757        ];
758        let widths = ColumnSolver::resolve_stretch_last(&cols, 400.0, 32.0, &HashMap::new());
759        assert_eq!(widths, vec![300.0, 200.0]);
760    }
761
762    #[test]
763    fn pane_widths_splits_leading_middle_trailing() {
764        let widths = [50.0, 60.0, 70.0, 80.0, 90.0];
765        // Slots 0..1 leading, 1..4 middle, 4.. trailing.
766        let b = PaneBoundaries::new(1, 4);
767        assert_eq!(pane_widths(&widths, b), (50.0, 210.0, 90.0));
768    }
769
770    #[test]
771    fn pane_widths_all_middle_when_unpinned() {
772        let widths = [50.0, 60.0, 70.0];
773        let b = PaneBoundaries::new(0, 3);
774        assert_eq!(pane_widths(&widths, b), (0.0, 180.0, 0.0));
775    }
776
777    #[test]
778    fn middle_viewport_width_is_band_minus_pinned_panes() {
779        let widths = [60.0, 100.0, 100.0, 100.0, 60.0];
780        let b = PaneBoundaries::new(1, 4);
781        // Band 400, leading 60, trailing 60 -> middle viewport 280.
782        assert_eq!(middle_viewport_width(400.0, &widths, b), 280.0);
783    }
784
785    #[test]
786    fn middle_viewport_width_floors_at_zero_when_pinned_panes_overflow() {
787        let widths = [300.0, 100.0, 300.0];
788        let b = PaneBoundaries::new(1, 2);
789        // Pinned panes alone (600) already exceed the 400 px band.
790        assert_eq!(middle_viewport_width(400.0, &widths, b), 0.0);
791    }
792
793    #[test]
794    fn max_scroll_x_is_zero_when_content_fits() {
795        let widths = [60.0, 100.0, 60.0];
796        let b = PaneBoundaries::new(1, 2);
797        // Middle content (100) fits the 280 px middle viewport (400-60-60).
798        assert_eq!(max_scroll_x(400.0, &widths, b), 0.0);
799    }
800
801    #[test]
802    fn max_scroll_x_clamps_after_a_pane_shrink() {
803        // Wide band: plenty of scroll headroom.
804        let widths = [60.0, 500.0, 60.0];
805        let b = PaneBoundaries::new(1, 2);
806        assert_eq!(max_scroll_x(400.0, &widths, b), 500.0 - 280.0);
807        // The pane (band) shrinks — e.g. the window narrowed. A scroll
808        // position computed against the old, larger max must still resolve
809        // to a smaller-but-still-correct max against the new band width, not
810        // go negative or panic.
811        let narrower = max_scroll_x(300.0, &widths, b);
812        assert_eq!(narrower, 500.0 - (300.0 - 120.0));
813        assert!(narrower > 0.0);
814        // Shrink until the pinned panes alone consume the whole band (the
815        // middle viewport itself floors at 0) — the scroll headroom becomes
816        // the full content width, never negative.
817        assert_eq!(max_scroll_x(50.0, &widths, b), 500.0);
818    }
819
820    #[test]
821    fn band_rects_ltr_places_leading_left_middle_center_trailing_right() {
822        let widths = [60.0, 200.0, 60.0];
823        let b = PaneBoundaries::new(1, 2);
824        let bounds = Rect::new(10.0, 20.0, 400.0, 30.0);
825        let (leading, middle, trailing) = band_rects(bounds, &widths, b, false);
826        assert_eq!(leading, Rect::new(10.0, 20.0, 60.0, 30.0));
827        assert_eq!(middle, Rect::new(70.0, 20.0, 280.0, 30.0));
828        assert_eq!(trailing, Rect::new(350.0, 20.0, 60.0, 30.0));
829    }
830
831    #[test]
832    fn band_rects_rtl_mirrors_leading_to_the_physical_right() {
833        let widths = [60.0, 200.0, 60.0];
834        let b = PaneBoundaries::new(1, 2);
835        let bounds = Rect::new(10.0, 20.0, 400.0, 30.0);
836        let (leading, middle, trailing) = band_rects(bounds, &widths, b, true);
837        // Leading pinned -> physical right edge of the band.
838        assert_eq!(leading, Rect::new(350.0, 20.0, 60.0, 30.0));
839        // Trailing pinned -> physical left edge.
840        assert_eq!(trailing, Rect::new(10.0, 20.0, 60.0, 30.0));
841        assert_eq!(middle, Rect::new(70.0, 20.0, 280.0, 30.0));
842    }
843
844    #[test]
845    fn column_logical_x_pinned_columns_ignore_scroll() {
846        let widths = [60.0, 80.0, 200.0, 60.0];
847        let b = PaneBoundaries::new(1, 3);
848        for scroll in [0.0, 40.0, 999.0] {
849            assert_eq!(
850                column_logical_x(&widths, b, scroll, 400.0, 0),
851                Some(0.0),
852                "leading column never moves"
853            );
854            assert_eq!(
855                column_logical_x(&widths, b, scroll, 400.0, 3),
856                Some(400.0 - 60.0),
857                "trailing column never moves"
858            );
859        }
860    }
861
862    #[test]
863    fn column_logical_x_middle_column_shifts_left_by_scroll() {
864        let widths = [60.0, 80.0, 200.0, 60.0];
865        let b = PaneBoundaries::new(1, 3);
866        // Middle pane starts right after the 60px leading pane.
867        assert_eq!(column_logical_x(&widths, b, 0.0, 400.0, 1), Some(60.0));
868        assert_eq!(column_logical_x(&widths, b, 25.0, 400.0, 1), Some(35.0));
869        assert_eq!(
870            column_logical_x(&widths, b, 25.0, 400.0, 2),
871            Some(60.0 - 25.0 + 80.0)
872        );
873    }
874
875    #[test]
876    fn column_logical_x_out_of_range_is_none() {
877        let widths = [60.0, 80.0];
878        let b = PaneBoundaries::new(0, 2);
879        assert_eq!(column_logical_x(&widths, b, 0.0, 400.0, 2), None);
880    }
881
882    #[test]
883    fn insertion_slot_at_x_finds_pinned_and_scrolled_columns() {
884        let widths = [60.0, 80.0, 200.0, 60.0];
885        let b = PaneBoundaries::new(1, 3);
886        // x = 0 is inside the (only) leading column's first half.
887        assert_eq!(insertion_slot_at_x(&widths, b, 0.0, 400.0, 0.0), 0);
888        // Well past everything -> append.
889        assert_eq!(insertion_slot_at_x(&widths, b, 0.0, 400.0, 10_000.0), 4);
890        // With no scroll, x just past the leading pane (60) lands in the
891        // first middle column's first half (60..60+40).
892        assert_eq!(insertion_slot_at_x(&widths, b, 0.0, 400.0, 65.0), 1);
893        // Scrolling the middle pane right by 70 slides that same physical x
894        // into what is now the SECOND middle column (slot 2): logical
895        // column 1 now starts at 60-70 = -10, ends at 70; x=65 falls in its
896        // second half, so the insertion point becomes column 2's slot.
897        assert_eq!(insertion_slot_at_x(&widths, b, 70.0, 400.0, 65.0), 2);
898    }
899}