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