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 three 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//!
23//! The output is a parallel `Vec<f32>` of resolved widths matching the
24//! input column order.
25
26use std::collections::HashMap;
27
28use teksilo_canvas::Rect;
29
30use super::PaneBoundaries;
31use super::column::{Column, ColumnWidth};
32
33/// Stateless solver — pure function in struct form so the call site reads
34/// clearly and so future caching can hang off `&mut self` without breaking
35/// the API.
36pub(crate) struct ColumnSolver;
37
38impl ColumnSolver {
39    /// Test-only convenience: resolve in declaration order.
40    #[cfg(test)]
41    pub(crate) fn resolve<T: 'static>(
42        columns: &[Column<T>],
43        available_width: f32,
44        min_width_default: f32,
45        overrides: &HashMap<String, f32>,
46    ) -> Vec<f32> {
47        let order: Vec<usize> = (0..columns.len()).collect();
48        Self::resolve_in_order(
49            columns,
50            &order,
51            available_width,
52            min_width_default,
53            overrides,
54        )
55    }
56
57    /// Resolve widths for the given columns, returning a `Vec<f32>` in
58    /// **display order** (parallel to `display_order`). Each entry is
59    /// the resolved width of `columns[display_order[i]]`.
60    pub(crate) fn resolve_in_order<T: 'static>(
61        columns: &[Column<T>],
62        display_order: &[usize],
63        available_width: f32,
64        min_width_default: f32,
65        overrides: &HashMap<String, f32>,
66    ) -> Vec<f32> {
67        if display_order.is_empty() {
68            return Vec::new();
69        }
70
71        let mut widths = vec![0.0_f32; display_order.len()];
72        let mut flex_total: f32 = 0.0;
73        let mut consumed: f32 = 0.0;
74
75        // Pass 1 + 2: Fixed, Auto, and any signal-overridden columns
76        // resolve to concrete widths.
77        for (slot, &col_idx) in display_order.iter().enumerate() {
78            let col = &columns[col_idx];
79            let floor = col.min_width.unwrap_or(min_width_default);
80            if let Some(&override_w) = overrides.get(&col.id) {
81                let clamped = clamp(override_w, floor, col.max_width);
82                widths[slot] = clamped;
83                consumed += clamped;
84                continue;
85            }
86            match col.width {
87                ColumnWidth::Fixed(px) => {
88                    let clamped = clamp(px, floor, col.max_width);
89                    widths[slot] = clamped;
90                    consumed += clamped;
91                }
92                ColumnWidth::Auto => {
93                    let clamped = clamp(floor, floor, col.max_width);
94                    widths[slot] = clamped;
95                    consumed += clamped;
96                }
97                ColumnWidth::Flex(factor) => {
98                    flex_total += factor.max(0.0);
99                }
100            }
101        }
102
103        // Pass 3: distribute leftover space among un-overridden Flex
104        // columns. See the module doc for the clamp-and-redistribute
105        // shape; a single unclamped pass would let one column's floor
106        // violation either starve its siblings (their share stays
107        // computed against the pre-floor leftover) or, if the floor
108        // exceeds the pre-floor share by only a little, silently push
109        // the resolved total past the pane.
110        let leftover = (available_width - consumed).max(0.0);
111        if flex_total > 0.0 {
112            struct FlexSlot {
113                slot: usize,
114                factor: f32,
115                floor: f32,
116                max: Option<f32>,
117            }
118            let mut pool: Vec<FlexSlot> = display_order
119                .iter()
120                .enumerate()
121                .filter_map(|(slot, &col_idx)| {
122                    let col = &columns[col_idx];
123                    if overrides.contains_key(&col.id) {
124                        return None;
125                    }
126                    match col.width {
127                        ColumnWidth::Flex(factor) => Some(FlexSlot {
128                            slot,
129                            factor: factor.max(0.0),
130                            floor: col.min_width.unwrap_or(min_width_default),
131                            max: col.max_width,
132                        }),
133                        _ => None,
134                    }
135                })
136                .collect();
137
138            let mut pool_leftover = leftover;
139            let mut pool_flex_total: f32 = pool.iter().map(|s| s.factor).sum();
140
141            while !pool.is_empty() {
142                if pool_flex_total <= 0.0 {
143                    // No factor left to key a share off (every
144                    // remaining column has a zero flex factor) —
145                    // whatever's left falls back to each floor.
146                    for slot in &pool {
147                        widths[slot.slot] = slot.floor;
148                    }
149                    break;
150                }
151                // One round: shares are computed against this round's
152                // leftover/total for every still-pooled column before
153                // any of them are removed, so removal order within a
154                // round never biases which columns clamp.
155                let round_leftover = pool_leftover;
156                let round_flex_total = pool_flex_total;
157                let mut next_pool = Vec::with_capacity(pool.len());
158                let mut any_clamped = false;
159                for slot in pool {
160                    let share = round_leftover * (slot.factor / round_flex_total);
161                    let violates = share < slot.floor || slot.max.is_some_and(|m| share > m);
162                    if violates {
163                        let clamped = clamp(share, slot.floor, slot.max);
164                        widths[slot.slot] = clamped;
165                        pool_leftover -= clamped;
166                        pool_flex_total -= slot.factor;
167                        any_clamped = true;
168                    } else {
169                        next_pool.push(slot);
170                    }
171                }
172                if !any_clamped {
173                    // Fixed point: every remaining column's proportional
174                    // share already fits within its bounds.
175                    for slot in &next_pool {
176                        let share = pool_leftover * (slot.factor / pool_flex_total);
177                        widths[slot.slot] = share;
178                    }
179                    break;
180                }
181                pool_leftover = pool_leftover.max(0.0);
182                pool = next_pool;
183            }
184        }
185
186        widths
187    }
188
189    /// Sum of resolved widths. Used for pane partitioning.
190    #[allow(dead_code)]
191    pub(crate) fn total_width(widths: &[f32]) -> f32 {
192        widths.iter().sum()
193    }
194
195    /// X-offset of column `i` relative to the pane's leading edge.
196    /// Used for column-resize hit testing.
197    #[allow(dead_code)]
198    pub(crate) fn x_offset(widths: &[f32], i: usize) -> f32 {
199        widths.iter().take(i).sum()
200    }
201}
202
203fn clamp(value: f32, min: f32, max: Option<f32>) -> f32 {
204    let m = max.unwrap_or(f32::INFINITY);
205    value.max(min).min(m)
206}
207
208// ── Horizontal scroll / pane geometry ───────────────────────────────────────
209//
210// A row's (or the header's) `body_width`-wide band splits into up to three
211// panes by `PaneBoundaries`: Leading-pinned columns anchor at the band's own
212// leading edge, Trailing-pinned columns anchor at its trailing edge, and the
213// columns in between (the Middle pane) scroll horizontally by `scroll_x`,
214// clipped to whatever room the pinned panes leave. Every function below
215// works in **logical** (reading-order) offsets — 0 is always the band's own
216// leading edge — so a single physical mirror step at the call site (`rtl ?
217// band_width - offset - width : offset`, the same convention `BodyRow` /
218// `HeaderRow` already use for their flat, unpinned cumulative walk) handles
219// RTL for pinned AND scrolled content alike; nothing here needs its own RTL
220// branch.
221
222/// Sum of the resolved widths in `widths[range]`, defensively clamped to the
223/// slice length (a display-order / widths-vector length mismatch is a
224/// pre-existing tolerated edge case elsewhere in this module — see
225/// `BodyRow`/`HeaderRow`'s `fallback_w`).
226fn sum_range(widths: &[f32], range: std::ops::Range<usize>) -> f32 {
227    let start = range.start.min(widths.len());
228    let end = range.end.min(widths.len()).max(start);
229    widths[start..end].iter().sum()
230}
231
232/// `(leading_width, middle_content_width, trailing_width)` — the three
233/// panes' resolved widths. `middle_content_width` is the *unclamped* sum of
234/// the scrollable columns, i.e. the horizontal analogue of
235/// `RowMetrics::total_height` — it can exceed the viewport, which is exactly
236/// what makes scrolling necessary.
237pub(crate) fn pane_widths(widths: &[f32], boundaries: PaneBoundaries) -> (f32, f32, f32) {
238    let leading = sum_range(widths, 0..boundaries.leading_count);
239    let middle = sum_range(widths, boundaries.leading_count..boundaries.middle_end);
240    let trailing = sum_range(widths, boundaries.middle_end..widths.len());
241    (leading, middle, trailing)
242}
243
244/// Width left for the Middle pane's own viewport once the pinned panes take
245/// their share of `band_width`. Floors at 0 (pinned columns alone can
246/// outgrow the band on a very narrow table — same "just overflow" fallback
247/// the rest of this module already accepts for an over-subscribed pane).
248pub(crate) fn middle_viewport_width(
249    band_width: f32,
250    widths: &[f32],
251    boundaries: PaneBoundaries,
252) -> f32 {
253    let (leading, _, trailing) = pane_widths(widths, boundaries);
254    (band_width - leading - trailing).max(0.0)
255}
256
257/// Maximum `scroll_x` — `middle_content_width − middle_viewport_width`,
258/// floored at 0 (content that already fits needs no scroll headroom).
259pub(crate) fn max_scroll_x(band_width: f32, widths: &[f32], boundaries: PaneBoundaries) -> f32 {
260    let (_, middle_content, _) = pane_widths(widths, boundaries);
261    let viewport = middle_viewport_width(band_width, widths, boundaries);
262    (middle_content - viewport).max(0.0)
263}
264
265/// Physical rects for the Leading / Middle / Trailing bands within a header
266/// or body row's own `bounds` — the geometry `BodyRow::place_children` /
267/// `HeaderRow::place_children` hand to their `RowBand` children, and that
268/// `TableView`/`TreeTableView`'s own `paint()` re-derives to clip
269/// pane-crossing root-painted decorations (vertical grid lines, the cell
270/// focus ring) to the pane the target column actually belongs to.
271///
272/// A pane with no columns collapses to a zero-width rect at its edge —
273/// harmless, since `RowBand` skips a band whose cell list is empty and a
274/// zero-width clip paints nothing.
275pub(crate) fn band_rects(
276    bounds: Rect,
277    widths: &[f32],
278    boundaries: PaneBoundaries,
279    rtl: bool,
280) -> (Rect, Rect, Rect) {
281    let (leading_w, _, trailing_w) = pane_widths(widths, boundaries);
282    let middle_w = middle_viewport_width(bounds.width, widths, boundaries);
283    if rtl {
284        let leading = Rect::new(
285            bounds.right() - leading_w,
286            bounds.y,
287            leading_w,
288            bounds.height,
289        );
290        let trailing = Rect::new(bounds.x, bounds.y, trailing_w, bounds.height);
291        let middle = Rect::new(bounds.x + trailing_w, bounds.y, middle_w, bounds.height);
292        (leading, middle, trailing)
293    } else {
294        let leading = Rect::new(bounds.x, bounds.y, leading_w, bounds.height);
295        let middle = Rect::new(bounds.x + leading_w, bounds.y, middle_w, bounds.height);
296        let trailing = Rect::new(
297            bounds.x + bounds.width - trailing_w,
298            bounds.y,
299            trailing_w,
300            bounds.height,
301        );
302        (leading, middle, trailing)
303    }
304}
305
306/// Logical x-offset (from the band's own leading edge, pre-RTL-mirror) of
307/// display slot `slot` — Leading columns are anchored at the band start
308/// (unaffected by `scroll_x`), Trailing columns are anchored at the band
309/// end (also unaffected), and Middle columns run in between, shifted by
310/// `-scroll_x`. `None` when `slot` is out of range.
311pub(crate) fn column_logical_x(
312    widths: &[f32],
313    boundaries: PaneBoundaries,
314    scroll_x: f32,
315    band_width: f32,
316    slot: usize,
317) -> Option<f32> {
318    if slot >= widths.len() {
319        return None;
320    }
321    if slot < boundaries.leading_count {
322        return Some(sum_range(widths, 0..slot));
323    }
324    let (leading_w, _, trailing_w) = pane_widths(widths, boundaries);
325    if slot < boundaries.middle_end {
326        let within = sum_range(widths, boundaries.leading_count..slot);
327        return Some(leading_w - scroll_x + within);
328    }
329    let within = sum_range(widths, boundaries.middle_end..slot);
330    Some(band_width - trailing_w + within)
331}
332
333/// Inverse of [`column_logical_x`]: given a **logical** (already
334/// RTL-un-mirrored) drop x, find the display slot whose column midpoint it
335/// falls before — the same "first column whose midpoint exceeds x" rule the
336/// column-reorder drop handler always used, generalized to account for
337/// pinning + the Middle pane's scroll offset. Returns `widths.len()` (append)
338/// when `x` is past every column.
339pub(crate) fn insertion_slot_at_x(
340    widths: &[f32],
341    boundaries: PaneBoundaries,
342    scroll_x: f32,
343    band_width: f32,
344    x: f32,
345) -> usize {
346    let leading_end = boundaries.leading_count.min(widths.len());
347    let mut cursor = 0.0;
348    for i in 0..leading_end {
349        let w = widths[i];
350        if x < cursor + w * 0.5 {
351            return i;
352        }
353        cursor += w;
354    }
355    let (leading_w, _, trailing_w) = pane_widths(widths, boundaries);
356    let middle_end = boundaries.middle_end.min(widths.len()).max(leading_end);
357    let mut cursor = leading_w - scroll_x;
358    for i in leading_end..middle_end {
359        let w = widths[i];
360        if x < cursor + w * 0.5 {
361            return i;
362        }
363        cursor += w;
364    }
365    let mut cursor = band_width - trailing_w;
366    for i in middle_end..widths.len() {
367        let w = widths[i];
368        if x < cursor + w * 0.5 {
369            return i;
370        }
371        cursor += w;
372    }
373    widths.len()
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use crate::primitives::TextWidget;
380    use crate::table_view::column::{CellContext, Column};
381    use teksilo_i18n::lit;
382
383    fn col(id: &str, w: ColumnWidth) -> Column<&'static str> {
384        Column::<&str>::new(id, lit!("h"), |_, _: &CellContext| {
385            Box::new(TextWidget::new(lit!("x")))
386        })
387        .width(w)
388    }
389
390    #[test]
391    fn fixed_widths_pass_through() {
392        let cols = vec![
393            col("a", ColumnWidth::Fixed(80.0)),
394            col("b", ColumnWidth::Fixed(120.0)),
395        ];
396        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
397        assert_eq!(widths, vec![80.0, 120.0]);
398    }
399
400    #[test]
401    fn flex_columns_split_leftover() {
402        let cols = vec![
403            col("a", ColumnWidth::Fixed(100.0)),
404            col("b", ColumnWidth::Flex(1.0)),
405            col("c", ColumnWidth::Flex(2.0)),
406        ];
407        // Leftover = 400 - 100 = 300; split 1:2 = 100 / 200.
408        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
409        assert_eq!(widths[0], 100.0);
410        assert!((widths[1] - 100.0).abs() < 0.01);
411        assert!((widths[2] - 200.0).abs() < 0.01);
412    }
413
414    #[test]
415    fn flex_clamps_to_min_width() {
416        let cols = vec![
417            col("a", ColumnWidth::Fixed(380.0)),
418            col("b", ColumnWidth::Flex(1.0)).min_width(60.0),
419        ];
420        // Leftover only 20 px but min 60 — clamped up.
421        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
422        assert_eq!(widths[1], 60.0);
423    }
424
425    #[test]
426    fn flex_clamps_to_max_width() {
427        let cols = vec![col("a", ColumnWidth::Flex(1.0)).max_width(120.0)];
428        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
429        assert_eq!(widths[0], 120.0);
430    }
431
432    #[test]
433    fn fixed_clamps_to_min_when_below() {
434        let cols = vec![col("a", ColumnWidth::Fixed(10.0)).min_width(60.0)];
435        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
436        assert_eq!(widths[0], 60.0);
437    }
438
439    #[test]
440    fn fixed_clamps_to_max_when_above() {
441        let cols = vec![col("a", ColumnWidth::Fixed(500.0)).max_width(180.0)];
442        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
443        assert_eq!(widths[0], 180.0);
444    }
445
446    #[test]
447    fn auto_falls_back_to_min_default() {
448        let cols = vec![col("a", ColumnWidth::Auto)];
449        let widths = ColumnSolver::resolve(&cols, 400.0, 48.0, &HashMap::new());
450        assert_eq!(widths[0], 48.0);
451    }
452
453    #[test]
454    fn auto_with_min_uses_min() {
455        let cols = vec![col("a", ColumnWidth::Auto).min_width(100.0)];
456        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
457        assert_eq!(widths[0], 100.0);
458    }
459
460    #[test]
461    fn no_flex_no_overflow() {
462        // Total fixed = 200, available = 400, no flex — leftover 200 stays
463        // unallocated; the table pane is wider than the column total, which
464        // is fine.
465        let cols = vec![
466            col("a", ColumnWidth::Fixed(80.0)),
467            col("b", ColumnWidth::Fixed(120.0)),
468        ];
469        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
470        assert_eq!(ColumnSolver::total_width(&widths), 200.0);
471    }
472
473    #[test]
474    fn x_offset_walks_widths() {
475        let widths = vec![80.0, 120.0, 60.0];
476        assert_eq!(ColumnSolver::x_offset(&widths, 0), 0.0);
477        assert_eq!(ColumnSolver::x_offset(&widths, 1), 80.0);
478        assert_eq!(ColumnSolver::x_offset(&widths, 2), 200.0);
479        assert_eq!(ColumnSolver::x_offset(&widths, 3), 260.0);
480    }
481
482    #[test]
483    fn empty_columns_returns_empty() {
484        let cols: Vec<Column<&'static str>> = vec![];
485        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
486        assert!(widths.is_empty());
487    }
488
489    #[test]
490    fn override_pins_column_regardless_of_width_policy() {
491        let cols = vec![
492            col("a", ColumnWidth::Flex(1.0)),
493            col("b", ColumnWidth::Flex(1.0)),
494        ];
495        let mut over = HashMap::new();
496        over.insert("a".to_string(), 250.0);
497        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &over);
498        // a is pinned to 250, b gets the leftover 150.
499        assert_eq!(widths[0], 250.0);
500        assert!((widths[1] - 150.0).abs() < 0.01, "got {}", widths[1]);
501    }
502
503    #[test]
504    fn override_clamps_to_min_max() {
505        let cols = vec![
506            col("a", ColumnWidth::Flex(1.0))
507                .min_width(80.0)
508                .max_width(200.0),
509        ];
510        let mut over = HashMap::new();
511        over.insert("a".to_string(), 5.0); // below min
512        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &over);
513        assert_eq!(widths[0], 80.0);
514
515        let mut over = HashMap::new();
516        over.insert("a".to_string(), 999.0); // above max
517        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &over);
518        assert_eq!(widths[0], 200.0);
519    }
520
521    #[test]
522    fn negative_leftover_keeps_min() {
523        let cols = vec![
524            col("a", ColumnWidth::Fixed(500.0)),
525            col("b", ColumnWidth::Flex(1.0)).min_width(50.0),
526        ];
527        // Available 400, fixed 500 — overflow. Flex still respects min.
528        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
529        assert_eq!(widths[1], 50.0);
530    }
531
532    #[test]
533    fn zero_flex_factor_treated_as_zero_share() {
534        let cols = vec![
535            col("a", ColumnWidth::Flex(0.0)).min_width(40.0),
536            col("b", ColumnWidth::Flex(1.0)),
537        ];
538        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
539        // Flex(0.0) gets a 0 share of the leftover (400), which is below
540        // its min_width (40) — it's pinned to 40 and drops out of the
541        // pool. That 40 px is subtracted from the leftover *before* `b`
542        // (the only remaining pooled column) claims the rest, so the two
543        // resolved widths sum to exactly the pane instead of overflowing
544        // it.
545        assert_eq!(widths[0], 40.0);
546        assert_eq!(widths[1], 360.0);
547    }
548
549    #[test]
550    fn flex_min_width_redistributes_to_siblings() {
551        let cols = vec![
552            col("fixed", ColumnWidth::Fixed(100.0)),
553            col("a", ColumnWidth::Flex(1.0)),
554            col("b", ColumnWidth::Flex(1.0)).min_width(200.0),
555        ];
556        // Leftover after the fixed column is 300, split evenly 1:1 —
557        // 150 apiece — but `b`'s min_width (200) wins its round and
558        // pins it there. The 200 it now consumes (not its 150 share) is
559        // subtracted from the leftover before `a`'s share is
560        // recomputed in the next round, so `a` settles at 100 instead
561        // of its stale first-round share of 150 — and the three
562        // resolved widths sum to exactly the 400 px pane rather than
563        // overflowing it by `b`'s 50 px shortfall.
564        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
565        assert_eq!(widths[0], 100.0);
566        assert_eq!(widths[1], 100.0);
567        assert_eq!(widths[2], 200.0);
568        assert_eq!(ColumnSolver::total_width(&widths), 400.0);
569    }
570
571    #[test]
572    fn flex_min_widths_that_oversubscribe_the_pane_still_overflow() {
573        // When the floors alone exceed the available width, redistribution
574        // can't help — floors win and the resolved total overflows the
575        // pane, same as a single non-iterative clamp would produce.
576        let cols = vec![
577            col("a", ColumnWidth::Flex(1.0)).min_width(300.0),
578            col("b", ColumnWidth::Flex(1.0)).min_width(300.0),
579        ];
580        let widths = ColumnSolver::resolve(&cols, 400.0, 32.0, &HashMap::new());
581        assert_eq!(widths[0], 300.0);
582        assert_eq!(widths[1], 300.0);
583    }
584
585    // ── Pane geometry / horizontal scroll ───────────────────────────────
586
587    #[test]
588    fn pane_widths_splits_leading_middle_trailing() {
589        let widths = [50.0, 60.0, 70.0, 80.0, 90.0];
590        // Slots 0..1 leading, 1..4 middle, 4.. trailing.
591        let b = PaneBoundaries::new(1, 4);
592        assert_eq!(pane_widths(&widths, b), (50.0, 210.0, 90.0));
593    }
594
595    #[test]
596    fn pane_widths_all_middle_when_unpinned() {
597        let widths = [50.0, 60.0, 70.0];
598        let b = PaneBoundaries::new(0, 3);
599        assert_eq!(pane_widths(&widths, b), (0.0, 180.0, 0.0));
600    }
601
602    #[test]
603    fn middle_viewport_width_is_band_minus_pinned_panes() {
604        let widths = [60.0, 100.0, 100.0, 100.0, 60.0];
605        let b = PaneBoundaries::new(1, 4);
606        // Band 400, leading 60, trailing 60 -> middle viewport 280.
607        assert_eq!(middle_viewport_width(400.0, &widths, b), 280.0);
608    }
609
610    #[test]
611    fn middle_viewport_width_floors_at_zero_when_pinned_panes_overflow() {
612        let widths = [300.0, 100.0, 300.0];
613        let b = PaneBoundaries::new(1, 2);
614        // Pinned panes alone (600) already exceed the 400 px band.
615        assert_eq!(middle_viewport_width(400.0, &widths, b), 0.0);
616    }
617
618    #[test]
619    fn max_scroll_x_is_zero_when_content_fits() {
620        let widths = [60.0, 100.0, 60.0];
621        let b = PaneBoundaries::new(1, 2);
622        // Middle content (100) fits the 280 px middle viewport (400-60-60).
623        assert_eq!(max_scroll_x(400.0, &widths, b), 0.0);
624    }
625
626    #[test]
627    fn max_scroll_x_clamps_after_a_pane_shrink() {
628        // Wide band: plenty of scroll headroom.
629        let widths = [60.0, 500.0, 60.0];
630        let b = PaneBoundaries::new(1, 2);
631        assert_eq!(max_scroll_x(400.0, &widths, b), 500.0 - 280.0);
632        // The pane (band) shrinks — e.g. the window narrowed. A scroll
633        // position computed against the old, larger max must still resolve
634        // to a smaller-but-still-correct max against the new band width, not
635        // go negative or panic.
636        let narrower = max_scroll_x(300.0, &widths, b);
637        assert_eq!(narrower, 500.0 - (300.0 - 120.0));
638        assert!(narrower > 0.0);
639        // Shrink until the pinned panes alone consume the whole band (the
640        // middle viewport itself floors at 0) — the scroll headroom becomes
641        // the full content width, never negative.
642        assert_eq!(max_scroll_x(50.0, &widths, b), 500.0);
643    }
644
645    #[test]
646    fn band_rects_ltr_places_leading_left_middle_center_trailing_right() {
647        let widths = [60.0, 200.0, 60.0];
648        let b = PaneBoundaries::new(1, 2);
649        let bounds = Rect::new(10.0, 20.0, 400.0, 30.0);
650        let (leading, middle, trailing) = band_rects(bounds, &widths, b, false);
651        assert_eq!(leading, Rect::new(10.0, 20.0, 60.0, 30.0));
652        assert_eq!(middle, Rect::new(70.0, 20.0, 280.0, 30.0));
653        assert_eq!(trailing, Rect::new(350.0, 20.0, 60.0, 30.0));
654    }
655
656    #[test]
657    fn band_rects_rtl_mirrors_leading_to_the_physical_right() {
658        let widths = [60.0, 200.0, 60.0];
659        let b = PaneBoundaries::new(1, 2);
660        let bounds = Rect::new(10.0, 20.0, 400.0, 30.0);
661        let (leading, middle, trailing) = band_rects(bounds, &widths, b, true);
662        // Leading pinned -> physical right edge of the band.
663        assert_eq!(leading, Rect::new(350.0, 20.0, 60.0, 30.0));
664        // Trailing pinned -> physical left edge.
665        assert_eq!(trailing, Rect::new(10.0, 20.0, 60.0, 30.0));
666        assert_eq!(middle, Rect::new(70.0, 20.0, 280.0, 30.0));
667    }
668
669    #[test]
670    fn column_logical_x_pinned_columns_ignore_scroll() {
671        let widths = [60.0, 80.0, 200.0, 60.0];
672        let b = PaneBoundaries::new(1, 3);
673        for scroll in [0.0, 40.0, 999.0] {
674            assert_eq!(
675                column_logical_x(&widths, b, scroll, 400.0, 0),
676                Some(0.0),
677                "leading column never moves"
678            );
679            assert_eq!(
680                column_logical_x(&widths, b, scroll, 400.0, 3),
681                Some(400.0 - 60.0),
682                "trailing column never moves"
683            );
684        }
685    }
686
687    #[test]
688    fn column_logical_x_middle_column_shifts_left_by_scroll() {
689        let widths = [60.0, 80.0, 200.0, 60.0];
690        let b = PaneBoundaries::new(1, 3);
691        // Middle pane starts right after the 60px leading pane.
692        assert_eq!(column_logical_x(&widths, b, 0.0, 400.0, 1), Some(60.0));
693        assert_eq!(column_logical_x(&widths, b, 25.0, 400.0, 1), Some(35.0));
694        assert_eq!(
695            column_logical_x(&widths, b, 25.0, 400.0, 2),
696            Some(60.0 - 25.0 + 80.0)
697        );
698    }
699
700    #[test]
701    fn column_logical_x_out_of_range_is_none() {
702        let widths = [60.0, 80.0];
703        let b = PaneBoundaries::new(0, 2);
704        assert_eq!(column_logical_x(&widths, b, 0.0, 400.0, 2), None);
705    }
706
707    #[test]
708    fn insertion_slot_at_x_finds_pinned_and_scrolled_columns() {
709        let widths = [60.0, 80.0, 200.0, 60.0];
710        let b = PaneBoundaries::new(1, 3);
711        // x = 0 is inside the (only) leading column's first half.
712        assert_eq!(insertion_slot_at_x(&widths, b, 0.0, 400.0, 0.0), 0);
713        // Well past everything -> append.
714        assert_eq!(insertion_slot_at_x(&widths, b, 0.0, 400.0, 10_000.0), 4);
715        // With no scroll, x just past the leading pane (60) lands in the
716        // first middle column's first half (60..60+40).
717        assert_eq!(insertion_slot_at_x(&widths, b, 0.0, 400.0, 65.0), 1);
718        // Scrolling the middle pane right by 70 slides that same physical x
719        // into what is now the SECOND middle column (slot 2): logical
720        // column 1 now starts at 60-70 = -10, ends at 70; x=65 falls in its
721        // second half, so the insertion point becomes column 2's slot.
722        assert_eq!(insertion_slot_at_x(&widths, b, 70.0, 400.0, 65.0), 2);
723    }
724}