Skip to main content

retroglyph_ui/
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; they return the gap rects alongside the content panes so a caller can draw
8//! dividers in them without recomputing the gap positions.
9//!
10//! Every `split_*` function has a const-generic `split_*_n` sibling ([`split_v_n`]/[`split_h_n`],
11//! plus the `_flex`/`_spaced` combinations) that takes `[Constraint; N]` and returns `[Rect; N]`
12//! instead of allocating a `Vec<Rect>`: useful for a fixed pane count re-split every frame, and
13//! the array return type lets a caller destructure (`let [header, body] = split_v_n(area, [..]);`)
14//! instead of indexing into a `Vec` that can silently drift out of sync with the constraint list.
15//!
16//! The solver sums the [`Fixed`](Constraint::Fixed), [`Percent`](Constraint::Percent), and
17//! [`Ratio`](Constraint::Ratio) amounts, then distributes whatever remains across the
18//! [`Fill`](Constraint::Fill),
19//! [`Min`](Constraint::Min), and [`Max`](Constraint::Max) panes in proportion to their
20//! weight: a `Fill(w)` pane claims a share proportional to `w` relative
21//! to the other flexible panes, while [`Min`](Constraint::Min) and [`Max`](Constraint::Max)
22//! panes always weigh 1. `Fill(1)` (equivalent to every pane weighing 1) reproduces plain
23//! equal distribution. Sizes are clamped so the panes never spill past `area`. This is a
24//! single sequential pass, not an iterative constraint solver: a [`Max`](Constraint::Max)
25//! pane that is capped below its share does not redistribute the excess to other panes, so
26//! leftover space can remain unclaimed (see [`Flex`] for how that leftover is placed via
27//! [`split_v_flex`]/[`split_h_flex`]).
28use alloc::vec::Vec;
29
30use retroglyph_core::grid::{HasSize, Rect, Size};
31
32/// How a single pane claims space along the split axis.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34#[non_exhaustive]
35pub enum Constraint {
36    /// An exact number of cells.
37    Fixed(u16),
38    /// A percentage (0–100) of the axis length.
39    Percent(u16),
40    /// A proportional share of the axis length, `numerator / denominator`, without picking
41    /// an arbitrary [`Fill`](Self::Fill) weight. Resolves like [`Percent`](Self::Percent): a
42    /// fixed size computed up front, not a weighted share of the remainder. A zero
43    /// `denominator` resolves to zero rather than panicking.
44    Ratio(u16, u16),
45    /// Claim a share of whatever space the fixed/percent panes leave, proportional to
46    /// `weight` relative to the other [`Fill`](Self::Fill)/[`Min`](Self::Min)/[`Max`](Self::Max)
47    /// panes in the same split ([`Min`](Self::Min)/[`Max`](Self::Max) panes always weigh 1).
48    /// `Fill(1)` reproduces plain equal distribution across an all-`Fill` split; a weight of
49    /// 0 claims no share of the remainder.
50    Fill(u16),
51    /// Like [`Fill`](Self::Fill), but guarantees at least this many cells even if the axis
52    /// is too small for every pane to get its share, and always weighs 1.
53    Min(u16),
54    /// Like [`Fill`](Self::Fill), but never grows past this many cells (any share past the
55    /// cap is left unclaimed rather than redistributed), and always weighs 1.
56    Max(u16),
57}
58
59impl Constraint {
60    /// Resolve this constraint's base size against `total` axis length.
61    /// [`Fill`](Self::Fill) and [`Max`](Self::Max) resolve to zero here;
62    /// [`Min`](Self::Min) reserves its floor up front like [`Fixed`](Self::Fixed).
63    /// Flexible sizes are filled in later by [`solve`].
64    fn base(self, total: u16) -> u16 {
65        match self {
66            Self::Fixed(n) | Self::Min(n) => n.min(total),
67            Self::Percent(p) => {
68                let p = u32::from(p.min(100));
69                // `p` is clamped to `0..=100`, so `total * p / 100 <= total`, itself a `u16`.
70                #[allow(clippy::cast_possible_truncation)]
71                {
72                    (u32::from(total) * p / 100) as u16
73                }
74            }
75            Self::Ratio(num, den) => {
76                if den == 0 {
77                    0
78                } else {
79                    // `num / den` can exceed 1 (e.g. `Ratio(3, 2)`), so the result is clamped to
80                    // `total` rather than relying on the ratio alone to stay in range.
81                    #[allow(clippy::cast_possible_truncation)]
82                    {
83                        (u32::from(total) * u32::from(num) / u32::from(den)).min(u32::from(total))
84                            as u16
85                    }
86                }
87            }
88            Self::Fill(_) | Self::Max(_) => 0,
89        }
90    }
91}
92
93/// Constraint counts at or below this stay on the stack in [`SmallBuf`]; larger splits fall back
94/// to a heap `Vec`. Chosen comfortably above a typical multi-panel layout (a header, a handful of
95/// flexible content panes, a status bar) while staying correct for arbitrarily many panes: see
96/// the `layout_solve` benchmark's 100-pane case, which exercises the heap fallback.
97const STACK_CAP: usize = 8;
98
99/// A small buffer that stays inline on the stack for up to `N` items and only allocates on the
100/// heap past that. `solve` uses this for its scratch buffers (pane sizes, the flexible-pane
101/// index/weight/cap list, and the largest-remainder distribution pass) so that the common case of
102/// a handful of panes per split (called several times per frame by multi-panel UIs) does not
103/// pay for a heap allocation at all.
104enum SmallBuf<T: Copy + Default, const N: usize> {
105    Stack([T; N], usize),
106    Heap(Vec<T>),
107}
108
109impl<T: Copy + Default, const N: usize> SmallBuf<T, N> {
110    /// Create a buffer able to hold `cap` items without reallocating: inline on the stack if
111    /// `cap` fits within `N`, otherwise a heap `Vec` pre-sized to `cap`.
112    fn with_capacity(cap: usize) -> Self {
113        if cap <= N {
114            Self::Stack([T::default(); N], 0)
115        } else {
116            Self::Heap(Vec::with_capacity(cap))
117        }
118    }
119
120    /// Append `value`.
121    ///
122    /// # Panics
123    ///
124    /// Panics if the buffer is the `Stack` variant and already holds `N` items: callers must
125    /// size `with_capacity` to the true upper bound of pushes, as `solve` does.
126    fn push(&mut self, value: T) {
127        match self {
128            Self::Stack(buf, len) => {
129                buf[*len] = value;
130                *len += 1;
131            }
132            Self::Heap(vec) => vec.push(value),
133        }
134    }
135}
136
137impl<T: Copy + Default, const N: usize> core::ops::Deref for SmallBuf<T, N> {
138    type Target = [T];
139
140    fn deref(&self) -> &[T] {
141        match self {
142            Self::Stack(buf, len) => &buf[..*len],
143            Self::Heap(vec) => vec,
144        }
145    }
146}
147
148impl<T: Copy + Default, const N: usize> core::ops::DerefMut for SmallBuf<T, N> {
149    fn deref_mut(&mut self) -> &mut [T] {
150        match self {
151            Self::Stack(buf, len) => &mut buf[..*len],
152            Self::Heap(vec) => vec,
153        }
154    }
155}
156
157impl<T: Copy + Default, const N: usize> core::ops::Index<usize> for SmallBuf<T, N> {
158    type Output = T;
159
160    fn index(&self, idx: usize) -> &T {
161        &(**self)[idx]
162    }
163}
164
165impl<T: Copy + Default, const N: usize> core::ops::IndexMut<usize> for SmallBuf<T, N> {
166    fn index_mut(&mut self, idx: usize) -> &mut T {
167        &mut (**self)[idx]
168    }
169}
170
171/// Compute the length of each pane along an axis of `total` cells.
172fn solve(total: u16, constraints: &[Constraint]) -> SmallBuf<u16, STACK_CAP> {
173    let mut sizes: SmallBuf<u16, STACK_CAP> = SmallBuf::with_capacity(constraints.len());
174    for c in constraints {
175        sizes.push(c.base(total));
176    }
177
178    // Clamp the fixed/percent sum so it never exceeds the axis. If it does,
179    // shave from the tail so earlier panes keep their requested size.
180    let mut used: u16 = 0;
181    for size in sizes.iter_mut() {
182        let room = total.saturating_sub(used);
183        *size = (*size).min(room);
184        used += *size;
185    }
186
187    // Distribute the remainder across the Fill, Min, and Max panes in proportion to
188    // their weight (Fill(w) weighs w; Min/Max always weigh 1). Min panes add their
189    // share on top of the floor already reserved above; Max panes start at zero and
190    // are capped at their declared value (any share past the cap is simply left
191    // unclaimed, not redistributed).
192    let mut flexible: SmallBuf<(usize, u16, Option<u16>), STACK_CAP> =
193        SmallBuf::with_capacity(constraints.len());
194    for (i, c) in constraints.iter().enumerate() {
195        match c {
196            Constraint::Fill(weight) => flexible.push((i, *weight, None)),
197            Constraint::Min(_) => flexible.push((i, 1, None)),
198            Constraint::Max(cap) => flexible.push((i, 1, Some(*cap))),
199            Constraint::Fixed(_) | Constraint::Percent(_) | Constraint::Ratio(_, _) => {}
200        }
201    }
202    if !flexible.is_empty() {
203        let remainder = total.saturating_sub(used);
204        let total_weight: u32 = flexible.iter().map(|&(_, w, _)| u32::from(w)).sum();
205        if let Some(total_weight) = core::num::NonZeroU32::new(total_weight) {
206            // Largest-remainder method: give every pane the integer floor of its
207            // proportional share, then hand out the leftover cells one at a time to
208            // the panes with the largest fractional remainder (ties -> earlier pane
209            // first). For equal weights every fraction ties, so this reduces to the
210            // original round-robin-from-the-front behavior exactly.
211            let mut shares: SmallBuf<u32, STACK_CAP> = SmallBuf::with_capacity(flexible.len());
212            let mut fracs: SmallBuf<u32, STACK_CAP> = SmallBuf::with_capacity(flexible.len());
213            let mut floor_sum: u32 = 0;
214            for &(_, weight, _) in flexible.iter() {
215                let product = u32::from(remainder) * u32::from(weight);
216                let share = product / total_weight;
217                fracs.push(product % total_weight);
218                shares.push(share);
219                floor_sum += share;
220            }
221            let mut leftover = u32::from(remainder).saturating_sub(floor_sum);
222            let mut order: SmallBuf<usize, STACK_CAP> = SmallBuf::with_capacity(flexible.len());
223            for idx in 0..flexible.len() {
224                order.push(idx);
225            }
226            order.sort_by(|&a, &b| fracs[b].cmp(&fracs[a]).then(a.cmp(&b)));
227            for &idx in order.iter() {
228                if leftover == 0 {
229                    break;
230                }
231                shares[idx] += 1;
232                leftover -= 1;
233            }
234            for (k, &(i, _, cap)) in flexible.iter().enumerate() {
235                // `shares[k]` is an integer share of `remainder` (a `u16` widened to `u32`), so it
236                // can never exceed `remainder` itself and fits back in a `u16`.
237                #[allow(clippy::cast_possible_truncation)]
238                let share = shares[k] as u16;
239                let grown = sizes[i].saturating_add(share);
240                sizes[i] = cap.map_or(grown, |max| grown.min(max));
241            }
242        }
243    }
244
245    sizes
246}
247
248/// Const-generic sibling of [`solve`]: same algorithm, but sized to `N` at compile time, so
249/// every scratch buffer is a plain `[T; N]` on the stack and there is no [`SmallBuf`] heap
250/// fallback to worry about, whatever `N` is. Used by [`split_v_n`]/[`split_h_n`] and their
251/// `_flex`/`_spaced` siblings.
252fn solve_n<const N: usize>(total: u16, constraints: &[Constraint; N]) -> [u16; N] {
253    let mut sizes = [0u16; N];
254    for (i, c) in constraints.iter().enumerate() {
255        sizes[i] = c.base(total);
256    }
257
258    // Clamp the fixed/percent sum so it never exceeds the axis, same as `solve`.
259    let mut used: u16 = 0;
260    for size in &mut sizes {
261        let room = total.saturating_sub(used);
262        *size = (*size).min(room);
263        used += *size;
264    }
265
266    // Same largest-remainder distribution as `solve`, but into fixed-size `[T; N]` scratch
267    // (at most `N` panes can be flexible, so `N` is always enough room).
268    let mut flexible: [(usize, u16, Option<u16>); N] = [(0, 0, None); N];
269    let mut flex_len = 0usize;
270    for (i, c) in constraints.iter().enumerate() {
271        match c {
272            Constraint::Fill(weight) => {
273                flexible[flex_len] = (i, *weight, None);
274                flex_len += 1;
275            }
276            Constraint::Min(_) => {
277                flexible[flex_len] = (i, 1, None);
278                flex_len += 1;
279            }
280            Constraint::Max(cap) => {
281                flexible[flex_len] = (i, 1, Some(*cap));
282                flex_len += 1;
283            }
284            Constraint::Fixed(_) | Constraint::Percent(_) | Constraint::Ratio(_, _) => {}
285        }
286    }
287    if flex_len > 0 {
288        let remainder = total.saturating_sub(used);
289        let total_weight: u32 = flexible[..flex_len]
290            .iter()
291            .map(|&(_, w, _)| u32::from(w))
292            .sum();
293        if let Some(total_weight) = core::num::NonZeroU32::new(total_weight) {
294            let mut shares = [0u32; N];
295            let mut fracs = [0u32; N];
296            let mut floor_sum: u32 = 0;
297            for (k, &(_, weight, _)) in flexible[..flex_len].iter().enumerate() {
298                let product = u32::from(remainder) * u32::from(weight);
299                let share = product / total_weight;
300                fracs[k] = product % total_weight;
301                shares[k] = share;
302                floor_sum += share;
303            }
304            let mut leftover = u32::from(remainder).saturating_sub(floor_sum);
305            let mut order = [0usize; N];
306            for (idx, slot) in order[..flex_len].iter_mut().enumerate() {
307                *slot = idx;
308            }
309            order[..flex_len].sort_by(|&a, &b| fracs[b].cmp(&fracs[a]).then(a.cmp(&b)));
310            for &idx in &order[..flex_len] {
311                if leftover == 0 {
312                    break;
313                }
314                shares[idx] += 1;
315                leftover -= 1;
316            }
317            for (k, &(i, _, cap)) in flexible[..flex_len].iter().enumerate() {
318                // `shares[k]` is an integer share of `remainder` (a `u16` widened to `u32`), so
319                // it can never exceed `remainder` itself and fits back in a `u16`.
320                #[allow(clippy::cast_possible_truncation)]
321                let share = shares[k] as u16;
322                let grown = sizes[i].saturating_add(share);
323                sizes[i] = cap.map_or(grown, |max| grown.min(max));
324            }
325        }
326    }
327
328    sizes
329}
330
331/// Split `area` into stacked rows top-to-bottom.
332///
333/// Returns one [`Rect`] per constraint; empty panes (zero height) are still
334/// returned so indices line up with `constraints`.
335///
336/// Never panics: a degenerate `area` (zero height, zero width, or both) resolves every
337/// constraint to a zero-height pane via [`saturating_sub`](u16::saturating_sub) arithmetic
338/// rather than under/overflowing, and an empty `constraints` slice simply returns an empty
339/// `Vec`.
340///
341/// # Examples
342///
343/// ```
344/// use retroglyph_core::grid::Rect;
345/// use retroglyph_ui::{Constraint, split_v};
346///
347/// let area = Rect::new(0, 0, 20, 10);
348/// let panes = split_v(area, &[Constraint::Fixed(1), Constraint::Fill(1), Constraint::Fixed(1)]);
349/// assert_eq!(panes.iter().map(Rect::height).collect::<Vec<_>>(), vec![1, 8, 1]);
350/// ```
351#[must_use]
352pub fn split_v(area: Rect, constraints: &[Constraint]) -> Vec<Rect> {
353    let sizes = solve(area.height(), constraints);
354    let mut y = area.top();
355    sizes
356        .iter()
357        .copied()
358        .map(|h| {
359            let rect = Rect::new(area.left(), y, area.width(), h);
360            y = y.saturating_add(h);
361            rect
362        })
363        .collect()
364}
365
366/// Split `area` into stacked rows top-to-bottom, like [`split_v`], but sized to a compile-time
367/// pane count `N`: takes `[Constraint; N]` and returns `[Rect; N]` instead of allocating a `Vec`.
368///
369/// The array length ties the constraint count to the return type, so a caller that destructures
370/// the result (`let [header, body, footer] = split_v_n(area, [..]);`) gets a compile error if it
371/// adds or removes a constraint without updating the destructuring pattern, rather than a
372/// silently out-of-sync index into a `Vec`. Never allocates, for any `N`.
373///
374/// Never panics, for the same reason as [`split_v`].
375///
376/// # Examples
377///
378/// ```
379/// use retroglyph_core::grid::Rect;
380/// use retroglyph_ui::{Constraint, split_v_n};
381///
382/// let area = Rect::new(0, 0, 20, 10);
383/// let [header, body, footer] =
384///     split_v_n(area, [Constraint::Fixed(1), Constraint::Fill(1), Constraint::Fixed(1)]);
385/// assert_eq!((header.height(), body.height(), footer.height()), (1, 8, 1));
386/// ```
387#[must_use]
388pub fn split_v_n<const N: usize>(area: Rect, constraints: [Constraint; N]) -> [Rect; N] {
389    let sizes = solve_n(area.height(), &constraints);
390    let mut y = area.top();
391    core::array::from_fn(|i| {
392        let h = sizes[i];
393        let rect = Rect::new(area.left(), y, area.width(), h);
394        y = y.saturating_add(h);
395        rect
396    })
397}
398
399/// Split `area` into columns left-to-right.
400///
401/// Returns one [`Rect`] per constraint; empty panes (zero width) are still
402/// returned so indices line up with `constraints`.
403///
404/// Never panics, for the same reason as [`split_v`]: a degenerate `area` resolves every
405/// constraint to a zero-width pane instead of under/overflowing, and an empty `constraints`
406/// slice returns an empty `Vec`.
407///
408/// # Examples
409///
410/// ```
411/// use retroglyph_core::grid::Rect;
412/// use retroglyph_ui::{Constraint, split_h};
413///
414/// let area = Rect::new(0, 0, 100, 5);
415/// let panes = split_h(area, &[Constraint::Percent(30), Constraint::Fill(1)]);
416/// assert_eq!(panes.iter().map(Rect::width).collect::<Vec<_>>(), vec![30, 70]);
417/// ```
418#[must_use]
419pub fn split_h(area: Rect, constraints: &[Constraint]) -> Vec<Rect> {
420    let sizes = solve(area.width(), constraints);
421    let mut x = area.left();
422    sizes
423        .iter()
424        .copied()
425        .map(|w| {
426            let rect = Rect::new(x, area.top(), w, area.height());
427            x = x.saturating_add(w);
428            rect
429        })
430        .collect()
431}
432
433/// Split `area` into columns left-to-right, like [`split_h`], but sized to a compile-time pane
434/// count `N`: takes `[Constraint; N]` and returns `[Rect; N]` instead of allocating a `Vec`.
435///
436/// See [`split_v_n`] for why the array-sized signature is worth it over indexing a `Vec`. Never
437/// allocates, for any `N`; never panics, for the same reason as [`split_h`].
438///
439/// # Examples
440///
441/// ```
442/// use retroglyph_core::grid::Rect;
443/// use retroglyph_ui::{Constraint, split_h_n};
444///
445/// let area = Rect::new(0, 0, 100, 5);
446/// let [left, right] = split_h_n(area, [Constraint::Percent(30), Constraint::Fill(1)]);
447/// assert_eq!((left.width(), right.width()), (30, 70));
448/// ```
449#[must_use]
450pub fn split_h_n<const N: usize>(area: Rect, constraints: [Constraint; N]) -> [Rect; N] {
451    let sizes = solve_n(area.width(), &constraints);
452    let mut x = area.left();
453    core::array::from_fn(|i| {
454        let w = sizes[i];
455        let rect = Rect::new(x, area.top(), w, area.height());
456        x = x.saturating_add(w);
457        rect
458    })
459}
460
461/// How adjacent panes relate along the split axis in a `_spaced` split: a fixed gap between
462/// them, or a shared, overlapping edge.
463///
464/// A plain `u16` (the historic `spacing` parameter) converts to [`Space`](Self::Space) via
465/// [`From`], so existing callers of [`split_h_spaced`]/[`split_v_spaced`]/[`split_h_n_spaced`]/
466/// [`split_v_n_spaced`] keep compiling unchanged.
467#[derive(Clone, Copy, Debug, PartialEq, Eq)]
468#[non_exhaustive]
469pub enum Spacing {
470    /// Carve out a fixed `n`-cell gap between every adjacent pair of panes. The gap comes out
471    /// of the area before constraints are resolved, so [`Fill`](Constraint::Fill)/
472    /// [`Percent`](Constraint::Percent) panes share only what's left after every gap is
473    /// reserved. No-op with fewer than two panes or `n == 0`.
474    Space(u16),
475    /// Shift every pane back by `n` cells so it overlaps the trailing edge of the previous
476    /// pane, e.g. so two adjacent bordered panels can share one line of border instead of each
477    /// drawing its own (ratatui's `Spacing::Overlap`). Purely geometric: retroglyph does not
478    /// merge the overlapping cells' contents, so whichever pane is drawn last wins on the
479    /// shared cells. No-op with fewer than two panes or `n == 0`.
480    Overlap(u16),
481}
482
483impl Spacing {
484    /// The gap/overlap cell count, regardless of which variant this is.
485    const fn cells(self) -> u16 {
486        match self {
487            Self::Space(n) | Self::Overlap(n) => n,
488        }
489    }
490}
491
492impl From<u16> for Spacing {
493    /// A plain cell count is a gap, matching the historic `u16` spacing parameter.
494    fn from(n: u16) -> Self {
495        Self::Space(n)
496    }
497}
498
499/// Split `area` into columns left-to-right, like [`split_h`], but with a `spacing`-cell gap or
500/// overlap between every adjacent pair of panes; see [`Spacing`].
501///
502/// `spacing`'s cell count is reserved from (for [`Spacing::Space`]) or added back to (for
503/// [`Spacing::Overlap`]) `area` before `constraints` are resolved, so
504/// [`Fill`](Constraint::Fill)/[`Percent`](Constraint::Percent) panes share only what's left
505/// after every gap is reserved, or the full extra room an overlap frees up. No-op (falls back
506/// to [`split_h`], with an empty gap `Vec`) with fewer than two panes or zero spacing/overlap.
507///
508/// Returns `(panes, gaps)`: `gaps` has one entry between every adjacent pair of panes (so
509/// `constraints.len() - 1` gaps, none leading or trailing), in the same left-to-right order as
510/// `panes`. For [`Spacing::Space`] a gap rect is the empty cell(s) between the two panes it
511/// separates; for [`Spacing::Overlap`] it is the shared cell(s) both adjacent panes draw over
512/// (whichever pane is drawn last wins there), which is exactly the region a caller would want to
513/// draw a shared border into.
514///
515/// # Examples
516///
517/// ```
518/// use retroglyph_core::grid::Rect;
519/// use retroglyph_ui::{Constraint, Spacing, split_h_spaced};
520///
521/// let area = Rect::new(0, 0, 59, 6);
522/// let (panes, gaps) = split_h_spaced(area, &[Constraint::Fill(1); 3], 1);
523/// assert_eq!(panes.iter().map(Rect::width).collect::<Vec<_>>(), vec![19, 19, 19]);
524/// assert_eq!(panes[1].left(), panes[0].right() + 1); // one gap cell between panes
525/// assert_eq!(gaps.len(), 2); // one gap between each adjacent pair of panes
526/// assert_eq!(gaps[0], Rect::new(19, 0, 1, 6));
527///
528/// let (panes, gaps) = split_h_spaced(area, &[Constraint::Fill(1); 2], Spacing::Overlap(1));
529/// assert_eq!(panes[1].left(), panes[0].right() - 1); // panes share one border column
530/// assert_eq!(gaps[0], Rect::new(panes[0].right() - 1, 0, 1, 6)); // the shared column
531/// ```
532#[must_use]
533pub fn split_h_spaced(
534    area: Rect,
535    constraints: &[Constraint],
536    spacing: impl Into<Spacing>,
537) -> (Vec<Rect>, Vec<Rect>) {
538    let spacing = spacing.into();
539    if spacing.cells() == 0 || constraints.len() < 2 {
540        return (split_h(area, constraints), Vec::new());
541    }
542    let sizes = solve(
543        spaced_total(area.width(), constraints.len(), spacing),
544        constraints,
545    );
546    let mut x = area.left();
547    let mut panes = Vec::with_capacity(sizes.len());
548    let mut gaps = Vec::with_capacity(sizes.len().saturating_sub(1));
549    let last = sizes.len().saturating_sub(1);
550    for (i, w) in sizes.iter().copied().enumerate() {
551        panes.push(Rect::new(x, area.top(), w, area.height()));
552        if i < last {
553            let (gx, gw) = spacer_span(x, w, spacing);
554            gaps.push(Rect::new(gx, area.top(), gw, area.height()));
555        }
556        x = step(x, w, spacing);
557    }
558    (panes, gaps)
559}
560
561/// Split `area` into stacked rows top-to-bottom, like [`split_v`], but with a `spacing`-cell gap
562/// or overlap between every adjacent pair of panes; see [`Spacing`].
563///
564/// See [`split_h_spaced`] for the full behavior, including the shape and meaning of the returned
565/// `(panes, gaps)`; this is the same operation along the vertical axis.
566#[must_use]
567pub fn split_v_spaced(
568    area: Rect,
569    constraints: &[Constraint],
570    spacing: impl Into<Spacing>,
571) -> (Vec<Rect>, Vec<Rect>) {
572    let spacing = spacing.into();
573    if spacing.cells() == 0 || constraints.len() < 2 {
574        return (split_v(area, constraints), Vec::new());
575    }
576    let sizes = solve(
577        spaced_total(area.height(), constraints.len(), spacing),
578        constraints,
579    );
580    let mut y = area.top();
581    let mut panes = Vec::with_capacity(sizes.len());
582    let mut gaps = Vec::with_capacity(sizes.len().saturating_sub(1));
583    let last = sizes.len().saturating_sub(1);
584    for (i, h) in sizes.iter().copied().enumerate() {
585        panes.push(Rect::new(area.left(), y, area.width(), h));
586        if i < last {
587            let (gy, gh) = spacer_span(y, h, spacing);
588            gaps.push(Rect::new(area.left(), gy, area.width(), gh));
589        }
590        y = step(y, h, spacing);
591    }
592    (panes, gaps)
593}
594
595/// The axis length to solve `count` panes' [`Constraint`]s against for a `_spaced` split: `total`
596/// with `count - 1` gaps of `spacing.cells()` reserved out ([`Spacing::Space`]) or handed back in
597/// ([`Spacing::Overlap`]). Shared by the `Vec`-returning and array-returning `_spaced` splits.
598const fn spaced_total(total: u16, count: usize, spacing: Spacing) -> u16 {
599    // `count` is the number of panes in one layout split, nowhere near `u16::MAX` in any
600    // realistic UI, and callers only reach here with `count >= 2`.
601    #[allow(clippy::cast_possible_truncation)]
602    let gaps = count as u16 - 1;
603    let delta = spacing.cells().saturating_mul(gaps);
604    match spacing {
605        Spacing::Space(_) => total.saturating_sub(delta),
606        Spacing::Overlap(_) => total.saturating_add(delta),
607    }
608}
609
610/// Advance a `_spaced` split's cursor past a pane of size `size` starting at `pos`, leaving a
611/// [`Spacing::Space`] gap or eating into the next pane by a [`Spacing::Overlap`] amount.
612const fn step(pos: u16, size: u16, spacing: Spacing) -> u16 {
613    match spacing {
614        Spacing::Space(n) => pos.saturating_add(size).saturating_add(n),
615        Spacing::Overlap(n) => pos.saturating_add(size).saturating_sub(n),
616    }
617}
618
619/// The gap rect's start position and length along the split axis, trailing the pane of `size`
620/// cells starting at `pos`. For [`Spacing::Space`] this is the empty `n`-cell run right after the
621/// pane; for [`Spacing::Overlap`] it is the pane's own trailing `n` cells, which the next pane
622/// draws over. Shared by every `_spaced` split, `Vec`- and array-returning alike.
623const fn spacer_span(pos: u16, size: u16, spacing: Spacing) -> (u16, u16) {
624    let n = spacing.cells();
625    let start = match spacing {
626        Spacing::Space(_) => pos.saturating_add(size),
627        Spacing::Overlap(_) => pos.saturating_add(size).saturating_sub(n),
628    };
629    (start, n)
630}
631
632/// Split `area` into columns left-to-right, like [`split_h_n`], but with a `spacing`-cell gap or
633/// overlap between every adjacent pair of panes, like [`split_h_spaced`]; see [`Spacing`].
634///
635/// Reserves or hands back `spacing.cells() * (N - 1)` cells up front (equivalent to
636/// [`split_h_spaced`]) and solves the remaining panes against what's left, so
637/// [`Fill`](Constraint::Fill)/[`Percent`](Constraint::Percent) panes share only the space after
638/// every gap is reserved, same as [`split_h_spaced`]. No-op (falls back to [`split_h_n`], with an
639/// empty gap `Vec`) with fewer than two panes or zero spacing/overlap.
640///
641/// Returns `(panes, gaps)`, same meaning as [`split_h_spaced`]'s return value: `panes` is the
642/// array of `N` content panes (never allocates, unlike the `Vec`-returning [`split_h_spaced`]),
643/// and `gaps` is a `Vec` of the `N - 1` gap rects between them. `gaps` is a `Vec` rather than a
644/// second const-generic array because its length (`N - 1`) is not expressible as a fixed-size
645/// array tied to `N` on stable Rust.
646///
647/// # Examples
648///
649/// ```
650/// use retroglyph_core::grid::Rect;
651/// use retroglyph_ui::{Constraint, split_h_n_spaced};
652///
653/// let area = Rect::new(0, 0, 59, 6);
654/// let ([a, b, c], gaps) = split_h_n_spaced(area, [Constraint::Fill(1); 3], 1);
655/// assert_eq!((a.width(), b.width(), c.width()), (19, 19, 19));
656/// assert_eq!(b.left(), a.right() + 1);
657/// assert_eq!(gaps.len(), 2);
658/// ```
659#[must_use]
660pub fn split_h_n_spaced<const N: usize>(
661    area: Rect,
662    constraints: [Constraint; N],
663    spacing: impl Into<Spacing>,
664) -> ([Rect; N], Vec<Rect>) {
665    let spacing = spacing.into();
666    if spacing.cells() == 0 || N < 2 {
667        return (split_h_n(area, constraints), Vec::new());
668    }
669    let sizes = solve_n(spaced_total(area.width(), N, spacing), &constraints);
670    let mut x = area.left();
671    let mut gaps = Vec::with_capacity(N.saturating_sub(1));
672    let panes = core::array::from_fn(|i| {
673        let w = sizes[i];
674        let rect = Rect::new(x, area.top(), w, area.height());
675        if i + 1 < N {
676            let (gx, gw) = spacer_span(x, w, spacing);
677            gaps.push(Rect::new(gx, area.top(), gw, area.height()));
678        }
679        x = step(x, w, spacing);
680        rect
681    });
682    (panes, gaps)
683}
684
685/// Split `area` into stacked rows top-to-bottom, like [`split_v_n`], but with a `spacing`-cell
686/// gap or overlap between every adjacent pair of panes, like [`split_v_spaced`]; see [`Spacing`].
687///
688/// See [`split_h_n_spaced`] for the full behavior, including why `gaps` is a `Vec` rather than a
689/// second const-generic array; this is the same operation along the vertical axis.
690#[must_use]
691pub fn split_v_n_spaced<const N: usize>(
692    area: Rect,
693    constraints: [Constraint; N],
694    spacing: impl Into<Spacing>,
695) -> ([Rect; N], Vec<Rect>) {
696    let spacing = spacing.into();
697    if spacing.cells() == 0 || N < 2 {
698        return (split_v_n(area, constraints), Vec::new());
699    }
700    let sizes = solve_n(spaced_total(area.height(), N, spacing), &constraints);
701    let mut y = area.top();
702    let mut gaps = Vec::with_capacity(N.saturating_sub(1));
703    let panes = core::array::from_fn(|i| {
704        let h = sizes[i];
705        let rect = Rect::new(area.left(), y, area.width(), h);
706        if i + 1 < N {
707            let (gy, gh) = spacer_span(y, h, spacing);
708            gaps.push(Rect::new(area.left(), gy, area.width(), gh));
709        }
710        y = step(y, h, spacing);
711        rect
712    });
713    (panes, gaps)
714}
715
716/// How leftover space is placed along the split axis, once [`Constraint`]s
717/// are resolved.
718///
719/// Only matters when the resolved pane sizes sum to less than `area`'s
720/// length; passed to [`split_v_flex`]/[`split_h_flex`].
721///
722/// [`split_v`]/[`split_h`] always behave like [`Start`](Self::Start): any
723/// leftover space trails after the last pane, unclaimed. This matches their
724/// existing documented behavior, so adding `Flex` does not change them.
725#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
726#[non_exhaustive]
727pub enum Flex {
728    /// Panes are packed at the start of the area; leftover space trails
729    /// after the last pane. The default, and what [`split_v`]/[`split_h`] use.
730    #[default]
731    Start,
732    /// Panes are packed at the end of the area; leftover space leads before
733    /// the first pane.
734    End,
735    /// Leftover space is split evenly before and after the panes.
736    Center,
737    /// Leftover space is distributed as gaps between panes (none before the
738    /// first or after the last). No-op with fewer than two panes.
739    SpaceBetween,
740    /// Leftover space is distributed as equal-width gaps around every pane,
741    /// including before the first and after the last.
742    SpaceAround,
743}
744
745/// Compute each pane's starting offset along an axis of `total` cells for
746/// the resolved `sizes`, per `flex`. Companion to [`solve`]; used by
747/// [`split_v_flex`]/[`split_h_flex`].
748///
749/// Returns a [`SmallBuf`], not a `Vec`, so the common small-split case does not pay for a heap
750/// allocation here either (same rationale as `solve`'s scratch buffers).
751fn place(total: u16, sizes: &[u16], flex: Flex) -> SmallBuf<u16, STACK_CAP> {
752    let content: u16 = sizes.iter().fold(0u16, |a, &b| a.saturating_add(b));
753    let slack = total.saturating_sub(content);
754    let n = sizes.len();
755    let mut offsets: SmallBuf<u16, STACK_CAP> = SmallBuf::with_capacity(n);
756
757    let packed_from = |start: u16, offsets: &mut SmallBuf<u16, STACK_CAP>| {
758        let mut pos = start;
759        for &s in sizes {
760            offsets.push(pos);
761            pos = pos.saturating_add(s);
762        }
763    };
764
765    match flex {
766        Flex::End => packed_from(slack, &mut offsets),
767        Flex::Center => packed_from(slack / 2, &mut offsets),
768        Flex::SpaceBetween if n > 1 => {
769            // `n` is the number of panes in one layout split, nowhere near `u16::MAX` in any
770            // realistic UI.
771            #[allow(clippy::cast_possible_truncation)]
772            let gaps = n as u16 - 1;
773            let gap = slack / gaps;
774            let mut extra = slack % gaps;
775            let mut pos = 0;
776            for (i, &s) in sizes.iter().enumerate() {
777                offsets.push(pos);
778                pos = pos.saturating_add(s);
779                if i + 1 < n {
780                    pos = pos.saturating_add(gap + u16::from(extra > 0));
781                    extra = extra.saturating_sub(1);
782                }
783            }
784        }
785        Flex::Start | Flex::SpaceBetween => packed_from(0, &mut offsets),
786        Flex::SpaceAround => {
787            // `n` is the number of panes in one layout split, nowhere near `u16::MAX` in any
788            // realistic UI.
789            #[allow(clippy::cast_possible_truncation)]
790            let gaps = n as u16 + 1;
791            let unit = slack / gaps;
792            let mut extra = slack % gaps;
793            let mut pos = unit + u16::from(extra > 0);
794            extra = extra.saturating_sub(u16::from(extra > 0));
795            for &s in sizes {
796                offsets.push(pos);
797                pos = pos.saturating_add(s);
798                pos = pos.saturating_add(unit + u16::from(extra > 0));
799                extra = extra.saturating_sub(u16::from(extra > 0));
800            }
801        }
802    }
803
804    offsets
805}
806
807/// Const-generic sibling of [`place`], used by [`split_v_n_flex`]/[`split_h_n_flex`]. Same
808/// algorithm, but into a fixed-size `[u16; N]` so it never allocates, for any `N`.
809fn place_n<const N: usize>(total: u16, sizes: &[u16; N], flex: Flex) -> [u16; N] {
810    let content: u16 = sizes.iter().fold(0u16, |a, &b| a.saturating_add(b));
811    let slack = total.saturating_sub(content);
812    let mut offsets = [0u16; N];
813
814    let packed_from = |start: u16, offsets: &mut [u16; N]| {
815        let mut pos = start;
816        for (o, &s) in offsets.iter_mut().zip(sizes.iter()) {
817            *o = pos;
818            pos = pos.saturating_add(s);
819        }
820    };
821
822    match flex {
823        Flex::End => packed_from(slack, &mut offsets),
824        Flex::Center => packed_from(slack / 2, &mut offsets),
825        Flex::SpaceBetween if N > 1 => {
826            // `N` is the number of panes in one layout split, nowhere near `u16::MAX` in any
827            // realistic UI.
828            #[allow(clippy::cast_possible_truncation)]
829            let gaps = N as u16 - 1;
830            let gap = slack / gaps;
831            let mut extra = slack % gaps;
832            let mut pos = 0;
833            for (i, &s) in sizes.iter().enumerate() {
834                offsets[i] = pos;
835                pos = pos.saturating_add(s);
836                if i + 1 < N {
837                    pos = pos.saturating_add(gap + u16::from(extra > 0));
838                    extra = extra.saturating_sub(1);
839                }
840            }
841        }
842        Flex::Start | Flex::SpaceBetween => packed_from(0, &mut offsets),
843        Flex::SpaceAround => {
844            // `N` is the number of panes in one layout split, nowhere near `u16::MAX` in any
845            // realistic UI.
846            #[allow(clippy::cast_possible_truncation)]
847            let gaps = N as u16 + 1;
848            let unit = slack / gaps;
849            let mut extra = slack % gaps;
850            let mut pos = unit + u16::from(extra > 0);
851            extra = extra.saturating_sub(u16::from(extra > 0));
852            for (i, &s) in sizes.iter().enumerate() {
853                offsets[i] = pos;
854                pos = pos.saturating_add(s);
855                pos = pos.saturating_add(unit + u16::from(extra > 0));
856                extra = extra.saturating_sub(u16::from(extra > 0));
857            }
858        }
859    }
860
861    offsets
862}
863
864/// Split `area` into stacked rows top-to-bottom, like [`split_v`], but with
865/// explicit control over how leftover space is placed via [`Flex`].
866///
867/// Never panics, for the same reason as [`split_v`]: every offset is computed with
868/// [`saturating_add`](u16::saturating_add)/[`saturating_sub`](u16::saturating_sub).
869#[must_use]
870pub fn split_v_flex(area: Rect, constraints: &[Constraint], flex: Flex) -> Vec<Rect> {
871    let sizes = solve(area.height(), constraints);
872    let offsets = place(area.height(), &sizes, flex);
873    offsets
874        .iter()
875        .copied()
876        .zip(sizes.iter().copied())
877        .map(|(y, h)| Rect::new(area.left(), area.top().saturating_add(y), area.width(), h))
878        .collect()
879}
880
881/// Split `area` into stacked rows top-to-bottom, like [`split_v_n`], but with explicit control
882/// over how leftover space is placed via [`Flex`], like [`split_v_flex`].
883///
884/// Never allocates, for any `N`; never panics, for the same reason as [`split_v_flex`].
885#[must_use]
886pub fn split_v_n_flex<const N: usize>(
887    area: Rect,
888    constraints: [Constraint; N],
889    flex: Flex,
890) -> [Rect; N] {
891    let sizes = solve_n(area.height(), &constraints);
892    let offsets = place_n(area.height(), &sizes, flex);
893    core::array::from_fn(|i| {
894        Rect::new(
895            area.left(),
896            area.top().saturating_add(offsets[i]),
897            area.width(),
898            sizes[i],
899        )
900    })
901}
902
903/// Split `area` into columns left-to-right, like [`split_h`], but with
904/// explicit control over how leftover space is placed via [`Flex`].
905///
906/// Never panics, for the same reason as [`split_h`]: every offset is computed with
907/// [`saturating_add`](u16::saturating_add)/[`saturating_sub`](u16::saturating_sub).
908#[must_use]
909pub fn split_h_flex(area: Rect, constraints: &[Constraint], flex: Flex) -> Vec<Rect> {
910    let sizes = solve(area.width(), constraints);
911    let offsets = place(area.width(), &sizes, flex);
912    offsets
913        .iter()
914        .copied()
915        .zip(sizes.iter().copied())
916        .map(|(x, w)| Rect::new(area.left().saturating_add(x), area.top(), w, area.height()))
917        .collect()
918}
919
920/// Split `area` into columns left-to-right, like [`split_h_n`], but with explicit control over
921/// how leftover space is placed via [`Flex`], like [`split_h_flex`].
922///
923/// Never allocates, for any `N`; never panics, for the same reason as [`split_h_flex`].
924#[must_use]
925pub fn split_h_n_flex<const N: usize>(
926    area: Rect,
927    constraints: [Constraint; N],
928    flex: Flex,
929) -> [Rect; N] {
930    let sizes = solve_n(area.width(), &constraints);
931    let offsets = place_n(area.width(), &sizes, flex);
932    core::array::from_fn(|i| {
933        Rect::new(
934            area.left().saturating_add(offsets[i]),
935            area.top(),
936            sizes[i],
937            area.height(),
938        )
939    })
940}
941
942/// Compute a `width`×`height` [`Rect`] centered within `screen`.
943///
944/// `width`/`height` are clamped down to `screen`'s own dimensions if larger,
945/// so the result never extends past `screen`'s edges: a modal, dialog, or
946/// tooltip box built from this is always fully on-screen, even on a
947/// terminal too small to fit the box's requested size. Pure layout math: no
948/// drawing, no `Terminal`. Pairs with `panel`/`modal` in `retroglyph-ui`
949/// (the `draw` module) for a centered, bordered box.
950///
951/// Never panics: the clamp and centering offsets are computed with saturating arithmetic, so a
952/// zero-size `screen`, `width`, or `height` resolves to a zero-size or edge-pinned rect instead
953/// of under/overflowing.
954#[must_use]
955pub fn centered_rect(screen: Rect, width: u16, height: u16) -> Rect {
956    let width = width.min(screen.width());
957    let height = height.min(screen.height());
958    let x = screen.left().saturating_add((screen.width() - width) / 2);
959    let y = screen.top().saturating_add((screen.height() - height) / 2);
960    Rect::new(x, y, width, height)
961}
962
963/// Which side of an anchor rect a panel prefers to open on, for [`anchored_rect`].
964#[derive(Clone, Copy, Debug, PartialEq, Eq)]
965pub enum Side {
966    /// Open above the anchor (panel's bottom edge touches the anchor's top edge).
967    Above,
968    /// Open below the anchor (panel's top edge touches the anchor's bottom edge). The usual
969    /// choice for a dropdown under a menu label or a field.
970    Below,
971    /// Open to the left of the anchor (panel's right edge touches the anchor's left edge).
972    Left,
973    /// Open to the right of the anchor (panel's left edge touches the anchor's right edge).
974    Right,
975}
976
977impl Side {
978    /// The side to fall back to when this side doesn't have room, per [`anchored_rect`].
979    const fn opposite(self) -> Self {
980        match self {
981            Self::Above => Self::Below,
982            Self::Below => Self::Above,
983            Self::Left => Self::Right,
984            Self::Right => Self::Left,
985        }
986    }
987}
988
989/// Place a `size` panel adjacent to `anchor`, preferring `side`, flipping to the opposite side
990/// when there isn't room, and clamped to stay within `bounds`.
991///
992/// `side` decides which edge of `anchor` the panel opens from: [`Side::Below`]/[`Side::Above`]
993/// place the panel's left edge at `anchor`'s left edge and stack it vertically off `anchor`'s
994/// bottom/top edge; [`Side::Right`]/[`Side::Left`] place the panel's top edge at `anchor`'s top
995/// edge and lay it out horizontally off `anchor`'s right/left edge. If the preferred side doesn't
996/// have enough room within `bounds` (the panel's far edge would fall outside `bounds` on that
997/// axis) but the opposite side does, the panel opens on the opposite side instead; if neither
998/// side has room, the preferred side is kept and clamped like the fitting case. Once a side is
999/// chosen, the panel is clamped along the perpendicular axis so it never runs past `bounds`'
1000/// edges: this is the three-line clamp a hand-rolled dropdown would otherwise repeat
1001/// (`x.min(bounds.right() - width).max(bounds.left())`), applied to whichever axis `side` didn't
1002/// already pin.
1003///
1004/// `size` is clamped down to `bounds`' own dimensions if larger, so the result is always fully
1005/// within `bounds`, the same guarantee [`centered_rect`] makes for a centered box.
1006///
1007/// Pure layout math: no drawing, no `Terminal`. Callers still own sizing (deciding `size` from
1008/// content, with a floor/ceiling) and overflow (scrolling when content is taller than the
1009/// resulting rect); this only answers where the rect goes.
1010///
1011/// Never panics: every offset is computed with saturating arithmetic, so a degenerate `anchor`,
1012/// `size`, or `bounds` (zero width/height, or `anchor` outside `bounds`) resolves to a clamped,
1013/// zero-size-or-larger rect instead of under/overflowing.
1014///
1015/// # Examples
1016///
1017/// ```
1018/// use retroglyph_core::grid::{Rect, Size};
1019/// use retroglyph_ui::{Side, anchored_rect};
1020///
1021/// let bounds = Rect::new(0, 0, 40, 20);
1022/// let anchor = Rect::new(5, 5, 10, 1); // e.g. a menu label
1023/// let rect = anchored_rect(anchor, Size::new(12, 4), Side::Below, bounds);
1024/// assert_eq!(rect, Rect::new(5, 6, 12, 4));
1025/// ```
1026#[must_use]
1027pub fn anchored_rect(anchor: Rect, size: Size, preferred: Side, bounds: Rect) -> Rect {
1028    let width = size.width().min(bounds.width());
1029    let height = size.height().min(bounds.height());
1030
1031    // `checked_sub` (not `saturating_sub`): a saturated 0 would make an anchor too close to
1032    // `bounds`' start edge for `height`/`width` look like it fits with room to spare.
1033    let fits = |candidate: Side| match candidate {
1034        Side::Above => {
1035            anchor.top() <= bounds.bottom()
1036                && anchor
1037                    .top()
1038                    .checked_sub(height)
1039                    .is_some_and(|t| t >= bounds.top())
1040        }
1041        Side::Below => anchor.bottom().saturating_add(height) <= bounds.bottom(),
1042        Side::Left => {
1043            anchor.left() <= bounds.right()
1044                && anchor
1045                    .left()
1046                    .checked_sub(width)
1047                    .is_some_and(|l| l >= bounds.left())
1048        }
1049        Side::Right => anchor.right().saturating_add(width) <= bounds.right(),
1050    };
1051    let resolved = if fits(preferred) || !fits(preferred.opposite()) {
1052        preferred
1053    } else {
1054        preferred.opposite()
1055    };
1056
1057    let (x, y) = match resolved {
1058        Side::Above => {
1059            let x = anchor
1060                .left()
1061                .min(bounds.right().saturating_sub(width))
1062                .max(bounds.left());
1063            let y = anchor
1064                .top()
1065                .saturating_sub(height)
1066                .min(bounds.bottom().saturating_sub(height))
1067                .max(bounds.top());
1068            (x, y)
1069        }
1070        Side::Below => {
1071            let x = anchor
1072                .left()
1073                .min(bounds.right().saturating_sub(width))
1074                .max(bounds.left());
1075            let y = anchor
1076                .bottom()
1077                .min(bounds.bottom().saturating_sub(height))
1078                .max(bounds.top());
1079            (x, y)
1080        }
1081        Side::Left => {
1082            let x = anchor
1083                .left()
1084                .saturating_sub(width)
1085                .min(bounds.right().saturating_sub(width))
1086                .max(bounds.left());
1087            let y = anchor
1088                .top()
1089                .min(bounds.bottom().saturating_sub(height))
1090                .max(bounds.top());
1091            (x, y)
1092        }
1093        Side::Right => {
1094            let x = anchor
1095                .right()
1096                .min(bounds.right().saturating_sub(width))
1097                .max(bounds.left());
1098            let y = anchor
1099                .top()
1100                .min(bounds.bottom().saturating_sub(height))
1101                .max(bounds.top());
1102            (x, y)
1103        }
1104    };
1105
1106    Rect::new(x, y, width, height)
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111    use alloc::vec;
1112
1113    use super::*;
1114
1115    #[test]
1116    fn vertical_split_sums_and_clamps() {
1117        let area = Rect::new(0, 0, 20, 10);
1118        let panes = split_v(
1119            area,
1120            &[
1121                Constraint::Fixed(1),
1122                Constraint::Fill(1),
1123                Constraint::Fixed(1),
1124            ],
1125        );
1126        assert_eq!(panes.len(), 3);
1127        // Heights: 1 + 8 + 1 = 10, exactly filling the area.
1128        assert_eq!(panes[0].height(), 1);
1129        assert_eq!(panes[1].height(), 8);
1130        assert_eq!(panes[2].height(), 1);
1131        // Panes are contiguous and never exceed the area bottom.
1132        assert_eq!(panes[0].top(), 0);
1133        assert_eq!(panes[1].top(), 1);
1134        assert_eq!(panes[2].top(), 9);
1135        assert_eq!(panes[2].bottom(), area.bottom());
1136        // Width is preserved across all panes.
1137        for p in &panes {
1138            assert_eq!(p.width(), 20);
1139        }
1140    }
1141
1142    #[test]
1143    fn horizontal_percent_and_fill() {
1144        let area = Rect::new(0, 0, 100, 5);
1145        let panes = split_h(area, &[Constraint::Percent(30), Constraint::Fill(1)]);
1146        assert_eq!(panes[0].width(), 30);
1147        assert_eq!(panes[1].width(), 70);
1148        assert_eq!(panes[0].left(), 0);
1149        assert_eq!(panes[1].left(), 30);
1150        assert_eq!(panes[1].right(), area.right());
1151    }
1152
1153    #[test]
1154    fn horizontal_ratio_and_fill() {
1155        let area = Rect::new(0, 0, 100, 5);
1156        let panes = split_h(area, &[Constraint::Ratio(3, 10), Constraint::Fill(1)]);
1157        assert_eq!(panes[0].width(), 30);
1158        assert_eq!(panes[1].width(), 70);
1159    }
1160
1161    #[test]
1162    fn ratio_zero_denominator_resolves_to_zero() {
1163        let area = Rect::new(0, 0, 100, 5);
1164        let panes = split_h(area, &[Constraint::Ratio(1, 0), Constraint::Fill(1)]);
1165        assert_eq!(panes[0].width(), 0);
1166        assert_eq!(panes[1].width(), 100);
1167    }
1168
1169    #[test]
1170    fn ratio_over_one_clamps_to_total() {
1171        let area = Rect::new(0, 0, 100, 5);
1172        let panes = split_h(area, &[Constraint::Ratio(3, 2)]);
1173        assert_eq!(panes[0].width(), 100);
1174    }
1175
1176    #[test]
1177    fn fill_remainder_distributes_evenly() {
1178        let area = Rect::new(0, 0, 10, 1);
1179        // 10 cells across 3 fills: 4, 3, 3 (leftover goes to the front).
1180        let panes = split_h(
1181            area,
1182            &[
1183                Constraint::Fill(1),
1184                Constraint::Fill(1),
1185                Constraint::Fill(1),
1186            ],
1187        );
1188        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
1189        assert_eq!(widths, vec![4, 3, 3]);
1190        assert_eq!(widths.iter().sum::<u16>(), 10);
1191    }
1192
1193    #[test]
1194    fn oversized_fixed_is_clamped() {
1195        let area = Rect::new(0, 0, 5, 3);
1196        // Requested 10 + 10 but only 5 columns exist: first takes all, rest zero.
1197        let panes = split_h(area, &[Constraint::Fixed(10), Constraint::Fixed(10)]);
1198        assert_eq!(panes[0].width(), 5);
1199        assert_eq!(panes[1].width(), 0);
1200        // No pane extends past the area.
1201        for p in &panes {
1202            assert!(p.right() <= area.right());
1203        }
1204    }
1205
1206    #[test]
1207    fn no_fill_leaves_gap() {
1208        let area = Rect::new(0, 0, 10, 4);
1209        let panes = split_v(area, &[Constraint::Fixed(2), Constraint::Fixed(2)]);
1210        // Only 4 of 10 rows consumed; that is fine, panes still fit.
1211        assert_eq!(panes[0].height(), 2);
1212        assert_eq!(panes[1].height(), 2);
1213        assert_eq!(panes[1].bottom(), 4);
1214    }
1215
1216    #[test]
1217    fn min_gets_at_least_its_floor_plus_a_share() {
1218        let area = Rect::new(0, 0, 10, 1);
1219        // Min(3) and Fill both get an equal share (5 each) of the full 10
1220        // cells, since Min's floor is reserved up front and then also
1221        // shares in distributing the remaining 7: Min ends up with
1222        // 3 (floor) + 4 (share, rounded up) = 7, Fill gets the other 3.
1223        let panes = split_h(area, &[Constraint::Min(3), Constraint::Fill(1)]);
1224        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
1225        assert_eq!(widths, vec![7, 3]);
1226        assert_eq!(widths.iter().sum::<u16>(), 10);
1227    }
1228
1229    #[test]
1230    fn min_floor_holds_when_share_would_be_smaller() {
1231        let area = Rect::new(0, 0, 10, 1);
1232        // Three flexible panes would each get ~3, but Min(4) guarantees 4:
1233        // its floor (4) plus an equal share of the remaining 6 across all
1234        // three (2 each) gives Min(4) a total of 6, leaving 2 each for the
1235        // two Fill panes.
1236        let panes = split_h(
1237            area,
1238            &[Constraint::Min(4), Constraint::Fill(1), Constraint::Fill(1)],
1239        );
1240        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
1241        assert_eq!(widths[0], 6);
1242        assert_eq!(widths[1], 2);
1243        assert_eq!(widths[2], 2);
1244        assert_eq!(widths.iter().sum::<u16>(), 10);
1245    }
1246
1247    #[test]
1248    fn max_caps_its_share_and_leaves_the_rest_unclaimed() {
1249        let area = Rect::new(0, 0, 10, 1);
1250        // Fill and Max(2) would each get 5; Max(2) is capped, and its extra
1251        // 3 cells are left unclaimed (no redistribution), not given to Fill.
1252        let panes = split_h(area, &[Constraint::Fill(1), Constraint::Max(2)]);
1253        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
1254        assert_eq!(widths, vec![5, 2]);
1255        assert_eq!(widths.iter().sum::<u16>(), 7);
1256    }
1257
1258    #[test]
1259    fn weighted_fill_splits_proportionally() {
1260        let area = Rect::new(0, 0, 12, 1);
1261        // Fill(2) claims twice the share of Fill(1): 4 and 8 of 12.
1262        let panes = split_h(area, &[Constraint::Fill(1), Constraint::Fill(2)]);
1263        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
1264        assert_eq!(widths, vec![4, 8]);
1265        assert_eq!(widths.iter().sum::<u16>(), 12);
1266    }
1267
1268    #[test]
1269    fn weighted_fill_at_weight_one_matches_equal_distribution() {
1270        let area = Rect::new(0, 0, 10, 1);
1271        // Every pane weighing the same value (not just 1) still divides
1272        // evenly, since distribution is by weight *ratio*, not magnitude.
1273        let panes = split_h(
1274            area,
1275            &[
1276                Constraint::Fill(5),
1277                Constraint::Fill(5),
1278                Constraint::Fill(5),
1279            ],
1280        );
1281        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
1282        assert_eq!(widths, vec![4, 3, 3]);
1283        assert_eq!(widths.iter().sum::<u16>(), 10);
1284    }
1285
1286    #[test]
1287    fn weighted_fill_leftover_goes_to_the_largest_fractional_share() {
1288        let area = Rect::new(0, 0, 10, 1);
1289        // Ideal shares are 30/7 ~= 4.29, 20/7 ~= 2.86, 20/7 ~= 2.86. Floors are
1290        // 4, 2, 2 (sum 8); the 2 leftover cells go to the panes with the
1291        // largest fractional remainder, in this case the two Fill(2)s tied
1292        // ahead of Fill(3), not to the first pane in the slice.
1293        let panes = split_h(
1294            area,
1295            &[
1296                Constraint::Fill(3),
1297                Constraint::Fill(2),
1298                Constraint::Fill(2),
1299            ],
1300        );
1301        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
1302        assert_eq!(widths, vec![4, 3, 3]);
1303        assert_eq!(widths.iter().sum::<u16>(), 10);
1304    }
1305
1306    #[test]
1307    fn fill_weight_zero_claims_no_share_of_the_remainder() {
1308        let area = Rect::new(0, 0, 10, 1);
1309        let panes = split_h(area, &[Constraint::Fill(0), Constraint::Fill(1)]);
1310        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
1311        assert_eq!(widths, vec![0, 10]);
1312    }
1313
1314    #[test]
1315    fn all_fill_weights_zero_leaves_the_remainder_unclaimed() {
1316        let area = Rect::new(0, 0, 10, 1);
1317        let panes = split_h(area, &[Constraint::Fill(0), Constraint::Fill(0)]);
1318        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
1319        assert_eq!(widths, vec![0, 0]);
1320    }
1321
1322    #[test]
1323    fn weighted_fill_mixes_with_min_and_max_at_weight_one() {
1324        let area = Rect::new(0, 0, 20, 1);
1325        // Fill(3) claims 3 parts of the 6-way weight pool (3 + 1 + 1 + 1 = 6);
1326        // Min(2) and Max(10) each claim 1 part like before. Remainder after
1327        // Min's floor: 20 - 2 = 18, split 3:1:1:1 -> 9, 3, 3, 3; Min ends at
1328        // 2 + 3 = 5.
1329        let panes = split_h(
1330            area,
1331            &[
1332                Constraint::Fill(3),
1333                Constraint::Min(2),
1334                Constraint::Fill(1),
1335                Constraint::Max(10),
1336            ],
1337        );
1338        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
1339        assert_eq!(widths, vec![9, 5, 3, 3]);
1340        assert_eq!(widths.iter().sum::<u16>(), 20);
1341    }
1342
1343    #[test]
1344    fn flex_start_matches_split_v() {
1345        let area = Rect::new(0, 0, 10, 4);
1346        let constraints = [Constraint::Fixed(2), Constraint::Fixed(2)];
1347        let legacy = split_v(area, &constraints);
1348        let flexed = split_v_flex(area, &constraints, Flex::Start);
1349        assert_eq!(legacy, flexed);
1350    }
1351
1352    #[test]
1353    fn flex_end_pushes_leftover_before_the_panes() {
1354        let area = Rect::new(0, 0, 10, 10);
1355        let panes = split_v_flex(
1356            area,
1357            &[Constraint::Fixed(2), Constraint::Fixed(2)],
1358            Flex::End,
1359        );
1360        // 6 rows of slack lead before the first pane.
1361        assert_eq!(panes[0].top(), 6);
1362        assert_eq!(panes[1].top(), 8);
1363        assert_eq!(panes[1].bottom(), 10);
1364    }
1365
1366    #[test]
1367    fn flex_center_splits_leftover_around_the_panes() {
1368        let area = Rect::new(0, 0, 10, 10);
1369        let panes = split_v_flex(area, &[Constraint::Fixed(4)], Flex::Center);
1370        // 6 rows of slack, 3 leading before the single pane.
1371        assert_eq!(panes[0].top(), 3);
1372        assert_eq!(panes[0].bottom(), 7);
1373    }
1374
1375    #[test]
1376    fn flex_space_between_puts_leftover_between_panes_only() {
1377        let area = Rect::new(0, 0, 10, 1);
1378        let panes = split_h_flex(
1379            area,
1380            &[Constraint::Fixed(2), Constraint::Fixed(2)],
1381            Flex::SpaceBetween,
1382        );
1383        // 6 cells of slack become a single gap between the two panes.
1384        assert_eq!(panes[0].left(), 0);
1385        assert_eq!(panes[0].right(), 2);
1386        assert_eq!(panes[1].left(), 8);
1387        assert_eq!(panes[1].right(), 10);
1388    }
1389
1390    #[test]
1391    fn flex_space_around_puts_equal_gaps_at_both_edges() {
1392        let area = Rect::new(0, 0, 9, 1);
1393        let panes = split_h_flex(area, &[Constraint::Fixed(3)], Flex::SpaceAround);
1394        // 6 cells of slack split into 2 gaps (before and after) of 3 each.
1395        assert_eq!(panes[0].left(), 3);
1396        assert_eq!(panes[0].right(), 6);
1397    }
1398
1399    #[test]
1400    fn spaced_split_carves_out_gaps_between_panes() {
1401        let area = Rect::new(0, 0, 59, 6);
1402        let (panes, gaps) = split_h_spaced(
1403            area,
1404            &[
1405                Constraint::Fill(1),
1406                Constraint::Fill(1),
1407                Constraint::Fill(1),
1408            ],
1409            1,
1410        );
1411        assert_eq!(panes.len(), 3);
1412        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
1413        assert_eq!(widths, vec![19, 19, 19]);
1414        // Adjacent panes are separated by exactly one gap cell, not touching.
1415        assert_eq!(panes[1].left(), panes[0].right() + 1);
1416        assert_eq!(panes[2].left(), panes[1].right() + 1);
1417        // One gap rect between each adjacent pair of panes, filling exactly the empty cell.
1418        assert_eq!(gaps.len(), 2);
1419        assert_eq!(
1420            gaps[0],
1421            Rect::new(panes[0].right(), area.top(), 1, area.height())
1422        );
1423        assert_eq!(
1424            gaps[1],
1425            Rect::new(panes[1].right(), area.top(), 1, area.height())
1426        );
1427    }
1428
1429    #[test]
1430    fn spaced_split_resolves_percent_against_the_post_gap_axis() {
1431        let area = Rect::new(0, 0, 100, 1);
1432        let (panes, _) = split_h_spaced(
1433            area,
1434            &[Constraint::Percent(50), Constraint::Percent(50)],
1435            10,
1436        );
1437        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
1438        assert_eq!(widths, vec![45, 45]);
1439
1440        let (panes, _) = split_h_spaced(
1441            area,
1442            &[Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)],
1443            10,
1444        );
1445        let widths: Vec<u16> = panes.iter().map(Rect::width).collect();
1446        assert_eq!(widths, vec![45, 45]);
1447    }
1448
1449    #[test]
1450    fn spaced_split_falls_back_with_one_pane_or_no_spacing() {
1451        let area = Rect::new(0, 0, 10, 1);
1452        assert_eq!(
1453            split_h_spaced(area, &[Constraint::Fill(1)], 1),
1454            (split_h(area, &[Constraint::Fill(1)]), Vec::new())
1455        );
1456        assert_eq!(
1457            split_h_spaced(area, &[Constraint::Fill(1), Constraint::Fill(1)], 0),
1458            (
1459                split_h(area, &[Constraint::Fill(1), Constraint::Fill(1)]),
1460                Vec::new()
1461            )
1462        );
1463    }
1464
1465    #[test]
1466    fn vertical_spaced_split_matches_horizontal_shape() {
1467        let area = Rect::new(0, 0, 6, 59);
1468        let (panes, gaps) = split_v_spaced(
1469            area,
1470            &[
1471                Constraint::Fill(1),
1472                Constraint::Fill(1),
1473                Constraint::Fill(1),
1474            ],
1475            1,
1476        );
1477        let heights: Vec<u16> = panes.iter().map(Rect::height).collect();
1478        assert_eq!(heights, vec![19, 19, 19]);
1479        assert_eq!(panes[1].top(), panes[0].bottom() + 1);
1480        assert_eq!(gaps.len(), 2);
1481        assert_eq!(
1482            gaps[0],
1483            Rect::new(area.left(), panes[0].bottom(), area.width(), 1)
1484        );
1485    }
1486
1487    #[test]
1488    fn overlap_spacing_shares_one_edge_cell_between_panes() {
1489        let area = Rect::new(0, 0, 60, 6);
1490        let (panes, gaps) = split_h_spaced(
1491            area,
1492            &[Constraint::Fill(1), Constraint::Fill(1)],
1493            Spacing::Overlap(1),
1494        );
1495        assert_eq!(panes.len(), 2);
1496        // The panes claim the full area between them and share exactly one column.
1497        assert_eq!(panes[0].left(), area.left());
1498        assert_eq!(panes[1].right(), area.right());
1499        assert_eq!(panes[1].left(), panes[0].right() - 1);
1500        // The gap rect is the shared column both panes draw over.
1501        assert_eq!(gaps.len(), 1);
1502        assert_eq!(
1503            gaps[0],
1504            Rect::new(panes[0].right() - 1, area.top(), 1, area.height())
1505        );
1506    }
1507
1508    #[test]
1509    fn overlap_spacing_works_vertically_and_with_more_than_two_panes() {
1510        let area = Rect::new(0, 0, 6, 61);
1511        let (panes, gaps) = split_v_spaced(area, &[Constraint::Fill(1); 3], Spacing::Overlap(1));
1512        assert_eq!(panes.len(), 3);
1513        assert_eq!(panes[0].top(), area.top());
1514        assert_eq!(panes[2].bottom(), area.bottom());
1515        assert_eq!(panes[1].top(), panes[0].bottom() - 1);
1516        assert_eq!(panes[2].top(), panes[1].bottom() - 1);
1517        assert_eq!(gaps.len(), 2);
1518    }
1519
1520    #[test]
1521    fn overlap_spacing_falls_back_with_one_pane_or_zero_overlap() {
1522        let area = Rect::new(0, 0, 10, 1);
1523        assert_eq!(
1524            split_h_spaced(area, &[Constraint::Fill(1)], Spacing::Overlap(1)),
1525            (split_h(area, &[Constraint::Fill(1)]), Vec::new())
1526        );
1527        assert_eq!(
1528            split_h_spaced(
1529                area,
1530                &[Constraint::Fill(1), Constraint::Fill(1)],
1531                Spacing::Overlap(0)
1532            ),
1533            (
1534                split_h(area, &[Constraint::Fill(1), Constraint::Fill(1)]),
1535                Vec::new()
1536            )
1537        );
1538    }
1539
1540    #[test]
1541    fn split_h_n_spaced_matches_split_h_spaced_with_overlap() {
1542        let area = Rect::new(0, 0, 60, 6);
1543        let constraints = [Constraint::Fill(1); 2];
1544        let (vec_panes, vec_gaps) = split_h_spaced(area, &constraints, Spacing::Overlap(1));
1545        let ([a, b], gaps) = split_h_n_spaced(area, constraints, Spacing::Overlap(1));
1546        assert_eq!(vec_panes, vec![a, b]);
1547        assert_eq!(vec_gaps, gaps);
1548    }
1549
1550    #[test]
1551    fn centered_rect_centers_within_the_screen() {
1552        let screen = Rect::new(0, 0, 20, 10);
1553        let r = centered_rect(screen, 10, 4);
1554        assert_eq!(r, Rect::new(5, 3, 10, 4));
1555    }
1556
1557    #[test]
1558    fn centered_rect_clamps_to_the_screen_size_when_larger() {
1559        let screen = Rect::new(0, 0, 20, 10);
1560        let r = centered_rect(screen, 100, 100);
1561        assert_eq!(r, Rect::new(0, 0, 20, 10));
1562    }
1563
1564    #[test]
1565    fn centered_rect_respects_a_non_origin_screen() {
1566        let screen = Rect::new(5, 5, 20, 10);
1567        let r = centered_rect(screen, 10, 4);
1568        assert_eq!(r, Rect::new(10, 8, 10, 4));
1569    }
1570
1571    #[test]
1572    fn centered_rect_does_not_overflow_on_a_far_off_screen() {
1573        // retroglyph#729: `screen.left() + (screen.width() - width) / 2` used to overflow `u16`
1574        // once `screen.left()` was large enough, even though `width`/`height` themselves fit.
1575        let screen = Rect::new(50_000, 0, 40_000, 10);
1576        let r = centered_rect(screen, 10, 4);
1577        assert_eq!(r, Rect::new(u16::MAX, 3, 10, 4));
1578    }
1579
1580    #[test]
1581    fn anchored_rect_opens_below_when_preferred() {
1582        let bounds = Rect::new(0, 0, 40, 20);
1583        let anchor = Rect::new(5, 5, 10, 1);
1584        let r = anchored_rect(anchor, Size::new(12, 4), Side::Below, bounds);
1585        assert_eq!(r, Rect::new(5, 6, 12, 4));
1586    }
1587
1588    #[test]
1589    fn anchored_rect_opens_above_when_preferred() {
1590        let bounds = Rect::new(0, 0, 40, 20);
1591        let anchor = Rect::new(5, 10, 10, 1);
1592        let r = anchored_rect(anchor, Size::new(12, 4), Side::Above, bounds);
1593        assert_eq!(r, Rect::new(5, 6, 12, 4));
1594    }
1595
1596    #[test]
1597    fn anchored_rect_flips_below_to_above_when_there_is_no_room_below() {
1598        let bounds = Rect::new(0, 0, 40, 20);
1599        // Anchor near the bottom: no room for a 4-tall panel below, but there is above.
1600        let anchor = Rect::new(5, 18, 10, 1);
1601        let r = anchored_rect(anchor, Size::new(12, 4), Side::Below, bounds);
1602        assert_eq!(r, Rect::new(5, 14, 12, 4));
1603    }
1604
1605    #[test]
1606    fn anchored_rect_flips_above_to_below_when_there_is_no_room_above() {
1607        let bounds = Rect::new(0, 0, 40, 20);
1608        // Anchor near the top: no room for a 4-tall panel above, but there is below.
1609        let anchor = Rect::new(5, 1, 10, 1);
1610        let r = anchored_rect(anchor, Size::new(12, 4), Side::Above, bounds);
1611        assert_eq!(r, Rect::new(5, 2, 12, 4));
1612    }
1613
1614    #[test]
1615    fn anchored_rect_keeps_preferred_side_when_neither_side_has_room() {
1616        let bounds = Rect::new(0, 0, 40, 3);
1617        let anchor = Rect::new(5, 1, 10, 1);
1618        // Neither above nor below fits the panel (bounds is only 3 rows tall); the panel's
1619        // height is itself first clamped down to bounds.height() (3), stays on the preferred
1620        // Below side, and its clamped-height rect is then pulled up to fit inside bounds.
1621        let r = anchored_rect(anchor, Size::new(12, 4), Side::Below, bounds);
1622        assert_eq!(r, Rect::new(5, 0, 12, 3));
1623    }
1624
1625    #[test]
1626    fn anchored_rect_clamps_to_the_right_bounds_edge() {
1627        let bounds = Rect::new(0, 0, 20, 20);
1628        // Anchor near the right edge: a 12-wide panel starting at anchor.left() (15) would run
1629        // past bounds.right() (20), so it's pulled left to stay inside.
1630        let anchor = Rect::new(15, 5, 4, 1);
1631        let r = anchored_rect(anchor, Size::new(12, 4), Side::Below, bounds);
1632        assert_eq!(r, Rect::new(8, 6, 12, 4));
1633    }
1634
1635    #[test]
1636    fn anchored_rect_clamps_to_the_left_bounds_edge() {
1637        // A non-origin bounds so an anchor can sit to the left of bounds.left() itself.
1638        let bounds = Rect::new(5, 0, 20, 20);
1639        let anchor = Rect::new(2, 5, 2, 1);
1640        let r = anchored_rect(anchor, Size::new(12, 4), Side::Below, bounds);
1641        assert_eq!(r, Rect::new(5, 6, 12, 4));
1642    }
1643
1644    #[test]
1645    fn anchored_rect_opens_to_the_right_and_clamps_vertically() {
1646        let bounds = Rect::new(0, 0, 40, 10);
1647        // Anchor near the bottom: a 6-tall panel starting at anchor.top() would run past
1648        // bounds.bottom(), so it's pulled up to stay inside.
1649        let anchor = Rect::new(5, 8, 6, 1);
1650        let r = anchored_rect(anchor, Size::new(8, 6), Side::Right, bounds);
1651        assert_eq!(r, Rect::new(11, 4, 8, 6));
1652    }
1653
1654    #[test]
1655    fn anchored_rect_opens_to_the_left() {
1656        let bounds = Rect::new(0, 0, 40, 10);
1657        let anchor = Rect::new(20, 2, 6, 1);
1658        let r = anchored_rect(anchor, Size::new(8, 4), Side::Left, bounds);
1659        assert_eq!(r, Rect::new(12, 2, 8, 4));
1660    }
1661
1662    #[test]
1663    fn anchored_rect_above_stays_within_bounds_for_an_anchor_below_them() {
1664        let bounds = Rect::new(0, 0, 40, 10);
1665        // Anchor scrolled far below bounds (e.g. a list row scrolled past the viewport).
1666        let anchor = Rect::new(5, 50, 10, 1);
1667        let r = anchored_rect(anchor, Size::new(12, 4), Side::Above, bounds);
1668        assert!(r.top() >= bounds.top() && r.bottom() <= bounds.bottom());
1669    }
1670
1671    #[test]
1672    fn anchored_rect_left_stays_within_bounds_for_an_anchor_right_of_them() {
1673        let bounds = Rect::new(0, 0, 10, 10);
1674        // Anchor scrolled far right of bounds.
1675        let anchor = Rect::new(50, 2, 2, 1);
1676        let r = anchored_rect(anchor, Size::new(4, 2), Side::Left, bounds);
1677        assert!(r.left() >= bounds.left() && r.right() <= bounds.right());
1678    }
1679
1680    #[test]
1681    fn anchored_rect_clamps_size_down_to_bounds() {
1682        let bounds = Rect::new(0, 0, 10, 10);
1683        let anchor = Rect::new(2, 2, 2, 1);
1684        let r = anchored_rect(anchor, Size::new(100, 100), Side::Below, bounds);
1685        assert_eq!(r.width(), 10);
1686        assert_eq!(r.height(), 10);
1687        assert!(r.left() >= bounds.left() && r.right() <= bounds.right());
1688        assert!(r.top() >= bounds.top() && r.bottom() <= bounds.bottom());
1689    }
1690
1691    #[test]
1692    fn anchored_rect_handles_a_zero_size_bounds() {
1693        let bounds = Rect::new(3, 3, 0, 0);
1694        let anchor = Rect::new(3, 3, 0, 0);
1695        let r = anchored_rect(anchor, Size::new(5, 5), Side::Below, bounds);
1696        assert_eq!(r, Rect::new(3, 3, 0, 0));
1697    }
1698
1699    /// `solve`'s internal `SmallBuf` scratch buffers stay on the stack for up to `STACK_CAP`
1700    /// (8) items and fall back to the heap past that; this covers a constraint count past the
1701    /// cap (all-`Fixed`, so `sizes` alone crosses into the heap path) and asserts the result is
1702    /// identical in shape to what an all-`Vec` implementation would produce: every pane keeps its
1703    /// requested size and the total exactly fills the area.
1704    #[test]
1705    fn split_beyond_stack_cap_matches_small_case_behavior() {
1706        let panes = 20; // > STACK_CAP, and far below u16::MAX
1707        #[allow(clippy::cast_possible_truncation)]
1708        let panes_u16 = panes as u16;
1709        let area = Rect::new(0, 0, panes_u16, 1);
1710        let constraints = vec![Constraint::Fixed(1); panes];
1711        let widths: Vec<u16> = split_h(area, &constraints)
1712            .iter()
1713            .map(Rect::width)
1714            .collect();
1715        assert_eq!(widths, vec![1u16; panes]);
1716        assert_eq!(widths.iter().sum::<u16>(), panes_u16);
1717    }
1718
1719    /// Same as above, but exercises the flexible-pane path (`flexible`/`shares`/`fracs`/`order`
1720    /// scratch buffers) past `STACK_CAP` by mixing every `Constraint` kind across enough panes
1721    /// that the flexible subset alone also crosses the stack cap.
1722    #[test]
1723    fn weighted_fill_beyond_stack_cap_matches_small_case_proportions() {
1724        let area = Rect::new(0, 0, 100, 1);
1725        // 20 Fill(1) panes: same proportional-split logic as the 2/3-pane cases above, just at
1726        // a pane count that forces every scratch buffer in `solve` onto the heap.
1727        let constraints = vec![Constraint::Fill(1); 20];
1728        let widths: Vec<u16> = split_h(area, &constraints)
1729            .iter()
1730            .map(Rect::width)
1731            .collect();
1732        assert_eq!(widths.len(), 20);
1733        assert_eq!(widths.iter().sum::<u16>(), 100);
1734        // Equal weights distribute as evenly as integer division allows: every width is 5.
1735        assert!(widths.iter().all(|&w| w == 5));
1736    }
1737
1738    #[test]
1739    fn split_v_n_matches_split_v() {
1740        let area = Rect::new(0, 0, 20, 10);
1741        let constraints = [
1742            Constraint::Fixed(1),
1743            Constraint::Fill(1),
1744            Constraint::Fixed(1),
1745        ];
1746        let vec_panes = split_v(area, &constraints);
1747        let [a, b, c] = split_v_n(area, constraints);
1748        assert_eq!(vec_panes, vec![a, b, c]);
1749    }
1750
1751    #[test]
1752    fn split_h_n_matches_split_h() {
1753        let area = Rect::new(0, 0, 100, 5);
1754        let constraints = [Constraint::Percent(30), Constraint::Fill(1)];
1755        let vec_panes = split_h(area, &constraints);
1756        let [left, right] = split_h_n(area, constraints);
1757        assert_eq!(vec_panes, vec![left, right]);
1758    }
1759
1760    #[test]
1761    fn split_v_n_destructures_by_compile_time_count() {
1762        let area = Rect::new(0, 0, 12, 12);
1763        let [header, body, footer] = split_v_n(
1764            area,
1765            [
1766                Constraint::Fixed(2),
1767                Constraint::Fill(1),
1768                Constraint::Fixed(2),
1769            ],
1770        );
1771        assert_eq!(header.height(), 2);
1772        assert_eq!(body.height(), 8);
1773        assert_eq!(footer.height(), 2);
1774        assert_eq!(header.top(), 0);
1775        assert_eq!(body.top(), 2);
1776        assert_eq!(footer.top(), 10);
1777    }
1778
1779    #[test]
1780    fn split_h_n_flex_matches_split_h_flex() {
1781        let area = Rect::new(0, 0, 10, 1);
1782        let constraints = [Constraint::Fixed(2), Constraint::Fixed(2)];
1783        let vec_panes = split_h_flex(area, &constraints, Flex::SpaceBetween);
1784        let [a, b] = split_h_n_flex(area, constraints, Flex::SpaceBetween);
1785        assert_eq!(vec_panes, vec![a, b]);
1786    }
1787
1788    #[test]
1789    fn split_v_n_flex_matches_split_v_flex() {
1790        let area = Rect::new(0, 0, 10, 10);
1791        let constraints = [Constraint::Fixed(2), Constraint::Fixed(2)];
1792        let vec_panes = split_v_flex(area, &constraints, Flex::End);
1793        let [a, b] = split_v_n_flex(area, constraints, Flex::End);
1794        assert_eq!(vec_panes, vec![a, b]);
1795    }
1796
1797    #[test]
1798    fn split_h_n_spaced_matches_split_h_spaced() {
1799        let area = Rect::new(0, 0, 59, 6);
1800        let constraints = [Constraint::Fill(1); 3];
1801        let (vec_panes, vec_gaps) = split_h_spaced(area, &constraints, 1);
1802        let ([a, b, c], gaps) = split_h_n_spaced(area, constraints, 1);
1803        assert_eq!(vec_panes, vec![a, b, c]);
1804        assert_eq!(vec_gaps, gaps);
1805    }
1806
1807    #[test]
1808    fn split_v_n_spaced_matches_split_v_spaced() {
1809        let area = Rect::new(0, 0, 6, 59);
1810        let constraints = [Constraint::Fill(1); 3];
1811        let (vec_panes, vec_gaps) = split_v_spaced(area, &constraints, 1);
1812        let ([a, b, c], gaps) = split_v_n_spaced(area, constraints, 1);
1813        assert_eq!(vec_panes, vec![a, b, c]);
1814        assert_eq!(vec_gaps, gaps);
1815    }
1816
1817    #[test]
1818    fn split_h_n_spaced_falls_back_with_one_pane_or_no_spacing() {
1819        let area = Rect::new(0, 0, 10, 1);
1820        assert_eq!(
1821            split_h_n_spaced(area, [Constraint::Fill(1)], 1),
1822            ([split_h_n(area, [Constraint::Fill(1)])[0]], Vec::new())
1823        );
1824        let constraints = [Constraint::Fill(1), Constraint::Fill(1)];
1825        assert_eq!(
1826            split_h_n_spaced(area, constraints, 0),
1827            (split_h_n(area, constraints), Vec::new())
1828        );
1829    }
1830
1831    /// `solve_n` never falls back to the heap, unlike `solve`'s `SmallBuf`; this covers a pane
1832    /// count past `STACK_CAP` to confirm that holds true and produces the same result `solve`
1833    /// would.
1834    #[test]
1835    fn split_h_n_beyond_stack_cap_matches_split_h() {
1836        let area = Rect::new(0, 0, 20, 1);
1837        let constraints = [Constraint::Fixed(1); 20];
1838        let vec_panes = split_h(area, &constraints);
1839        let arr_panes = split_h_n(area, constraints);
1840        assert_eq!(vec_panes, arr_panes.to_vec());
1841    }
1842}