Skip to main content

retroglyph_ui/
layout.rs

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