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}