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