Skip to main content

retroglyph_widgets/
layout.rs

1//! Constraint-based `Rect` splitter for multi-panel UIs.
2//!
3//! Splits a [`Rect`] into stacked rows ([`split_v`]) or side-by-side columns
4//! ([`split_h`]) according to a slice of [`Constraint`]s. [`split_h_spaced`]/[`split_v_spaced`]
5//! do the same but also carve a fixed-cell gap between every adjacent pair of panes, without the
6//! caller having to interleave `Constraint::Fixed(spacing)` gap constraints and filter them back
7//! out by hand.
8//!
9//! The solver sums the [`Fixed`](Constraint::Fixed) and [`Percent`](Constraint::Percent)
10//! amounts, then distributes whatever remains across the [`Fill`](Constraint::Fill),
11//! [`Min`](Constraint::Min), and [`Max`](Constraint::Max) panes in proportion to their
12//! weight: a `Fill(w)` pane claims a share proportional to `w` relative
13//! to the other flexible panes, while [`Min`](Constraint::Min) and [`Max`](Constraint::Max)
14//! panes always weigh 1. `Fill(1)` (equivalent to every pane weighing 1) reproduces plain
15//! equal distribution. Sizes are clamped so the panes never spill past `area`. This is a
16//! single sequential pass, not an iterative constraint solver: a [`Max`](Constraint::Max)
17//! pane that is capped below its share does not redistribute the excess to other panes, so
18//! leftover space can remain unclaimed (see [`Flex`] for how that leftover is placed via
19//! [`split_v_flex`]/[`split_h_flex`]).
20use retroglyph_core::Rect;
21
22/// How a single pane claims space along the split axis.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum Constraint {
25    /// An exact number of cells.
26    Fixed(u16),
27    /// A percentage (0–100) of the axis length.
28    Percent(u16),
29    /// Claim a share of whatever space the fixed/percent panes leave, proportional to
30    /// `weight` relative to the other [`Fill`](Self::Fill)/[`Min`](Self::Min)/[`Max`](Self::Max)
31    /// panes in the same split ([`Min`](Self::Min)/[`Max`](Self::Max) panes always weigh 1).
32    /// `Fill(1)` reproduces plain equal distribution across an all-`Fill` split; a weight of
33    /// 0 claims no share of the remainder.
34    Fill(u16),
35    /// Like [`Fill`](Self::Fill), but guarantees at least this many cells even if the axis
36    /// is too small for every pane to get its share, and always weighs 1.
37    Min(u16),
38    /// Like [`Fill`](Self::Fill), but never grows past this many cells (any share past the
39    /// cap is left unclaimed rather than redistributed), and always weighs 1.
40    Max(u16),
41}
42
43impl Constraint {
44    /// Resolve this constraint's base size against `total` axis length.
45    /// [`Fill`](Self::Fill) and [`Max`](Self::Max) resolve to zero here;
46    /// [`Min`](Self::Min) reserves its floor up front like [`Fixed`](Self::Fixed).
47    /// Flexible sizes are filled in later by [`solve`].
48    fn base(self, total: u16) -> u16 {
49        match self {
50            Self::Fixed(n) | Self::Min(n) => n.min(total),
51            Self::Percent(p) => {
52                let p = u32::from(p.min(100));
53                #[allow(clippy::cast_possible_truncation)]
54                {
55                    (u32::from(total) * p / 100) as u16
56                }
57            }
58            Self::Fill(_) | Self::Max(_) => 0,
59        }
60    }
61}
62
63/// Constraint counts at or below this stay on the stack in [`SmallBuf`]; larger splits fall back
64/// to a heap `Vec`. Chosen comfortably above a typical multi-panel layout (a header, a handful of
65/// flexible content panes, a status bar) while staying correct for arbitrarily many panes -- see
66/// the `layout_solve` benchmark's 100-pane case, which exercises the heap fallback.
67const STACK_CAP: usize = 8;
68
69/// A small buffer that stays inline on the stack for up to `N` items and only allocates on the
70/// heap past that. `solve` uses this for its scratch buffers (pane sizes, the flexible-pane
71/// index/weight/cap list, and the largest-remainder distribution pass) so that the common case of
72/// a handful of panes per split -- called several times per frame by multi-panel UIs -- does not
73/// pay for a heap allocation at all.
74enum SmallBuf<T: Copy + Default, const N: usize> {
75    Stack([T; N], usize),
76    Heap(Vec<T>),
77}
78
79impl<T: Copy + Default, const N: usize> SmallBuf<T, N> {
80    /// Create a buffer able to hold `cap` items without reallocating: inline on the stack if
81    /// `cap` fits within `N`, otherwise a heap `Vec` pre-sized to `cap`.
82    fn with_capacity(cap: usize) -> Self {
83        if cap <= N {
84            Self::Stack([T::default(); N], 0)
85        } else {
86            Self::Heap(Vec::with_capacity(cap))
87        }
88    }
89
90    /// Append `value`.
91    ///
92    /// # Panics
93    ///
94    /// Panics if the buffer is the `Stack` variant and already holds `N` items -- callers must
95    /// size `with_capacity` to the true upper bound of pushes, as `solve` does.
96    fn push(&mut self, value: T) {
97        match self {
98            Self::Stack(buf, len) => {
99                buf[*len] = value;
100                *len += 1;
101            }
102            Self::Heap(vec) => vec.push(value),
103        }
104    }
105}
106
107impl<T: Copy + Default, const N: usize> std::ops::Deref for SmallBuf<T, N> {
108    type Target = [T];
109
110    fn deref(&self) -> &[T] {
111        match self {
112            Self::Stack(buf, len) => &buf[..*len],
113            Self::Heap(vec) => vec,
114        }
115    }
116}
117
118impl<T: Copy + Default, const N: usize> std::ops::DerefMut for SmallBuf<T, N> {
119    fn deref_mut(&mut self) -> &mut [T] {
120        match self {
121            Self::Stack(buf, len) => &mut buf[..*len],
122            Self::Heap(vec) => vec,
123        }
124    }
125}
126
127impl<T: Copy + Default, const N: usize> std::ops::Index<usize> for SmallBuf<T, N> {
128    type Output = T;
129
130    fn index(&self, idx: usize) -> &T {
131        &(**self)[idx]
132    }
133}
134
135impl<T: Copy + Default, const N: usize> std::ops::IndexMut<usize> for SmallBuf<T, N> {
136    fn index_mut(&mut self, idx: usize) -> &mut T {
137        &mut (**self)[idx]
138    }
139}
140
141/// Compute the length of each pane along an axis of `total` cells.
142fn solve(total: u16, constraints: &[Constraint]) -> SmallBuf<u16, STACK_CAP> {
143    let mut sizes: SmallBuf<u16, STACK_CAP> = SmallBuf::with_capacity(constraints.len());
144    for c in constraints {
145        sizes.push(c.base(total));
146    }
147
148    // Clamp the fixed/percent sum so it never exceeds the axis. If it does,
149    // shave from the tail so earlier panes keep their requested size.
150    let mut used: u16 = 0;
151    for size in sizes.iter_mut() {
152        let room = total.saturating_sub(used);
153        *size = (*size).min(room);
154        used += *size;
155    }
156
157    // Distribute the remainder across the Fill, Min, and Max panes in proportion to
158    // their weight (Fill(w) weighs w; Min/Max always weigh 1). Min panes add their
159    // share on top of the floor already reserved above; Max panes start at zero and
160    // are capped at their declared value (any share past the cap is simply left
161    // unclaimed, not redistributed).
162    let mut flexible: SmallBuf<(usize, u16, Option<u16>), STACK_CAP> =
163        SmallBuf::with_capacity(constraints.len());
164    for (i, c) in constraints.iter().enumerate() {
165        match c {
166            Constraint::Fill(weight) => flexible.push((i, *weight, None)),
167            Constraint::Min(_) => flexible.push((i, 1, None)),
168            Constraint::Max(cap) => flexible.push((i, 1, Some(*cap))),
169            Constraint::Fixed(_) | Constraint::Percent(_) => {}
170        }
171    }
172    if !flexible.is_empty() {
173        let remainder = total.saturating_sub(used);
174        let total_weight: u32 = flexible.iter().map(|&(_, w, _)| u32::from(w)).sum();
175        if let Some(total_weight) = std::num::NonZeroU32::new(total_weight) {
176            // Largest-remainder method: give every pane the integer floor of its
177            // proportional share, then hand out the leftover cells one at a time to
178            // the panes with the largest fractional remainder (ties -> earlier pane
179            // first). For equal weights every fraction ties, so this reduces to the
180            // original round-robin-from-the-front behavior exactly.
181            let mut shares: SmallBuf<u32, STACK_CAP> = SmallBuf::with_capacity(flexible.len());
182            let mut fracs: SmallBuf<u32, STACK_CAP> = SmallBuf::with_capacity(flexible.len());
183            let mut floor_sum: u32 = 0;
184            for &(_, weight, _) in flexible.iter() {
185                let product = u32::from(remainder) * u32::from(weight);
186                let share = product / total_weight;
187                fracs.push(product % total_weight);
188                shares.push(share);
189                floor_sum += share;
190            }
191            let mut leftover = u32::from(remainder).saturating_sub(floor_sum);
192            let mut order: SmallBuf<usize, STACK_CAP> = SmallBuf::with_capacity(flexible.len());
193            for idx in 0..flexible.len() {
194                order.push(idx);
195            }
196            order.sort_by(|&a, &b| fracs[b].cmp(&fracs[a]).then(a.cmp(&b)));
197            for &idx in order.iter() {
198                if leftover == 0 {
199                    break;
200                }
201                shares[idx] += 1;
202                leftover -= 1;
203            }
204            for (k, &(i, _, cap)) in flexible.iter().enumerate() {
205                #[allow(clippy::cast_possible_truncation)]
206                let share = shares[k] as u16;
207                let grown = sizes[i].saturating_add(share);
208                sizes[i] = cap.map_or(grown, |max| grown.min(max));
209            }
210        }
211    }
212
213    sizes
214}
215
216/// Split `area` into stacked rows top-to-bottom.
217///
218/// Returns one [`Rect`] per constraint; empty panes (zero height) are still
219/// returned so indices line up with `constraints`.
220///
221/// # Examples
222///
223/// ```
224/// use retroglyph_core::Rect;
225/// use retroglyph_widgets::{Constraint, split_v};
226///
227/// let area = Rect::new(0, 0, 20, 10);
228/// let panes = split_v(area, &[Constraint::Fixed(1), Constraint::Fill(1), Constraint::Fixed(1)]);
229/// assert_eq!(panes.iter().map(Rect::height).collect::<Vec<_>>(), vec![1, 8, 1]);
230/// ```
231#[must_use]
232pub fn split_v(area: Rect, constraints: &[Constraint]) -> Vec<Rect> {
233    let sizes = solve(area.height(), constraints);
234    let mut y = area.top();
235    sizes
236        .iter()
237        .copied()
238        .map(|h| {
239            let rect = Rect::new(area.left(), y, area.width(), h);
240            y = y.saturating_add(h);
241            rect
242        })
243        .collect()
244}
245
246/// Split `area` into columns left-to-right.
247///
248/// Returns one [`Rect`] per constraint; empty panes (zero width) are still
249/// returned so indices line up with `constraints`.
250///
251/// # Examples
252///
253/// ```
254/// use retroglyph_core::Rect;
255/// use retroglyph_widgets::{Constraint, split_h};
256///
257/// let area = Rect::new(0, 0, 100, 5);
258/// let panes = split_h(area, &[Constraint::Percent(30), Constraint::Fill(1)]);
259/// assert_eq!(panes.iter().map(Rect::width).collect::<Vec<_>>(), vec![30, 70]);
260/// ```
261#[must_use]
262pub fn split_h(area: Rect, constraints: &[Constraint]) -> Vec<Rect> {
263    let sizes = solve(area.width(), constraints);
264    let mut x = area.left();
265    sizes
266        .iter()
267        .copied()
268        .map(|w| {
269            let rect = Rect::new(x, area.top(), w, area.height());
270            x = x.saturating_add(w);
271            rect
272        })
273        .collect()
274}
275
276/// Interleaves a `Constraint::Fixed(spacing)` gap between every pair of adjacent `constraints`.
277///
278/// `[c0, c1, c2]` with `spacing` becomes `[c0, Fixed(spacing), c1, Fixed(spacing), c2]` -- the
279/// same shape a caller would otherwise have to build (and then remember to filter back out) by
280/// hand. No-op with fewer than two constraints.
281fn interleave_gaps(constraints: &[Constraint], spacing: u16) -> Vec<Constraint> {
282    let mut out = Vec::with_capacity(constraints.len().saturating_mul(2).saturating_sub(1));
283    for (i, &c) in constraints.iter().enumerate() {
284        if i > 0 {
285            out.push(Constraint::Fixed(spacing));
286        }
287        out.push(c);
288    }
289    out
290}
291
292/// Split `area` into columns left-to-right, like [`split_h`], but with a fixed `spacing`-cell gap
293/// carved out between every adjacent pair of panes.
294///
295/// Equivalent to interleaving `Constraint::Fixed(spacing)` between `constraints` and calling
296/// [`split_h`], then discarding the gap panes -- but the caller only ever sees the content panes,
297/// with no gap indices to filter out themselves. `spacing` gaps come out of `area` before
298/// `constraints` are resolved, so [`Fill`](Constraint::Fill)/[`Percent`](Constraint::Percent) panes
299/// share only what's left after every gap is reserved. No-op (falls back to [`split_h`]) with
300/// fewer than two panes or zero spacing.
301///
302/// # Examples
303///
304/// ```
305/// use retroglyph_core::Rect;
306/// use retroglyph_widgets::{Constraint, split_h_spaced};
307///
308/// let area = Rect::new(0, 0, 59, 6);
309/// let panes = split_h_spaced(area, &[Constraint::Fill(1); 3], 1);
310/// assert_eq!(panes.iter().map(Rect::width).collect::<Vec<_>>(), vec![19, 19, 19]);
311/// assert_eq!(panes[1].left(), panes[0].right() + 1); // one gap cell between panes
312/// ```
313#[must_use]
314pub fn split_h_spaced(area: Rect, constraints: &[Constraint], spacing: u16) -> Vec<Rect> {
315    if spacing == 0 || constraints.len() < 2 {
316        return split_h(area, constraints);
317    }
318    split_h(area, &interleave_gaps(constraints, spacing))
319        .into_iter()
320        .step_by(2)
321        .collect()
322}
323
324/// Split `area` into stacked rows top-to-bottom, like [`split_v`], but with a fixed `spacing`-cell
325/// gap carved out between every adjacent pair of panes.
326///
327/// See [`split_h_spaced`] for the full behavior; this is the same operation along the vertical
328/// axis.
329#[must_use]
330pub fn split_v_spaced(area: Rect, constraints: &[Constraint], spacing: u16) -> Vec<Rect> {
331    if spacing == 0 || constraints.len() < 2 {
332        return split_v(area, constraints);
333    }
334    split_v(area, &interleave_gaps(constraints, spacing))
335        .into_iter()
336        .step_by(2)
337        .collect()
338}
339
340/// How leftover space is placed along the split axis, once [`Constraint`]s
341/// are resolved.
342///
343/// Only matters when the resolved pane sizes sum to less than `area`'s
344/// length; passed to [`split_v_flex`]/[`split_h_flex`].
345///
346/// [`split_v`]/[`split_h`] always behave like [`Start`](Self::Start): any
347/// leftover space trails after the last pane, unclaimed. This matches their
348/// existing documented behavior, so adding `Flex` does not change them.
349#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
350pub enum Flex {
351    /// Panes are packed at the start of the area; leftover space trails
352    /// after the last pane. The default, and what [`split_v`]/[`split_h`] use.
353    #[default]
354    Start,
355    /// Panes are packed at the end of the area; leftover space leads before
356    /// the first pane.
357    End,
358    /// Leftover space is split evenly before and after the panes.
359    Center,
360    /// Leftover space is distributed as gaps between panes (none before the
361    /// first or after the last). No-op with fewer than two panes.
362    SpaceBetween,
363    /// Leftover space is distributed as equal-width gaps around every pane,
364    /// including before the first and after the last.
365    SpaceAround,
366}
367
368/// Compute each pane's starting offset along an axis of `total` cells for
369/// the resolved `sizes`, per `flex`. Companion to [`solve`]; used by
370/// [`split_v_flex`]/[`split_h_flex`].
371fn place(total: u16, sizes: &[u16], flex: Flex) -> Vec<u16> {
372    let content: u16 = sizes.iter().fold(0u16, |a, &b| a.saturating_add(b));
373    let slack = total.saturating_sub(content);
374    let n = sizes.len();
375    let mut offsets = Vec::with_capacity(n);
376
377    let packed_from = |start: u16| {
378        let mut pos = start;
379        sizes
380            .iter()
381            .map(|&s| {
382                let at = pos;
383                pos = pos.saturating_add(s);
384                at
385            })
386            .collect::<Vec<u16>>()
387    };
388
389    match flex {
390        Flex::End => offsets = packed_from(slack),
391        Flex::Center => offsets = packed_from(slack / 2),
392        Flex::SpaceBetween if n > 1 => {
393            #[allow(clippy::cast_possible_truncation)]
394            let gaps = n as u16 - 1;
395            let gap = slack / gaps;
396            let mut extra = slack % gaps;
397            let mut pos = 0;
398            for (i, &s) in sizes.iter().enumerate() {
399                offsets.push(pos);
400                pos = pos.saturating_add(s);
401                if i + 1 < n {
402                    pos = pos.saturating_add(gap + u16::from(extra > 0));
403                    extra = extra.saturating_sub(1);
404                }
405            }
406        }
407        Flex::Start | Flex::SpaceBetween => offsets = packed_from(0),
408        Flex::SpaceAround => {
409            #[allow(clippy::cast_possible_truncation)]
410            let gaps = n as u16 + 1;
411            let unit = slack / gaps;
412            let mut extra = slack % gaps;
413            let mut pos = unit + u16::from(extra > 0);
414            extra = extra.saturating_sub(u16::from(extra > 0));
415            for &s in sizes {
416                offsets.push(pos);
417                pos = pos.saturating_add(s);
418                pos = pos.saturating_add(unit + u16::from(extra > 0));
419                extra = extra.saturating_sub(u16::from(extra > 0));
420            }
421        }
422    }
423
424    offsets
425}
426
427/// Split `area` into stacked rows top-to-bottom, like [`split_v`], but with
428/// explicit control over how leftover space is placed via [`Flex`].
429#[must_use]
430pub fn split_v_flex(area: Rect, constraints: &[Constraint], flex: Flex) -> Vec<Rect> {
431    let sizes = solve(area.height(), constraints);
432    let offsets = place(area.height(), &sizes, flex);
433    offsets
434        .into_iter()
435        .zip(sizes.iter().copied())
436        .map(|(y, h)| Rect::new(area.left(), area.top().saturating_add(y), area.width(), h))
437        .collect()
438}
439
440/// Split `area` into columns left-to-right, like [`split_h`], but with
441/// explicit control over how leftover space is placed via [`Flex`].
442#[must_use]
443pub fn split_h_flex(area: Rect, constraints: &[Constraint], flex: Flex) -> Vec<Rect> {
444    let sizes = solve(area.width(), constraints);
445    let offsets = place(area.width(), &sizes, flex);
446    offsets
447        .into_iter()
448        .zip(sizes.iter().copied())
449        .map(|(x, w)| Rect::new(area.left().saturating_add(x), area.top(), w, area.height()))
450        .collect()
451}
452
453/// Compute a `width`×`height` [`Rect`] centered within `screen`.
454///
455/// `width`/`height` are clamped down to `screen`'s own dimensions if larger,
456/// so the result never extends past `screen`'s edges -- a modal, dialog, or
457/// tooltip box built from this is always fully on-screen, even on a
458/// terminal too small to fit the box's requested size. Pure layout math: no
459/// drawing, no `Terminal`. Pairs with `panel`/`modal` in `retroglyph-widgets`
460/// (the `draw` module) for a centered, bordered box.
461#[must_use]
462pub fn centered_rect(screen: Rect, width: u16, height: u16) -> Rect {
463    let width = width.min(screen.width());
464    let height = height.min(screen.height());
465    let x = screen.left() + (screen.width() - width) / 2;
466    let y = screen.top() + (screen.height() - height) / 2;
467    Rect::new(x, y, width, height)
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    #[test]
475    fn vertical_split_sums_and_clamps() {
476        let area = Rect::new(0, 0, 20, 10);
477        let panes = split_v(
478            area,
479            &[
480                Constraint::Fixed(1),
481                Constraint::Fill(1),
482                Constraint::Fixed(1),
483            ],
484        );
485        assert_eq!(panes.len(), 3);
486        // Heights: 1 + 8 + 1 = 10, exactly filling the area.
487        assert_eq!(panes[0].height(), 1);
488        assert_eq!(panes[1].height(), 8);
489        assert_eq!(panes[2].height(), 1);
490        // Panes are contiguous and never exceed the area bottom.
491        assert_eq!(panes[0].top(), 0);
492        assert_eq!(panes[1].top(), 1);
493        assert_eq!(panes[2].top(), 9);
494        assert_eq!(panes[2].bottom(), area.bottom());
495        // Width is preserved across all panes.
496        for p in &panes {
497            assert_eq!(p.width(), 20);
498        }
499    }
500
501    #[test]
502    fn horizontal_percent_and_fill() {
503        let area = Rect::new(0, 0, 100, 5);
504        let panes = split_h(area, &[Constraint::Percent(30), Constraint::Fill(1)]);
505        assert_eq!(panes[0].width(), 30);
506        assert_eq!(panes[1].width(), 70);
507        assert_eq!(panes[0].left(), 0);
508        assert_eq!(panes[1].left(), 30);
509        assert_eq!(panes[1].right(), area.right());
510    }
511
512    #[test]
513    fn fill_remainder_distributes_evenly() {
514        let area = Rect::new(0, 0, 10, 1);
515        // 10 cells across 3 fills: 4, 3, 3 (leftover goes to the front).
516        let panes = split_h(
517            area,
518            &[
519                Constraint::Fill(1),
520                Constraint::Fill(1),
521                Constraint::Fill(1),
522            ],
523        );
524        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
525        assert_eq!(widths, vec![4, 3, 3]);
526        assert_eq!(widths.iter().sum::<u16>(), 10);
527    }
528
529    #[test]
530    fn oversized_fixed_is_clamped() {
531        let area = Rect::new(0, 0, 5, 3);
532        // Requested 10 + 10 but only 5 columns exist: first takes all, rest zero.
533        let panes = split_h(area, &[Constraint::Fixed(10), Constraint::Fixed(10)]);
534        assert_eq!(panes[0].width(), 5);
535        assert_eq!(panes[1].width(), 0);
536        // No pane extends past the area.
537        for p in &panes {
538            assert!(p.right() <= area.right());
539        }
540    }
541
542    #[test]
543    fn no_fill_leaves_gap() {
544        let area = Rect::new(0, 0, 10, 4);
545        let panes = split_v(area, &[Constraint::Fixed(2), Constraint::Fixed(2)]);
546        // Only 4 of 10 rows consumed; that is fine — panes still fit.
547        assert_eq!(panes[0].height(), 2);
548        assert_eq!(panes[1].height(), 2);
549        assert_eq!(panes[1].bottom(), 4);
550    }
551
552    #[test]
553    fn min_gets_at_least_its_floor_plus_a_share() {
554        let area = Rect::new(0, 0, 10, 1);
555        // Min(3) and Fill both get an equal share (5 each) of the full 10
556        // cells, since Min's floor is reserved up front and then also
557        // shares in distributing the remaining 7: Min ends up with
558        // 3 (floor) + 4 (share, rounded up) = 7, Fill gets the other 3.
559        let panes = split_h(area, &[Constraint::Min(3), Constraint::Fill(1)]);
560        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
561        assert_eq!(widths, vec![7, 3]);
562        assert_eq!(widths.iter().sum::<u16>(), 10);
563    }
564
565    #[test]
566    fn min_floor_holds_when_share_would_be_smaller() {
567        let area = Rect::new(0, 0, 10, 1);
568        // Three flexible panes would each get ~3, but Min(4) guarantees 4:
569        // its floor (4) plus an equal share of the remaining 6 across all
570        // three (2 each) gives Min(4) a total of 6, leaving 2 each for the
571        // two Fill panes.
572        let panes = split_h(
573            area,
574            &[Constraint::Min(4), Constraint::Fill(1), Constraint::Fill(1)],
575        );
576        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
577        assert_eq!(widths[0], 6);
578        assert_eq!(widths[1], 2);
579        assert_eq!(widths[2], 2);
580        assert_eq!(widths.iter().sum::<u16>(), 10);
581    }
582
583    #[test]
584    fn max_caps_its_share_and_leaves_the_rest_unclaimed() {
585        let area = Rect::new(0, 0, 10, 1);
586        // Fill and Max(2) would each get 5; Max(2) is capped, and its extra
587        // 3 cells are left unclaimed (no redistribution), not given to Fill.
588        let panes = split_h(area, &[Constraint::Fill(1), Constraint::Max(2)]);
589        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
590        assert_eq!(widths, vec![5, 2]);
591        assert_eq!(widths.iter().sum::<u16>(), 7);
592    }
593
594    #[test]
595    fn weighted_fill_splits_proportionally() {
596        let area = Rect::new(0, 0, 12, 1);
597        // Fill(2) claims twice the share of Fill(1): 4 and 8 of 12.
598        let panes = split_h(area, &[Constraint::Fill(1), Constraint::Fill(2)]);
599        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
600        assert_eq!(widths, vec![4, 8]);
601        assert_eq!(widths.iter().sum::<u16>(), 12);
602    }
603
604    #[test]
605    fn weighted_fill_at_weight_one_matches_equal_distribution() {
606        let area = Rect::new(0, 0, 10, 1);
607        // Every pane weighing the same value (not just 1) still divides
608        // evenly, since distribution is by weight *ratio*, not magnitude.
609        let panes = split_h(
610            area,
611            &[
612                Constraint::Fill(5),
613                Constraint::Fill(5),
614                Constraint::Fill(5),
615            ],
616        );
617        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
618        assert_eq!(widths, vec![4, 3, 3]);
619        assert_eq!(widths.iter().sum::<u16>(), 10);
620    }
621
622    #[test]
623    fn weighted_fill_leftover_goes_to_the_largest_fractional_share() {
624        let area = Rect::new(0, 0, 10, 1);
625        // Ideal shares are 30/7 ~= 4.29, 20/7 ~= 2.86, 20/7 ~= 2.86. Floors are
626        // 4, 2, 2 (sum 8); the 2 leftover cells go to the panes with the
627        // largest fractional remainder, in this case the two Fill(2)s tied
628        // ahead of Fill(3) -- not to the first pane in the slice.
629        let panes = split_h(
630            area,
631            &[
632                Constraint::Fill(3),
633                Constraint::Fill(2),
634                Constraint::Fill(2),
635            ],
636        );
637        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
638        assert_eq!(widths, vec![4, 3, 3]);
639        assert_eq!(widths.iter().sum::<u16>(), 10);
640    }
641
642    #[test]
643    fn fill_weight_zero_claims_no_share_of_the_remainder() {
644        let area = Rect::new(0, 0, 10, 1);
645        let panes = split_h(area, &[Constraint::Fill(0), Constraint::Fill(1)]);
646        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
647        assert_eq!(widths, vec![0, 10]);
648    }
649
650    #[test]
651    fn all_fill_weights_zero_leaves_the_remainder_unclaimed() {
652        let area = Rect::new(0, 0, 10, 1);
653        let panes = split_h(area, &[Constraint::Fill(0), Constraint::Fill(0)]);
654        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
655        assert_eq!(widths, vec![0, 0]);
656    }
657
658    #[test]
659    fn weighted_fill_mixes_with_min_and_max_at_weight_one() {
660        let area = Rect::new(0, 0, 20, 1);
661        // Fill(3) claims 3 parts of the 6-way weight pool (3 + 1 + 1 + 1 = 6);
662        // Min(2) and Max(10) each claim 1 part like before. Remainder after
663        // Min's floor: 20 - 2 = 18, split 3:1:1:1 -> 9, 3, 3, 3; Min ends at
664        // 2 + 3 = 5.
665        let panes = split_h(
666            area,
667            &[
668                Constraint::Fill(3),
669                Constraint::Min(2),
670                Constraint::Fill(1),
671                Constraint::Max(10),
672            ],
673        );
674        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
675        assert_eq!(widths, vec![9, 5, 3, 3]);
676        assert_eq!(widths.iter().sum::<u16>(), 20);
677    }
678
679    #[test]
680    fn flex_start_matches_split_v() {
681        let area = Rect::new(0, 0, 10, 4);
682        let constraints = [Constraint::Fixed(2), Constraint::Fixed(2)];
683        let legacy = split_v(area, &constraints);
684        let flexed = split_v_flex(area, &constraints, Flex::Start);
685        assert_eq!(legacy, flexed);
686    }
687
688    #[test]
689    fn flex_end_pushes_leftover_before_the_panes() {
690        let area = Rect::new(0, 0, 10, 10);
691        let panes = split_v_flex(
692            area,
693            &[Constraint::Fixed(2), Constraint::Fixed(2)],
694            Flex::End,
695        );
696        // 6 rows of slack lead before the first pane.
697        assert_eq!(panes[0].top(), 6);
698        assert_eq!(panes[1].top(), 8);
699        assert_eq!(panes[1].bottom(), 10);
700    }
701
702    #[test]
703    fn flex_center_splits_leftover_around_the_panes() {
704        let area = Rect::new(0, 0, 10, 10);
705        let panes = split_v_flex(area, &[Constraint::Fixed(4)], Flex::Center);
706        // 6 rows of slack, 3 leading before the single pane.
707        assert_eq!(panes[0].top(), 3);
708        assert_eq!(panes[0].bottom(), 7);
709    }
710
711    #[test]
712    fn flex_space_between_puts_leftover_between_panes_only() {
713        let area = Rect::new(0, 0, 10, 1);
714        let panes = split_h_flex(
715            area,
716            &[Constraint::Fixed(2), Constraint::Fixed(2)],
717            Flex::SpaceBetween,
718        );
719        // 6 cells of slack become a single gap between the two panes.
720        assert_eq!(panes[0].left(), 0);
721        assert_eq!(panes[0].right(), 2);
722        assert_eq!(panes[1].left(), 8);
723        assert_eq!(panes[1].right(), 10);
724    }
725
726    #[test]
727    fn flex_space_around_puts_equal_gaps_at_both_edges() {
728        let area = Rect::new(0, 0, 9, 1);
729        let panes = split_h_flex(area, &[Constraint::Fixed(3)], Flex::SpaceAround);
730        // 6 cells of slack split into 2 gaps (before and after) of 3 each.
731        assert_eq!(panes[0].left(), 3);
732        assert_eq!(panes[0].right(), 6);
733    }
734
735    #[test]
736    fn spaced_split_carves_out_gaps_between_panes() {
737        let area = Rect::new(0, 0, 59, 6);
738        let panes = split_h_spaced(
739            area,
740            &[
741                Constraint::Fill(1),
742                Constraint::Fill(1),
743                Constraint::Fill(1),
744            ],
745            1,
746        );
747        assert_eq!(panes.len(), 3);
748        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
749        assert_eq!(widths, vec![19, 19, 19]);
750        // Adjacent panes are separated by exactly one gap cell, not touching.
751        assert_eq!(panes[1].left(), panes[0].right() + 1);
752        assert_eq!(panes[2].left(), panes[1].right() + 1);
753    }
754
755    #[test]
756    fn spaced_split_falls_back_with_one_pane_or_no_spacing() {
757        let area = Rect::new(0, 0, 10, 1);
758        assert_eq!(
759            split_h_spaced(area, &[Constraint::Fill(1)], 1),
760            split_h(area, &[Constraint::Fill(1)])
761        );
762        assert_eq!(
763            split_h_spaced(area, &[Constraint::Fill(1), Constraint::Fill(1)], 0),
764            split_h(area, &[Constraint::Fill(1), Constraint::Fill(1)])
765        );
766    }
767
768    #[test]
769    fn vertical_spaced_split_matches_horizontal_shape() {
770        let area = Rect::new(0, 0, 6, 59);
771        let panes = split_v_spaced(
772            area,
773            &[
774                Constraint::Fill(1),
775                Constraint::Fill(1),
776                Constraint::Fill(1),
777            ],
778            1,
779        );
780        let heights: Vec<u16> = panes.iter().map(Rect::height).collect();
781        assert_eq!(heights, vec![19, 19, 19]);
782        assert_eq!(panes[1].top(), panes[0].bottom() + 1);
783    }
784
785    #[test]
786    fn centered_rect_centers_within_the_screen() {
787        let screen = Rect::new(0, 0, 20, 10);
788        let r = centered_rect(screen, 10, 4);
789        assert_eq!(r, Rect::new(5, 3, 10, 4));
790    }
791
792    #[test]
793    fn centered_rect_clamps_to_the_screen_size_when_larger() {
794        let screen = Rect::new(0, 0, 20, 10);
795        let r = centered_rect(screen, 100, 100);
796        assert_eq!(r, Rect::new(0, 0, 20, 10));
797    }
798
799    #[test]
800    fn centered_rect_respects_a_non_origin_screen() {
801        let screen = Rect::new(5, 5, 20, 10);
802        let r = centered_rect(screen, 10, 4);
803        assert_eq!(r, Rect::new(10, 8, 10, 4));
804    }
805
806    /// `solve`'s internal `SmallBuf` scratch buffers stay on the stack for up to `STACK_CAP`
807    /// (8) items and fall back to the heap past that; this covers a constraint count past the
808    /// cap (all-`Fixed`, so `sizes` alone crosses into the heap path) and asserts the result is
809    /// identical in shape to what an all-`Vec` implementation would produce: every pane keeps its
810    /// requested size and the total exactly fills the area.
811    #[test]
812    fn split_beyond_stack_cap_matches_small_case_behavior() {
813        let panes = 20; // > STACK_CAP
814        let area = Rect::new(0, 0, panes as u16, 1);
815        let constraints = vec![Constraint::Fixed(1); panes];
816        let widths: Vec<u16> = split_h(area, &constraints)
817            .iter()
818            .map(Rect::width)
819            .collect();
820        assert_eq!(widths, vec![1u16; panes]);
821        assert_eq!(widths.iter().sum::<u16>(), panes as u16);
822    }
823
824    /// Same as above, but exercises the flexible-pane path (`flexible`/`shares`/`fracs`/`order`
825    /// scratch buffers) past `STACK_CAP` by mixing every `Constraint` kind across enough panes
826    /// that the flexible subset alone also crosses the stack cap.
827    #[test]
828    fn weighted_fill_beyond_stack_cap_matches_small_case_proportions() {
829        let area = Rect::new(0, 0, 100, 1);
830        // 20 Fill(1) panes: same proportional-split logic as the 2/3-pane cases above, just at
831        // a pane count that forces every scratch buffer in `solve` onto the heap.
832        let constraints = vec![Constraint::Fill(1); 20];
833        let widths: Vec<u16> = split_h(area, &constraints)
834            .iter()
835            .map(Rect::width)
836            .collect();
837        assert_eq!(widths.len(), 20);
838        assert_eq!(widths.iter().sum::<u16>(), 100);
839        // Equal weights distribute as evenly as integer division allows: every width is 5.
840        assert!(widths.iter().all(|&w| w == 5));
841    }
842}