Skip to main content

retroglyph_widgets/
layout.rs

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