Skip to main content

smelt_term/
layout.rs

1pub use super::geometry::Rect;
2use std::collections::HashMap;
3
4/// Opaque leaf identifier. Hosts mint and dispatch on these; the renderer
5/// treats them as opaque. Wide enough for common host-side id types.
6#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
7pub struct PaintId(pub u64);
8
9impl PaintId {
10    pub fn raw(self) -> u64 {
11        self.0
12    }
13}
14
15/// Sizing constraint for a layout child along the parent's primary
16/// axis. Resolved by `resolve_constraints` against the parent's total
17/// size, in declaration order:
18///
19/// 1. Hard sizes first (`Length`, `Percentage`, `Ratio`, `Max`) and
20///    `Fit` (resolved against the leaf's natural size via the active
21///    `LeafSizer`) consume their exact share of the available space.
22/// 2. `Min(n)` reserves at least `n` cells, then competes with
23///    `Fill` for the remainder.
24/// 3. `Fill` (and any unsatisfied `Min`) splits whatever remains
25///    evenly.
26///
27/// `Fit` reports the leaf's natural size from `LeafSizer`; with the
28/// default `NoopSizer` it contributes 0 (i.e. behaves like `Fill`).
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum Constraint {
31    /// Exactly `n` cells along the axis.
32    Length(u16),
33    /// `p` percent of the parent's total size, clamped to remaining.
34    Percentage(u16),
35    /// Proportional share `num / denom` of the parent. Multiple
36    /// `Ratio` siblings split proportionally to one another.
37    Ratio(u16, u16),
38    /// At least `n` cells; competes with `Fill` for the remainder
39    /// once the minimum is satisfied.
40    Min(u16),
41    /// At most `n` cells. Acts like `Length(n)` when the parent has
42    /// at least `n` available; smaller parents shrink it.
43    Max(u16),
44    /// Fill the remaining space; siblings split evenly.
45    Fill,
46    /// Size to the leaf's natural content. Falls back to `Fill`
47    /// until leaves expose a natural-size hook.
48    Fit,
49}
50
51/// A sizing `Constraint` paired with its subtree; used as `Vbox`/`Hbox` items.
52pub type Item = (Constraint, LayoutTree);
53
54/// How a container places children when its assigned rect is larger than the
55/// children's resolved sizes plus the minimum gap.
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum Justify {
58    /// Pack children at the start of the primary axis.
59    #[default]
60    Start,
61    /// Add all surplus cells to the gaps between children, keeping the first
62    /// child at the start and the last child at the end.
63    SpaceBetween,
64}
65
66/// Container chrome (gap, border, title, padding) shared by `Vbox`, `Hbox`,
67/// and `Leaf`.
68#[derive(Clone, Debug, Default)]
69pub struct Chrome {
70    /// Cells between adjacent children; `0` packs flush.
71    pub gap: u16,
72    /// Surplus-space placement along the primary axis.
73    pub justify: Justify,
74    /// Frame around the container; each enabled side reserves one row/col. `None` = no inset.
75    pub border: Option<Border>,
76    /// Title in the top border row. Requires `border = Some(_)`; renders as a styled [`Line`].
77    pub title: Option<crate::line::Line<'static>>,
78    /// Uniform inner padding (cells) on all four sides, *inside* any
79    /// border. Increases the container's natural size by `2 * padding` on
80    /// each axis; children are laid out in the padded-inset rect.
81    pub padding: u16,
82}
83
84/// Per-leaf natural-size hook. Plugins attach one to a leaf to drive
85/// content-aware sizing without going through `LeafSizer`. When present,
86/// it takes precedence over the sizer's reported size for `Fit`
87/// resolution. Implementations are typically a static `(w, h)` pair or a
88/// shared mutable cell that the plugin updates on user actions (variant
89/// cycling, content change, etc.).
90pub trait Natural: Send + Sync {
91    /// Natural `(width, height)` for the leaf at the given available cap.
92    /// `cap` is the inner-cap (border/gap already subtracted); impls may
93    /// ignore it.
94    fn size(&self, cap: (u16, u16)) -> (u16, u16);
95}
96
97/// Shared reference to a `Natural` hook; cheap to clone.
98pub type NaturalRef = std::sync::Arc<dyn Natural>;
99
100/// Fixed natural size. Use for leaves whose extent is known at layout
101/// construction time and doesn't change between frames.
102#[derive(Clone, Copy, Debug)]
103pub struct StaticNatural(pub u16, pub u16);
104
105impl Natural for StaticNatural {
106    fn size(&self, _cap: (u16, u16)) -> (u16, u16) {
107        (self.0, self.1)
108    }
109}
110
111#[derive(Clone)]
112pub enum LayoutTree {
113    /// Terminal node; the host matches on `PaintId` in its paint dispatcher.
114    /// Carries its own `Chrome` so leaves can have a border/title without a
115    /// synthetic wrapper container. `natural`, when set, overrides the
116    /// active `LeafSizer` for this leaf's natural-size reporting.
117    Leaf {
118        id: PaintId,
119        chrome: Chrome,
120        natural: Option<NaturalRef>,
121    },
122    /// Vertical container; children stack top-to-bottom.
123    Vbox { items: Vec<Item>, chrome: Chrome },
124    /// Horizontal container; children pack left-to-right.
125    Hbox { items: Vec<Item>, chrome: Chrome },
126}
127
128impl std::fmt::Debug for LayoutTree {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        match self {
131            LayoutTree::Leaf {
132                id,
133                chrome,
134                natural,
135            } => f
136                .debug_struct("Leaf")
137                .field("id", id)
138                .field("chrome", chrome)
139                .field("natural", &natural.as_ref().map(|_| "<NaturalRef>"))
140                .finish(),
141            LayoutTree::Vbox { items, chrome } => f
142                .debug_struct("Vbox")
143                .field("items", items)
144                .field("chrome", chrome)
145                .finish(),
146            LayoutTree::Hbox { items, chrome } => f
147                .debug_struct("Hbox")
148                .field("items", items)
149                .field("chrome", chrome)
150                .finish(),
151        }
152    }
153}
154
155impl LayoutTree {
156    /// Vertical container. Use `.with_gap` / `.with_border` / `.with_title` to add chrome.
157    pub fn vbox(items: Vec<Item>) -> Self {
158        Self::Vbox {
159            items,
160            chrome: Chrome::default(),
161        }
162    }
163
164    /// Horizontal container. Use `.with_gap` / `.with_border` / `.with_title` to add chrome.
165    pub fn hbox(items: Vec<Item>) -> Self {
166        Self::Hbox {
167            items,
168            chrome: Chrome::default(),
169        }
170    }
171
172    /// Terminal leaf. Accepts anything `Into<PaintId>`.
173    pub fn leaf(id: impl Into<PaintId>) -> Self {
174        Self::Leaf {
175            id: id.into(),
176            chrome: Chrome::default(),
177            natural: None,
178        }
179    }
180
181    /// Attach a `Natural` hook to a leaf node so it can drive its own
182    /// `Fit` size. No-op for containers.
183    pub fn with_natural(mut self, n: NaturalRef) -> Self {
184        if let Self::Leaf { natural, .. } = &mut self {
185            *natural = Some(n);
186        }
187        self
188    }
189
190    pub fn chrome_mut(&mut self) -> &mut Chrome {
191        match self {
192            Self::Leaf { chrome, .. } | Self::Vbox { chrome, .. } | Self::Hbox { chrome, .. } => {
193                chrome
194            }
195        }
196    }
197
198    pub fn chrome(&self) -> &Chrome {
199        match self {
200            Self::Leaf { chrome, .. } | Self::Vbox { chrome, .. } | Self::Hbox { chrome, .. } => {
201                chrome
202            }
203        }
204    }
205
206    pub fn with_gap(mut self, g: u16) -> Self {
207        self.chrome_mut().gap = g;
208        self
209    }
210
211    pub fn with_justify(mut self, justify: Justify) -> Self {
212        self.chrome_mut().justify = justify;
213        self
214    }
215
216    pub fn with_padding(mut self, p: u16) -> Self {
217        self.chrome_mut().padding = p;
218        self
219    }
220
221    pub fn with_border(mut self, b: Border) -> Self {
222        self.chrome_mut().border = Some(b);
223        self
224    }
225
226    pub fn with_title(mut self, t: impl Into<crate::line::Line<'static>>) -> Self {
227        self.chrome_mut().title = Some(t.into());
228        self
229    }
230
231    /// Replace the root chrome's title in place.
232    pub fn set_title(&mut self, t: Option<crate::line::Line<'static>>) {
233        self.chrome_mut().title = t;
234    }
235
236    /// Replace the root chrome's border.
237    pub fn set_border(&mut self, b: Option<Border>) {
238        self.chrome_mut().border = b;
239    }
240
241    /// Whether `id` appears as a leaf in this tree (depth-first structural check).
242    pub fn contains_leaf(&self, id: impl Into<PaintId>) -> bool {
243        let id = id.into();
244        self.contains_leaf_id(id)
245    }
246
247    fn contains_leaf_id(&self, id: PaintId) -> bool {
248        match self {
249            LayoutTree::Leaf { id: p, .. } => *p == id,
250            LayoutTree::Vbox { items, .. } | LayoutTree::Hbox { items, .. } => {
251                items.iter().any(|(_, child)| child.contains_leaf_id(id))
252            }
253        }
254    }
255
256    /// All leaf `PaintId`s in depth-first declaration order.
257    pub fn leaves_in_order(&self) -> Vec<PaintId> {
258        let mut out = Vec::new();
259        self.collect_leaves(&mut out);
260        out
261    }
262
263    fn collect_leaves(&self, out: &mut Vec<PaintId>) {
264        match self {
265            LayoutTree::Leaf { id, .. } => out.push(*id),
266            LayoutTree::Vbox { items, .. } | LayoutTree::Hbox { items, .. } => {
267                for (_, child) in items {
268                    child.collect_leaves(out);
269                }
270            }
271        }
272    }
273
274    /// Natural `(width, height)` bounded by `cap`. `Fill` contributes `0`;
275    /// `Fit` contributes the leaf's natural size from the default `NoopSizer`
276    /// (also `0`); chrome (border, gap) is added on top. Result is always
277    /// `<= cap`.
278    pub fn natural_size(&self, cap: (u16, u16)) -> (u16, u16) {
279        self.natural_size_with(cap, &NoopSizer)
280    }
281
282    /// Natural `(width, height)` bounded by `cap`, asking `sizer` for each
283    /// leaf's intrinsic size. `Fit` contributes the sizer's reported size on
284    /// the primary axis; `Fill` always contributes `0`. Chrome (border, gap)
285    /// is added on top. Result is always `<= cap`.
286    pub fn natural_size_with(&self, cap: (u16, u16), sizer: &dyn LeafSizer) -> (u16, u16) {
287        match self {
288            LayoutTree::Leaf {
289                id,
290                chrome,
291                natural,
292            } => {
293                let (cw, ch) = chrome_overhead(chrome);
294                let inner_cap = (cap.0.saturating_sub(cw), cap.1.saturating_sub(ch));
295                let (w, h) = natural
296                    .as_deref()
297                    .map(|n| n.size(inner_cap))
298                    .unwrap_or_else(|| sizer.leaf_natural_size(*id, inner_cap));
299                ((w + cw).min(cap.0), (h + ch).min(cap.1))
300            }
301            LayoutTree::Vbox { items, chrome } => natural_box(items, chrome, cap, true, sizer),
302            LayoutTree::Hbox { items, chrome } => natural_box(items, chrome, cap, false, sizer),
303        }
304    }
305}
306
307/// Resolves a leaf's natural size for `Fit` constraints and the natural-size
308/// pass. Hosts implement this against their leaf store (e.g. window buffer
309/// line counts) to drive content-aware sizing.
310pub trait LeafSizer {
311    /// Natural `(width, height)` for `id` at the given available cap. Return
312    /// `(0, 0)` for leaves with no intrinsic size.
313    fn leaf_natural_size(&self, id: PaintId, cap: (u16, u16)) -> (u16, u16);
314}
315
316/// Default sizer: every leaf reports `(0, 0)`. Used by callers that don't
317/// need content-aware sizing (split layout, tests, storybook).
318pub struct NoopSizer;
319
320impl LeafSizer for NoopSizer {
321    fn leaf_natural_size(&self, _id: PaintId, _cap: (u16, u16)) -> (u16, u16) {
322        (0, 0)
323    }
324}
325
326/// `(width, height)` reserved by `chrome.border`'s per-side toggles.
327fn chrome_border_dims(chrome: &Chrome) -> (u16, u16) {
328    let Some(b) = chrome.border else {
329        return (0, 0);
330    };
331    let bw = u16::from(b.left.is_some()) + u16::from(b.right.is_some());
332    let bh = u16::from(b.top.is_some()) + u16::from(b.bottom.is_some());
333    (bw, bh)
334}
335
336/// Total `(width, height)` overhead from chrome: border + uniform padding on
337/// all four sides. Padding contributes `2 * chrome.padding` on each axis.
338fn chrome_overhead(chrome: &Chrome) -> (u16, u16) {
339    let (bw, bh) = chrome_border_dims(chrome);
340    let pad2 = chrome.padding.saturating_mul(2);
341    (bw.saturating_add(pad2), bh.saturating_add(pad2))
342}
343
344fn natural_box(
345    items: &[Item],
346    chrome: &Chrome,
347    cap: (u16, u16),
348    vertical: bool,
349    sizer: &dyn LeafSizer,
350) -> (u16, u16) {
351    let (cap_w, cap_h) = cap;
352    let (chrome_w, chrome_h) = chrome_overhead(chrome);
353    let gaps = chrome
354        .gap
355        .saturating_mul(items.len().saturating_sub(1) as u16);
356
357    // Inner cap subtracts chrome (border + padding) and gap from the primary axis.
358    let (primary_cap, secondary_cap) = if vertical {
359        (
360            cap_h.saturating_sub(chrome_h).saturating_sub(gaps),
361            cap_w.saturating_sub(chrome_w),
362        )
363    } else {
364        (
365            cap_w.saturating_sub(chrome_w).saturating_sub(gaps),
366            cap_h.saturating_sub(chrome_h),
367        )
368    };
369
370    let inner_cap = if vertical {
371        (secondary_cap, primary_cap)
372    } else {
373        (primary_cap, secondary_cap)
374    };
375
376    let mut primary = 0u16;
377    let mut secondary = 0u16;
378    for (constraint, child) in items {
379        let (child_w, child_h) = child.natural_size_with(inner_cap, sizer);
380        let primary_size = match constraint {
381            Constraint::Length(n) | Constraint::Max(n) | Constraint::Min(n) => *n,
382            Constraint::Percentage(p) => {
383                ((primary_cap as u32 * *p as u32) / 100).min(primary_cap as u32) as u16
384            }
385            Constraint::Ratio(num, denom) => {
386                if *denom == 0 {
387                    0
388                } else {
389                    ((primary_cap as u32 * *num as u32) / *denom as u32).min(primary_cap as u32)
390                        as u16
391                }
392            }
393            // `Fit` reports the leaf's natural size (via the sizer); `Fill`
394            // is elastic and contributes `0` to the parent's demand.
395            Constraint::Fit => {
396                if vertical {
397                    child_h
398                } else {
399                    child_w
400                }
401            }
402            Constraint::Fill => 0,
403        };
404        let cross_size = if vertical { child_w } else { child_h };
405        primary = primary.saturating_add(primary_size);
406        secondary = secondary.max(cross_size);
407    }
408    let (primary_chrome, secondary_chrome) = if vertical {
409        (chrome_h, chrome_w)
410    } else {
411        (chrome_w, chrome_h)
412    };
413    primary = primary.saturating_add(gaps).saturating_add(primary_chrome);
414    secondary = secondary.saturating_add(secondary_chrome);
415
416    let (w, h) = if vertical {
417        (secondary, primary)
418    } else {
419        (primary, secondary)
420    };
421    (w.min(cap_w), h.min(cap_h))
422}
423
424/// Which corner of a rectangle serves as its anchor point. Used by
425/// `ScreenAt` and `Cursor` (whose flip-on-overflow logic specifically
426/// pivots on real corners). For window-relative anchoring see [`Align`].
427#[derive(Clone, Copy, Debug, PartialEq, Eq)]
428pub enum Corner {
429    NW,
430    NE,
431    SW,
432    SE,
433}
434
435/// 9-point alignment inside a rectangle. Used by `Anchor::Win`: the
436/// chosen alignment picks the same point on both the target and the
437/// overlay, so `Center` places the overlay's center on the target's
438/// center, `NW` flush-mounts top-left to top-left, `N` puts the
439/// overlay's top edge midpoint on the target's top edge midpoint, etc.
440#[derive(Clone, Copy, Debug, PartialEq, Eq)]
441pub enum Align {
442    NW,
443    N,
444    NE,
445    W,
446    Center,
447    E,
448    SW,
449    S,
450    SE,
451}
452
453impl From<Corner> for Align {
454    fn from(c: Corner) -> Self {
455        match c {
456            Corner::NW => Align::NW,
457            Corner::NE => Align::NE,
458            Corner::SW => Align::SW,
459            Corner::SE => Align::SE,
460        }
461    }
462}
463
464/// Screen position for an anchored overlay. Carries position only;
465/// sizing lives on the container's layout.
466#[derive(Clone, Debug, PartialEq, Eq)]
467pub enum Anchor {
468    /// Centered on screen.
469    ScreenCenter,
470    /// Absolute screen position; `corner` is placed at `(row, col)`.
471    ScreenAt { row: i32, col: i32, corner: Corner },
472    /// Anchored to the text cursor; flips to the opposite corner on screen overflow.
473    Cursor {
474        corner: Corner,
475        row_offset: i32,
476        col_offset: i32,
477    },
478    /// Anchored to another window. `attach` picks the alignment point on
479    /// both the target rect and the overlay rect - see [`Align`]. `NW`
480    /// flush-mounts top-left; `Center` centers the overlay inside the
481    /// target; `N`/`S`/`E`/`W` align the overlay's matching edge midpoint
482    /// to the target's. `row_offset`/`col_offset` nudge the resolved rect
483    /// away from the alignment point - useful for compensating chrome
484    /// rows (gutter, status line) the host adds inside the target.
485    Win {
486        target: PaintId,
487        attach: Align,
488        row_offset: i32,
489        col_offset: i32,
490    },
491    /// Docked to the bottom of the screen; height clamps to `term_h - above_rows`.
492    ScreenBottom { above_rows: u16 },
493}
494
495/// Glyph family painted along a border edge.
496#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
497pub enum BorderStyle {
498    #[default]
499    Single,
500    Double,
501    Rounded,
502    /// Light double-dash (`╌` / `╎`). Corners fall back to single-style.
503    Dashed,
504}
505
506/// Styling for one edge of a `Border`. Currently only `color` (a theme highlight
507/// group resolved at paint time). `EdgeStyle::default()` paints with the
508/// terminal's default fg.
509#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
510pub struct EdgeStyle {
511    pub color: Option<smelt_style::theme::HlGroup>,
512}
513
514impl EdgeStyle {
515    pub const fn new() -> Self {
516        Self { color: None }
517    }
518    pub const fn with_color(hl: smelt_style::theme::HlGroup) -> Self {
519        Self { color: Some(hl) }
520    }
521}
522
523impl From<()> for EdgeStyle {
524    fn from(_: ()) -> Self {
525        Self::new()
526    }
527}
528
529impl From<smelt_style::theme::HlGroup> for EdgeStyle {
530    fn from(hl: smelt_style::theme::HlGroup) -> Self {
531        Self::with_color(hl)
532    }
533}
534
535/// A frame around a container: glyph family plus per-side `Option<EdgeStyle>`.
536/// A side that is `None` is not drawn and reserves no row/column. A side that is
537/// `Some(_)` reserves one row/column and is painted in the resolved fg.
538#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
539pub struct Border {
540    pub style: BorderStyle,
541    pub top: Option<EdgeStyle>,
542    pub right: Option<EdgeStyle>,
543    pub bottom: Option<EdgeStyle>,
544    pub left: Option<EdgeStyle>,
545}
546
547impl Border {
548    /// All sides disabled; glyph family `Single`. Use as a base for builders.
549    pub const OFF: Self = Self {
550        style: BorderStyle::Single,
551        top: None,
552        right: None,
553        bottom: None,
554        left: None,
555    };
556
557    pub const fn single() -> Self {
558        Self {
559            style: BorderStyle::Single,
560            ..Self::OFF
561        }
562    }
563    pub const fn rounded() -> Self {
564        Self {
565            style: BorderStyle::Rounded,
566            ..Self::OFF
567        }
568    }
569    pub const fn double() -> Self {
570        Self {
571            style: BorderStyle::Double,
572            ..Self::OFF
573        }
574    }
575
576    pub fn top(mut self, e: impl Into<EdgeStyle>) -> Self {
577        self.top = Some(e.into());
578        self
579    }
580    pub fn right(mut self, e: impl Into<EdgeStyle>) -> Self {
581        self.right = Some(e.into());
582        self
583    }
584    pub fn bottom(mut self, e: impl Into<EdgeStyle>) -> Self {
585        self.bottom = Some(e.into());
586        self
587    }
588    pub fn left(mut self, e: impl Into<EdgeStyle>) -> Self {
589        self.left = Some(e.into());
590        self
591    }
592    /// Enable every side with `e`. Copy bound lets callers pass `()` or a `HlGroup`.
593    pub fn all<E: Into<EdgeStyle> + Copy>(self, e: E) -> Self {
594        self.top(e).right(e).bottom(e).left(e)
595    }
596
597    pub fn any_side(&self) -> bool {
598        self.top.is_some() || self.right.is_some() || self.bottom.is_some() || self.left.is_some()
599    }
600
601    /// `Border::single().all(())` - single glyphs on all four sides, default color.
602    pub fn single_all() -> Self {
603        Self::single().all(())
604    }
605    pub fn rounded_all() -> Self {
606        Self::rounded().all(())
607    }
608    pub fn double_all() -> Self {
609        Self::double().all(())
610    }
611
612    /// Compatibility shortcuts for the three most common presets.
613    pub const SINGLE: Border = Border {
614        style: BorderStyle::Single,
615        top: Some(EdgeStyle::new()),
616        right: Some(EdgeStyle::new()),
617        bottom: Some(EdgeStyle::new()),
618        left: Some(EdgeStyle::new()),
619    };
620    pub const DOUBLE: Border = Border {
621        style: BorderStyle::Double,
622        top: Some(EdgeStyle::new()),
623        right: Some(EdgeStyle::new()),
624        bottom: Some(EdgeStyle::new()),
625        left: Some(EdgeStyle::new()),
626    };
627    pub const ROUNDED: Border = Border {
628        style: BorderStyle::Rounded,
629        top: Some(EdgeStyle::new()),
630        right: Some(EdgeStyle::new()),
631        bottom: Some(EdgeStyle::new()),
632        left: Some(EdgeStyle::new()),
633    };
634}
635
636#[derive(Clone, Copy, Debug)]
637pub struct Gutters {
638    pub pad_left: u16,
639    pub pad_right: u16,
640    pub scrollbar: bool,
641}
642
643impl Default for Gutters {
644    /// Any buffer-backed window is scrollable by default. The scrollbar paints
645    /// only when content overflows; the column it occupies is reserved either
646    /// way so content width is stable across overflow transitions. Surfaces
647    /// that can't scroll by construction (single-line status strips, cursor-
648    /// driven list/option/input panels) must opt out with `scrollbar: false`.
649    fn default() -> Self {
650        Self {
651            pad_left: 0,
652            pad_right: 0,
653            scrollbar: true,
654        }
655    }
656}
657
658impl Gutters {
659    pub fn scrollbar_width(&self) -> u16 {
660        if self.scrollbar {
661            1
662        } else {
663            0
664        }
665    }
666
667    /// Width inside the left gutter (still includes the scrollbar column if any).
668    pub fn layer_width(&self, total: u16) -> u16 {
669        total.saturating_sub(self.pad_left)
670    }
671
672    /// Inner content width once `pad_left`, `pad_right`, and the scrollbar column are subtracted.
673    pub fn content_width(&self, total: u16) -> u16 {
674        self.layer_width(total)
675            .saturating_sub(self.pad_right)
676            .saturating_sub(self.scrollbar_width())
677    }
678}
679
680#[derive(Clone, Copy, Debug, PartialEq, Eq)]
681pub struct LayoutRect {
682    pub id: PaintId,
683    pub rect: Rect,
684}
685
686/// Resolve the tree against `area` and return the rect of every leaf. `Fit`
687/// constraints contribute `0` (no leaf-size hook); use `resolve_layout_with`
688/// to drive content-aware sizing.
689pub fn resolve_layout(tree: &LayoutTree, area: Rect) -> HashMap<PaintId, Rect> {
690    resolve_layout_with(tree, area, &NoopSizer)
691}
692
693/// Resolve the tree against `area` using `sizer` to size `Fit` constraints
694/// from each leaf's natural size. `Fit` items claim their natural primary
695/// extent before `Fill` siblings split the remainder.
696pub fn resolve_layout_with(
697    tree: &LayoutTree,
698    area: Rect,
699    sizer: &dyn LeafSizer,
700) -> HashMap<PaintId, Rect> {
701    resolve_layout_ordered_with(tree, area, sizer)
702        .into_iter()
703        .map(|r| (r.id, r.rect))
704        .collect()
705}
706
707/// Resolve the tree against `area` and return every leaf in declaration order.
708/// Unlike [`resolve_layout`], repeated `PaintId`s are preserved.
709pub fn resolve_layout_ordered(tree: &LayoutTree, area: Rect) -> Vec<LayoutRect> {
710    resolve_layout_ordered_with(tree, area, &NoopSizer)
711}
712
713/// Resolve the tree against `area` using `sizer` and preserve every leaf in
714/// declaration order. Repeated `PaintId`s appear once per leaf.
715pub fn resolve_layout_ordered_with(
716    tree: &LayoutTree,
717    area: Rect,
718    sizer: &dyn LeafSizer,
719) -> Vec<LayoutRect> {
720    let mut result = Vec::new();
721    resolve_node_ordered(tree, area, sizer, &mut result);
722    result
723}
724
725/// Inner area after subtracting the border's per-side reservations.
726/// Returns `area` unchanged when `border` is `None`. Does not account for
727/// `Chrome.padding`; prefer [`inset_for_chrome`] when you have the full
728/// `Chrome`.
729pub fn inset_for_border(area: Rect, border: Option<Border>) -> Rect {
730    let Some(b) = border else {
731        return area;
732    };
733    let top_pad = if b.top.is_some() { 1 } else { 0 };
734    let bot_pad = if b.bottom.is_some() { 1 } else { 0 };
735    let left_pad = if b.left.is_some() { 1 } else { 0 };
736    let right_pad = if b.right.is_some() { 1 } else { 0 };
737    let h = area.height.saturating_sub(top_pad).saturating_sub(bot_pad);
738    let w = area
739        .width
740        .saturating_sub(left_pad)
741        .saturating_sub(right_pad);
742    Rect::new(area.top + top_pad, area.left + left_pad, w, h)
743}
744
745/// Inner area after subtracting both `chrome.border` reservations and
746/// `chrome.padding` (uniform on all four sides). Returns `area` unchanged
747/// when both are zero.
748pub fn inset_for_chrome(area: Rect, chrome: &Chrome) -> Rect {
749    let bordered = inset_for_border(area, chrome.border);
750    let p = chrome.padding;
751    if p == 0 {
752        return bordered;
753    }
754    let top = bordered.top + p;
755    let left = bordered.left + p;
756    let w = bordered.width.saturating_sub(p).saturating_sub(p);
757    let h = bordered.height.saturating_sub(p).saturating_sub(p);
758    Rect::new(top, left, w, h)
759}
760
761/// Paint a container's border and title into `grid` at `area`.
762/// Corners are drawn only when both adjacent edges are enabled. When two
763/// adjacent edges disagree on color, the top/bottom edge wins.
764/// Title requires `border.top.is_some()`.
765pub fn paint_chrome(
766    grid: &mut crate::grid::Grid,
767    area: Rect,
768    chrome: &Chrome,
769    theme: &crate::Theme,
770) {
771    let Some(border) = chrome.border else {
772        return;
773    };
774    if !border.any_side() {
775        return;
776    }
777    if area.width == 0 || area.height == 0 {
778        return;
779    }
780    let (h, v, tl, tr, bl, br) = match border.style {
781        BorderStyle::Single => ('─', '│', '┌', '┐', '└', '┘'),
782        BorderStyle::Double => ('═', '║', '╔', '╗', '╚', '╝'),
783        BorderStyle::Rounded => ('─', '│', '╭', '╮', '╰', '╯'),
784        BorderStyle::Dashed => ('╌', '╎', '┌', '┐', '└', '┘'),
785    };
786    let edge_style = |e: Option<EdgeStyle>| -> super::grid::Style {
787        match e.and_then(|s| s.color) {
788            Some(hl) => {
789                let mut style = theme.resolve(hl);
790                style.bg = None;
791                style
792            }
793            None => super::grid::Style::default(),
794        }
795    };
796    let top_style = edge_style(border.top);
797    let bot_style = edge_style(border.bottom);
798    let left_style = edge_style(border.left);
799    let right_style = edge_style(border.right);
800    let right = area.left + area.width - 1;
801    let bottom = area.top + area.height - 1;
802
803    if border.top.is_some() {
804        for col in area.left..=right {
805            grid.set(col, area.top, h, top_style);
806        }
807    }
808    if border.bottom.is_some() && bottom != area.top {
809        for col in area.left..=right {
810            grid.set(col, bottom, h, bot_style);
811        }
812    }
813    if border.left.is_some() {
814        for row in area.top..=bottom {
815            grid.set(area.left, row, v, left_style);
816        }
817    }
818    if border.right.is_some() && right != area.left {
819        for row in area.top..=bottom {
820            grid.set(right, row, v, right_style);
821        }
822    }
823    // Corners only when both adjacent edges are present. Top/bottom wins on color.
824    if border.top.is_some() && border.left.is_some() {
825        grid.set(area.left, area.top, tl, top_style);
826    }
827    if border.top.is_some() && border.right.is_some() && right != area.left {
828        grid.set(right, area.top, tr, top_style);
829    }
830    if border.bottom.is_some() && border.left.is_some() && bottom != area.top {
831        grid.set(area.left, bottom, bl, bot_style);
832    }
833    if border.bottom.is_some() && border.right.is_some() && bottom != area.top && right != area.left
834    {
835        grid.set(right, bottom, br, bot_style);
836    }
837
838    if border.top.is_some() {
839        if let Some(title) = chrome.title.as_ref() {
840            // Inset title by one cell from each end so it reads as `─title──`
841            // regardless of whether the left/right sides are enabled.
842            let title_left = area.left + 1;
843            let title_right_excl = right;
844            if title_right_excl > title_left {
845                let limit = title_right_excl;
846                let mut col = title_left;
847                for span in &title.spans {
848                    if col >= limit {
849                        break;
850                    }
851                    let span_style = merge_title_span_style(top_style, span.style);
852                    let mut written = false;
853                    for ch in span.text.chars() {
854                        let cw = crate::grid::char_width(ch);
855                        if col + cw > limit {
856                            break;
857                        }
858                        grid.set(col, area.top, ch, span_style);
859                        col += cw;
860                        written = true;
861                    }
862                    if !written {
863                        break;
864                    }
865                }
866            }
867        }
868    }
869}
870
871/// Merge a title span's style over the chrome style: span fg/bg override when set; attrs OR.
872fn merge_title_span_style(
873    base: crate::grid::Style,
874    span: crate::grid::Style,
875) -> crate::grid::Style {
876    crate::grid::Style {
877        fg: span.fg.or(base.fg),
878        bg: span.bg.or(base.bg),
879        bold: base.bold || span.bold,
880        dim: base.dim || span.dim,
881        italic: base.italic || span.italic,
882        underline: base.underline || span.underline,
883        crossedout: base.crossedout || span.crossedout,
884        reverse: base.reverse || span.reverse,
885    }
886}
887
888fn resolve_node_ordered(
889    node: &LayoutTree,
890    area: Rect,
891    sizer: &dyn LeafSizer,
892    out: &mut Vec<LayoutRect>,
893) {
894    match node {
895        LayoutTree::Leaf { id, chrome, .. } => {
896            out.push(LayoutRect {
897                id: *id,
898                rect: inset_for_chrome(area, chrome),
899            });
900        }
901        LayoutTree::Vbox { items, chrome } => {
902            resolve_box_ordered(items, chrome, area, true, sizer, out);
903        }
904        LayoutTree::Hbox { items, chrome } => {
905            resolve_box_ordered(items, chrome, area, false, sizer, out);
906        }
907    }
908}
909
910/// Lay out a container's children. Returns `(inner_area, child_rects)`. Both
911/// `resolve_box` and the renderer in `term::paint_layout_tree_with` call this
912/// to keep their geometry in sync (hit-test rects must match painted rects).
913pub fn layout_box_children(
914    items: &[Item],
915    chrome: &Chrome,
916    area: Rect,
917    vertical: bool,
918    sizer: &dyn LeafSizer,
919) -> (Rect, Vec<Rect>) {
920    let inner = inset_for_chrome(area, chrome);
921    let total_gap = chrome
922        .gap
923        .saturating_mul(items.len().saturating_sub(1) as u16);
924    let primary_total = if vertical { inner.height } else { inner.width };
925    let available = primary_total.saturating_sub(total_gap);
926
927    // Compute `Fit` children's natural caps along the primary axis.
928    let fit_caps: Vec<Option<u16>> = items
929        .iter()
930        .map(|(c, child)| match c {
931            Constraint::Fit => {
932                let leaf_cap = if vertical {
933                    (inner.width, available)
934                } else {
935                    (available, inner.height)
936                };
937                let (nw, nh) = child.natural_size_with(leaf_cap, sizer);
938                Some(if vertical { nh } else { nw })
939            }
940            _ => None,
941        })
942        .collect();
943
944    let sizes = resolve_constraints_with_fit_caps(items, available, &fit_caps);
945    let used_sizes = sizes
946        .iter()
947        .fold(0u16, |acc, size| acc.saturating_add(*size));
948    let used = used_sizes.saturating_add(total_gap);
949    let extra = primary_total.saturating_sub(used);
950    let gap_count = items.len().saturating_sub(1) as u16;
951    let (extra_per_gap, mut extra_remainder) =
952        if chrome.justify == Justify::SpaceBetween && gap_count > 0 {
953            (extra / gap_count, extra % gap_count)
954        } else {
955            (0, 0)
956        };
957
958    let mut rects = Vec::with_capacity(items.len());
959    let mut offset = 0u16;
960    for (i, &size) in sizes.iter().enumerate() {
961        let r = if vertical {
962            Rect::new(inner.top + offset, inner.left, inner.width, size)
963        } else {
964            Rect::new(inner.top, inner.left + offset, size, inner.height)
965        };
966        rects.push(r);
967        offset = offset.saturating_add(size);
968        if i + 1 < items.len() {
969            offset = offset
970                .saturating_add(chrome.gap)
971                .saturating_add(extra_per_gap);
972            if extra_remainder > 0 {
973                offset = offset.saturating_add(1);
974                extra_remainder -= 1;
975            }
976        }
977    }
978    (inner, rects)
979}
980
981fn resolve_box_ordered(
982    items: &[Item],
983    chrome: &Chrome,
984    area: Rect,
985    vertical: bool,
986    sizer: &dyn LeafSizer,
987    out: &mut Vec<LayoutRect>,
988) {
989    let (_, rects) = layout_box_children(items, chrome, area, vertical, sizer);
990    for ((_, child), &rect) in items.iter().zip(rects.iter()) {
991        resolve_node_ordered(child, rect, sizer, out);
992    }
993}
994
995pub fn resolve_constraints(items: &[Item], total: u16) -> Vec<u16> {
996    let caps: Vec<Option<u16>> = vec![None; items.len()];
997    resolve_constraints_with_fit_caps(items, total, &caps)
998}
999
1000/// Resolve item sizes given pre-computed natural-size caps for `Fit` children.
1001/// `fit_caps[i]` is the natural size along the primary axis for a `Fit` child;
1002/// `None` means uncapped (used for non-Fit children or when the caller has no
1003/// sizer). Container resolvers (`resolve_box`, `paint_layout_tree_with`)
1004/// compute these and pass them through; the bare `resolve_constraints` wrapper
1005/// treats `Fit` as uncapped (equivalent to `Fill`).
1006pub fn resolve_constraints_with_fit_caps(
1007    items: &[Item],
1008    total: u16,
1009    fit_caps: &[Option<u16>],
1010) -> Vec<u16> {
1011    let mut sizes = vec![0u16; items.len()];
1012    let mut remaining = total;
1013
1014    // Pass 1: hard claimants (`Length`, `Percentage`) take their exact share.
1015    for (i, (c, _)) in items.iter().enumerate() {
1016        match c {
1017            Constraint::Length(n) => {
1018                let n = (*n).min(remaining);
1019                sizes[i] = n;
1020                remaining -= n;
1021            }
1022            Constraint::Percentage(pct) => {
1023                let n = ((total as u32 * *pct as u32) / 100).min(remaining as u32) as u16;
1024                sizes[i] = n;
1025                remaining -= n;
1026            }
1027            _ => {}
1028        }
1029    }
1030
1031    // Pass 2: `Ratio` siblings split a sub-pool proportionally.
1032    let ratio_total: u32 = items
1033        .iter()
1034        .filter_map(|(c, _)| match c {
1035            Constraint::Ratio(num, _) => Some(*num as u32),
1036            _ => None,
1037        })
1038        .sum();
1039    let ratio_pool = remaining;
1040    let mut consumed = 0u16;
1041    for (i, (c, _)) in items.iter().enumerate() {
1042        if let Constraint::Ratio(num, _) = c {
1043            let n = (ratio_pool as u32 * *num as u32)
1044                .checked_div(ratio_total)
1045                .unwrap_or(0) as u16;
1046            sizes[i] = n;
1047            consumed += n;
1048        }
1049    }
1050    remaining -= consumed.min(remaining);
1051
1052    // Pass 3: elastic children (`Fill`, `Fit`, `Min`, `Max`) share the remainder.
1053    // Each elastic child has a `(floor, cap)`:
1054    //   * `Fill`     → `(0, MAX)`
1055    //   * `Fit`      → `(0, natural)`     - natural comes from `fit_caps`
1056    //   * `Min(n)`   → `(n, MAX)`
1057    //   * `Max(n)`   → `(0, n)`
1058    // Equal-share baseline; clamp to caps; surplus from cap-hit children
1059    // redistributes to siblings with headroom; floors raising the total over
1060    // budget claw back proportionally.
1061    let elastic: Vec<(usize, u16, u16)> = items
1062        .iter()
1063        .enumerate()
1064        .filter_map(|(i, (c, _))| {
1065            elastic_bounds(*c, fit_caps.get(i).copied().flatten()).map(|(f, cap)| (i, f, cap))
1066        })
1067        .collect();
1068    if elastic.is_empty() || remaining == 0 {
1069        // Still need to honor floors (`Min(n)`) even when no remainder - they
1070        // can claim space by displacing earlier hard claimants? No: passes
1071        // 1-2 already locked those in. Min with no remainder gets 0.
1072        return sizes;
1073    }
1074
1075    // Distribute `remaining` across elastic children, never exceeding each
1076    // child's cap. Each round pours an equal portion to children with
1077    // headroom, clipping at their cap; any unallocated residue (because
1078    // someone's headroom was smaller than its share) rolls into the next
1079    // round and goes to the remaining uncapped siblings. Terminates when
1080    // either the budget is exhausted or every child is at its cap.
1081    let mut shares = vec![0u16; elastic.len()];
1082    let caps: Vec<u32> = elastic.iter().map(|&(_, _, c)| c as u32).collect();
1083    let mut to_allocate = remaining as u32;
1084    loop {
1085        let uncapped: Vec<usize> = (0..elastic.len())
1086            .filter(|&k| (shares[k] as u32) < caps[k])
1087            .collect();
1088        if uncapped.is_empty() || to_allocate == 0 {
1089            break;
1090        }
1091        let m = uncapped.len() as u32;
1092        let per = to_allocate / m;
1093        let mut leftover = to_allocate % m;
1094        let mut allocated: u32 = 0;
1095        for &k in &uncapped {
1096            let want = per + u32::from(leftover > 0);
1097            leftover = leftover.saturating_sub(1);
1098            let room = caps[k] - shares[k] as u32;
1099            let take = want.min(room);
1100            shares[k] = shares[k].saturating_add(take as u16);
1101            allocated += take;
1102        }
1103        if allocated == 0 {
1104            break; // every remaining uncapped child has zero room (defensive)
1105        }
1106        to_allocate = to_allocate.saturating_sub(allocated);
1107    }
1108
1109    // Apply floors. If floors push total over `remaining`, take back from
1110    // non-floored children first, then proportionally from floored ones.
1111    for (k, &(_, floor, _)) in elastic.iter().enumerate() {
1112        if shares[k] < floor {
1113            shares[k] = floor;
1114        }
1115    }
1116    let total_shares: u32 = shares.iter().map(|&v| v as u32).sum();
1117    if total_shares > remaining as u32 {
1118        let mut over = (total_shares - remaining as u32) as u16;
1119        for (k, &(_, floor, _)) in elastic.iter().enumerate() {
1120            if over == 0 {
1121                break;
1122            }
1123            if floor == 0 {
1124                let take = shares[k].min(over);
1125                shares[k] -= take;
1126                over -= take;
1127            }
1128        }
1129        if over > 0 {
1130            let floored_total: u32 = elastic
1131                .iter()
1132                .enumerate()
1133                .filter(|(_, &(_, f, _))| f > 0)
1134                .map(|(k, _)| shares[k] as u32)
1135                .sum();
1136            if let Some(divisor) = std::num::NonZeroU32::new(floored_total) {
1137                for (k, &(_, f, _)) in elastic.iter().enumerate() {
1138                    if f > 0 {
1139                        let take = ((shares[k] as u32 * over as u32) / divisor) as u16;
1140                        shares[k] = shares[k].saturating_sub(take);
1141                    }
1142                }
1143                // Mop up rounding residual from any floored child.
1144                let new_total: u32 = shares.iter().map(|&v| v as u32).sum();
1145                let mut residual = new_total.saturating_sub(remaining as u32) as u16;
1146                for (k, &(_, f, _)) in elastic.iter().enumerate() {
1147                    if residual == 0 {
1148                        break;
1149                    }
1150                    if f > 0 {
1151                        let take = shares[k].min(residual);
1152                        shares[k] -= take;
1153                        residual -= take;
1154                    }
1155                }
1156            }
1157        }
1158    }
1159
1160    for (k, &(i, _, _)) in elastic.iter().enumerate() {
1161        sizes[i] = shares[k];
1162    }
1163    sizes
1164}
1165
1166/// `(floor, cap)` bounds for an elastic constraint. `Length`/`Percentage`/`Ratio`
1167/// return `None` - they're hard claimants resolved in earlier passes.
1168fn elastic_bounds(c: Constraint, fit_cap: Option<u16>) -> Option<(u16, u16)> {
1169    match c {
1170        Constraint::Fill => Some((0, u16::MAX)),
1171        Constraint::Fit => Some((0, fit_cap.unwrap_or(u16::MAX))),
1172        Constraint::Min(n) => Some((n, u16::MAX)),
1173        Constraint::Max(n) => Some((0, n)),
1174        Constraint::Length(_) | Constraint::Percentage(_) | Constraint::Ratio(_, _) => None,
1175    }
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180    use super::*;
1181
1182    const A: PaintId = PaintId(100);
1183    const B: PaintId = PaintId(101);
1184    const C: PaintId = PaintId(102);
1185
1186    #[test]
1187    fn single_leaf_fills_area() {
1188        let tree = LayoutTree::leaf(A);
1189        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1190        assert_eq!(result[&A], Rect::new(0, 0, 80, 24));
1191    }
1192
1193    #[test]
1194    fn vertical_split_fixed_and_fill() {
1195        let tree = LayoutTree::vbox(vec![
1196            (Constraint::Fill, LayoutTree::leaf(A)),
1197            (Constraint::Length(5), LayoutTree::leaf(B)),
1198        ]);
1199        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1200        assert_eq!(result[&A], Rect::new(0, 0, 80, 19));
1201        assert_eq!(result[&B], Rect::new(19, 0, 80, 5));
1202    }
1203
1204    #[test]
1205    fn vertical_space_between_puts_surplus_into_gap() {
1206        let tree = LayoutTree::vbox(vec![
1207            (Constraint::Length(2), LayoutTree::leaf(A)),
1208            (Constraint::Length(3), LayoutTree::leaf(B)),
1209        ])
1210        .with_gap(1)
1211        .with_justify(Justify::SpaceBetween);
1212        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 10));
1213        assert_eq!(result[&A], Rect::new(0, 0, 80, 2));
1214        assert_eq!(result[&B], Rect::new(7, 0, 80, 3));
1215    }
1216
1217    #[test]
1218    fn vertical_split_pct_and_fill() {
1219        let tree = LayoutTree::vbox(vec![
1220            (Constraint::Fill, LayoutTree::leaf(A)),
1221            (Constraint::Percentage(25), LayoutTree::leaf(B)),
1222        ]);
1223        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1224        assert_eq!(result[&B].height, 6);
1225        assert_eq!(result[&A].height, 18);
1226    }
1227
1228    #[test]
1229    fn horizontal_split() {
1230        let tree = LayoutTree::hbox(vec![
1231            (Constraint::Length(20), LayoutTree::leaf(A)),
1232            (Constraint::Fill, LayoutTree::leaf(B)),
1233        ]);
1234        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1235        assert_eq!(result[&A], Rect::new(0, 0, 20, 24));
1236        assert_eq!(result[&B], Rect::new(0, 20, 60, 24));
1237    }
1238
1239    #[test]
1240    fn multiple_fills_distribute_evenly() {
1241        let tree = LayoutTree::vbox(vec![
1242            (Constraint::Fill, LayoutTree::leaf(A)),
1243            (Constraint::Fill, LayoutTree::leaf(B)),
1244        ]);
1245        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1246        assert_eq!(result[&A].height, 12);
1247        assert_eq!(result[&B].height, 12);
1248    }
1249
1250    #[test]
1251    fn rect_contains() {
1252        let r = Rect::new(5, 10, 20, 10);
1253        assert!(r.contains(5, 10));
1254        assert!(r.contains(14, 29));
1255        assert!(!r.contains(15, 10));
1256        assert!(!r.contains(5, 30));
1257    }
1258
1259    #[test]
1260    fn nested_split() {
1261        let tree = LayoutTree::vbox(vec![
1262            (
1263                Constraint::Fill,
1264                LayoutTree::hbox(vec![
1265                    (Constraint::Fill, LayoutTree::leaf(A)),
1266                    (Constraint::Fill, LayoutTree::leaf(B)),
1267                ]),
1268            ),
1269            (Constraint::Length(4), LayoutTree::leaf(C)),
1270        ]);
1271        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1272        assert_eq!(result[&C], Rect::new(20, 0, 80, 4));
1273        assert_eq!(result[&A], Rect::new(0, 0, 40, 20));
1274        assert_eq!(result[&B], Rect::new(0, 40, 40, 20));
1275    }
1276
1277    #[test]
1278    fn ordered_resolution_preserves_repeated_ids() {
1279        let tree = LayoutTree::vbox(vec![
1280            (Constraint::Length(1), LayoutTree::leaf(A)),
1281            (Constraint::Length(1), LayoutTree::leaf(A)),
1282        ]);
1283        let result = resolve_layout_ordered(&tree, Rect::new(0, 0, 10, 2));
1284        assert_eq!(
1285            result,
1286            vec![
1287                LayoutRect {
1288                    id: A,
1289                    rect: Rect::new(0, 0, 10, 1),
1290                },
1291                LayoutRect {
1292                    id: A,
1293                    rect: Rect::new(1, 0, 10, 1),
1294                },
1295            ]
1296        );
1297    }
1298
1299    #[test]
1300    fn map_resolution_keeps_last_repeated_id() {
1301        let tree = LayoutTree::vbox(vec![
1302            (Constraint::Length(1), LayoutTree::leaf(A)),
1303            (Constraint::Length(1), LayoutTree::leaf(A)),
1304        ]);
1305        let result = resolve_layout(&tree, Rect::new(0, 0, 10, 2));
1306        assert_eq!(result[&A], Rect::new(1, 0, 10, 1));
1307    }
1308
1309    #[test]
1310    fn min_competes_with_fill_for_equal_share_when_floor_satisfied() {
1311        let tree = LayoutTree::vbox(vec![
1312            (Constraint::Min(3), LayoutTree::leaf(A)),
1313            (Constraint::Fill, LayoutTree::leaf(B)),
1314        ]);
1315        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1316        assert_eq!(result[&A].height, 12);
1317        assert_eq!(result[&B].height, 12);
1318    }
1319
1320    #[test]
1321    fn min_clamps_up_to_floor_when_equal_share_too_small() {
1322        let tree = LayoutTree::vbox(vec![
1323            (Constraint::Min(20), LayoutTree::leaf(A)),
1324            (Constraint::Fill, LayoutTree::leaf(B)),
1325        ]);
1326        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1327        assert_eq!(result[&A].height, 20);
1328        assert_eq!(result[&B].height, 4);
1329    }
1330
1331    #[test]
1332    fn min_zero_alone_consumes_all_remaining() {
1333        let tree = LayoutTree::vbox(vec![(Constraint::Min(0), LayoutTree::leaf(A))]);
1334        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1335        assert_eq!(result[&A].height, 24);
1336    }
1337
1338    #[test]
1339    fn min_with_length_sibling_takes_remainder() {
1340        let tree = LayoutTree::vbox(vec![
1341            (Constraint::Length(10), LayoutTree::leaf(A)),
1342            (Constraint::Min(0), LayoutTree::leaf(B)),
1343        ]);
1344        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1345        assert_eq!(result[&A].height, 10);
1346        assert_eq!(result[&B].height, 14);
1347    }
1348
1349    #[test]
1350    fn two_mins_split_evenly_when_total_overruns_floors() {
1351        let tree = LayoutTree::vbox(vec![
1352            (Constraint::Min(20), LayoutTree::leaf(A)),
1353            (Constraint::Min(20), LayoutTree::leaf(B)),
1354        ]);
1355        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1356        assert_eq!(result[&A].height + result[&B].height, 24);
1357        assert!((result[&A].height as i32 - result[&B].height as i32).abs() <= 1);
1358    }
1359
1360    #[test]
1361    fn max_caps_at_ceiling_when_parent_has_room() {
1362        let tree = LayoutTree::vbox(vec![
1363            (Constraint::Max(5), LayoutTree::leaf(A)),
1364            (Constraint::Fill, LayoutTree::leaf(B)),
1365        ]);
1366        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1367        assert_eq!(result[&A].height, 5);
1368        assert_eq!(result[&B].height, 19);
1369    }
1370
1371    #[test]
1372    fn max_shrinks_when_parent_smaller_than_ceiling() {
1373        let tree = LayoutTree::vbox(vec![(Constraint::Max(50), LayoutTree::leaf(A))]);
1374        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1375        assert_eq!(result[&A].height, 24);
1376    }
1377
1378    #[test]
1379    fn ratio_splits_remaining_proportionally() {
1380        let tree = LayoutTree::hbox(vec![
1381            (Constraint::Ratio(1, 3), LayoutTree::leaf(A)),
1382            (Constraint::Ratio(2, 3), LayoutTree::leaf(B)),
1383        ]);
1384        let result = resolve_layout(&tree, Rect::new(0, 0, 90, 24));
1385        assert_eq!(result[&A].width, 30);
1386        assert_eq!(result[&B].width, 60);
1387    }
1388
1389    #[test]
1390    fn ratio_competes_with_length_for_remaining() {
1391        let tree = LayoutTree::hbox(vec![
1392            (Constraint::Length(20), LayoutTree::leaf(A)),
1393            (Constraint::Ratio(1, 2), LayoutTree::leaf(B)),
1394            (Constraint::Ratio(1, 2), LayoutTree::leaf(C)),
1395        ]);
1396        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1397        assert_eq!(result[&A].width, 20);
1398        assert_eq!(result[&B].width, 30);
1399        assert_eq!(result[&C].width, 30);
1400    }
1401
1402    #[test]
1403    fn fit_with_noop_sizer_contributes_zero() {
1404        let tree = LayoutTree::vbox(vec![
1405            (Constraint::Fit, LayoutTree::leaf(A)),
1406            (Constraint::Fill, LayoutTree::leaf(B)),
1407        ]);
1408        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1409        // `NoopSizer`: A reports 0 → `Fit` claims 0, `Fill` takes the rest.
1410        assert_eq!(result[&A].height, 0);
1411        assert_eq!(result[&B].height, 24);
1412    }
1413
1414    struct FixedSizer(u16);
1415
1416    impl LeafSizer for FixedSizer {
1417        fn leaf_natural_size(&self, _id: PaintId, _cap: (u16, u16)) -> (u16, u16) {
1418            (0, self.0)
1419        }
1420    }
1421
1422    /// Per-leaf-id natural heights, capped at the available extent.
1423    struct PerLeafSizer(std::collections::HashMap<PaintId, u16>);
1424
1425    impl LeafSizer for PerLeafSizer {
1426        fn leaf_natural_size(&self, id: PaintId, cap: (u16, u16)) -> (u16, u16) {
1427            (0, self.0.get(&id).copied().unwrap_or(0).min(cap.1))
1428        }
1429    }
1430
1431    /// Confirm-dialog-shaped vbox: small leaf Fits + one elastic Fit panel
1432    /// (the preview). At every terminal height the panels must collectively
1433    /// consume the entire dialog inner area - no leftover rows below the last
1434    /// panel (that's the "3 whitespaces under reason" bug).
1435    #[test]
1436    fn confirm_dialog_layout_consumes_all_rows_at_varying_heights() {
1437        let header = PaintId(101);
1438        let preview = PaintId(102);
1439        let allow = PaintId(103);
1440        let options = PaintId(104);
1441        let spacer = PaintId(105);
1442        let reason = PaintId(106);
1443
1444        let mut naturals = std::collections::HashMap::new();
1445        naturals.insert(header, 1);
1446        naturals.insert(preview, 50); // big content (file diff/text)
1447        naturals.insert(allow, 1);
1448        naturals.insert(options, 4);
1449        naturals.insert(spacer, 1);
1450        naturals.insert(reason, 1);
1451        let sizer = PerLeafSizer(naturals);
1452
1453        let tree = LayoutTree::vbox(vec![
1454            (Constraint::Fit, LayoutTree::leaf(header)),
1455            (Constraint::Fit, LayoutTree::leaf(preview)),
1456            (Constraint::Fit, LayoutTree::leaf(allow)),
1457            (Constraint::Fit, LayoutTree::leaf(options)),
1458            (Constraint::Fit, LayoutTree::leaf(spacer)),
1459            (Constraint::Fit, LayoutTree::leaf(reason)),
1460        ]);
1461
1462        for h in [8u16, 10, 12, 15, 18, 20, 24, 30, 40] {
1463            let result = resolve_layout_with(&tree, Rect::new(0, 0, 80, h), &sizer);
1464            let used: u16 = result.values().map(|r| r.height).sum();
1465            assert_eq!(
1466                used,
1467                h,
1468                "h={h}: panels used {used} rows, leaving {} unused",
1469                h - used
1470            );
1471            // Smalls keep their natural; preview absorbs the slack.
1472            assert_eq!(result[&header].height, 1, "h={h}: header");
1473            assert_eq!(result[&allow].height, 1, "h={h}: allow");
1474            assert_eq!(result[&spacer].height, 1, "h={h}: spacer");
1475            assert_eq!(result[&reason].height, 1, "h={h}: reason");
1476        }
1477    }
1478
1479    /// Same layout, but the preview has no content (`bash`-style confirm).
1480    /// Without an elastic outlet for surplus, smaller terminals must still
1481    /// pack the smalls tightly with zero whitespace below the last panel.
1482    #[test]
1483    fn confirm_dialog_no_preview_packs_tight_at_varying_heights() {
1484        let header = PaintId(101);
1485        let preview = PaintId(102);
1486        let allow = PaintId(103);
1487        let options = PaintId(104);
1488        let spacer = PaintId(105);
1489        let reason = PaintId(106);
1490
1491        let mut naturals = std::collections::HashMap::new();
1492        naturals.insert(header, 1);
1493        naturals.insert(preview, 0); // collapses (no content)
1494        naturals.insert(allow, 1);
1495        naturals.insert(options, 4);
1496        naturals.insert(spacer, 1);
1497        naturals.insert(reason, 1);
1498        let sizer = PerLeafSizer(naturals);
1499
1500        let tree = LayoutTree::vbox(vec![
1501            (Constraint::Fit, LayoutTree::leaf(header)),
1502            (Constraint::Fit, LayoutTree::leaf(preview)),
1503            (Constraint::Fit, LayoutTree::leaf(allow)),
1504            (Constraint::Fit, LayoutTree::leaf(options)),
1505            (Constraint::Fit, LayoutTree::leaf(spacer)),
1506            (Constraint::Fit, LayoutTree::leaf(reason)),
1507        ]);
1508
1509        // Natural sum is 8; with the dialog sized via max_height it picks
1510        // min(8, terminal). For terminals >= 8 the dialog should be 8 tall
1511        // and every panel takes its natural.
1512        for h in [8u16, 10, 12, 15, 20, 24] {
1513            let nat = tree.natural_size_with((80, h), &sizer);
1514            assert_eq!(nat.1, 8, "h={h}: dialog natural should equal sum-of-smalls");
1515            let dialog_h = nat.1.min(h);
1516            let result = resolve_layout_with(&tree, Rect::new(0, 0, 80, dialog_h), &sizer);
1517            let used: u16 = result.values().map(|r| r.height).sum();
1518            assert_eq!(used, dialog_h, "h={h}: total {used} != dialog_h {dialog_h}");
1519        }
1520    }
1521
1522    #[test]
1523    fn fit_with_sizer_uses_leaf_natural_height() {
1524        let tree = LayoutTree::vbox(vec![
1525            (Constraint::Fit, LayoutTree::leaf(A)),
1526            (Constraint::Fill, LayoutTree::leaf(B)),
1527        ]);
1528        let sizer = FixedSizer(3);
1529        let result = resolve_layout_with(&tree, Rect::new(0, 0, 80, 24), &sizer);
1530        assert_eq!(result[&A].height, 3, "Fit claims sizer-reported natural");
1531        assert_eq!(result[&B].height, 21, "Fill takes the remainder");
1532    }
1533
1534    #[test]
1535    fn fit_shares_with_fill_when_sizer_overflows() {
1536        // `Fit` is elastic-with-cap; when its natural exceeds the budget it
1537        // still shares fairly with `Fill` siblings instead of starving them.
1538        let tree = LayoutTree::vbox(vec![
1539            (Constraint::Fit, LayoutTree::leaf(A)),
1540            (Constraint::Fill, LayoutTree::leaf(B)),
1541        ]);
1542        // Sizer reports 50, but parent only has 10 rows.
1543        let sizer = FixedSizer(50);
1544        let result = resolve_layout_with(&tree, Rect::new(0, 0, 80, 10), &sizer);
1545        assert_eq!(result[&A].height, 5);
1546        assert_eq!(result[&B].height, 5);
1547    }
1548
1549    #[test]
1550    fn natural_size_with_sizer_reports_leaf_height() {
1551        let tree = LayoutTree::vbox(vec![(Constraint::Fit, LayoutTree::leaf(A))]);
1552        let sizer = FixedSizer(5);
1553        assert_eq!(tree.natural_size_with((80, 24), &sizer), (0, 5));
1554    }
1555
1556    #[test]
1557    fn natural_size_fill_contributes_zero_with_sizer() {
1558        let tree = LayoutTree::vbox(vec![(Constraint::Fill, LayoutTree::leaf(A))]);
1559        let sizer = FixedSizer(5);
1560        // Fill is elastic - even with a sizer reporting 5, it contributes 0
1561        // to the parent's natural-size demand.
1562        assert_eq!(tree.natural_size_with((80, 24), &sizer), (0, 0));
1563    }
1564
1565    #[test]
1566    fn zero_height_produces_empty_rects() {
1567        let tree = LayoutTree::vbox(vec![
1568            (Constraint::Length(30), LayoutTree::leaf(A)),
1569            (Constraint::Fill, LayoutTree::leaf(B)),
1570        ]);
1571        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 10));
1572        assert_eq!(result[&A].height, 10);
1573        assert_eq!(result[&B].height, 0);
1574    }
1575
1576    #[test]
1577    fn gap_inserts_spacing_between_children() {
1578        let tree = LayoutTree::vbox(vec![
1579            (Constraint::Fill, LayoutTree::leaf(A)),
1580            (Constraint::Fill, LayoutTree::leaf(B)),
1581            (Constraint::Fill, LayoutTree::leaf(C)),
1582        ])
1583        .with_gap(2);
1584        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1585        assert_eq!(result[&A], Rect::new(0, 0, 80, 7));
1586        assert_eq!(result[&B].top, 9);
1587        assert_eq!(result[&C].top, 18);
1588    }
1589
1590    #[test]
1591    fn border_insets_children_by_one_each_side() {
1592        let tree = LayoutTree::vbox(vec![(Constraint::Fill, LayoutTree::leaf(A))])
1593            .with_border(Border::SINGLE);
1594        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1595        assert_eq!(result[&A], Rect::new(1, 1, 78, 22));
1596    }
1597
1598    #[test]
1599    fn border_and_gap_compose() {
1600        let tree = LayoutTree::vbox(vec![
1601            (Constraint::Fill, LayoutTree::leaf(A)),
1602            (Constraint::Fill, LayoutTree::leaf(B)),
1603        ])
1604        .with_border(Border::SINGLE)
1605        .with_gap(1)
1606        .with_title("dialog");
1607        let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1608        assert_eq!(result[&A].top, 1);
1609        assert_eq!(result[&A].height + result[&B].height, 21);
1610        assert_eq!(result[&B].top, result[&A].top + result[&A].height + 1);
1611    }
1612
1613    #[test]
1614    fn natural_size_leaf_is_zero() {
1615        let tree = LayoutTree::leaf(A);
1616        assert_eq!(tree.natural_size((80, 24)), (0, 0));
1617    }
1618
1619    #[test]
1620    fn natural_size_vbox_lengths_sum_along_primary() {
1621        let tree = LayoutTree::vbox(vec![
1622            (Constraint::Length(5), LayoutTree::leaf(A)),
1623            (Constraint::Length(5), LayoutTree::leaf(B)),
1624        ]);
1625        assert_eq!(tree.natural_size((80, 24)), (0, 10));
1626    }
1627
1628    #[test]
1629    fn natural_size_hbox_lengths_sum_along_primary() {
1630        let tree = LayoutTree::hbox(vec![
1631            (Constraint::Length(20), LayoutTree::leaf(A)),
1632            (Constraint::Length(10), LayoutTree::leaf(B)),
1633        ]);
1634        assert_eq!(tree.natural_size((80, 24)), (30, 0));
1635    }
1636
1637    #[test]
1638    fn natural_size_vbox_gap_adds_to_primary() {
1639        let tree = LayoutTree::vbox(vec![
1640            (Constraint::Length(3), LayoutTree::leaf(A)),
1641            (Constraint::Length(4), LayoutTree::leaf(B)),
1642            (Constraint::Length(5), LayoutTree::leaf(C)),
1643        ])
1644        .with_gap(2);
1645        assert_eq!(tree.natural_size((80, 24)), (0, 16));
1646    }
1647
1648    #[test]
1649    fn natural_size_border_adds_two_each_axis() {
1650        let tree = LayoutTree::vbox(vec![(Constraint::Length(10), LayoutTree::leaf(A))])
1651            .with_border(Border::SINGLE);
1652        assert_eq!(tree.natural_size((80, 24)), (2, 12));
1653    }
1654
1655    #[test]
1656    fn natural_size_percentage_resolves_against_cap() {
1657        let tree = LayoutTree::vbox(vec![(Constraint::Percentage(50), LayoutTree::leaf(A))]);
1658        assert_eq!(tree.natural_size((80, 24)), (0, 12));
1659    }
1660
1661    #[test]
1662    fn natural_size_ratio_resolves_against_cap() {
1663        let tree = LayoutTree::hbox(vec![
1664            (Constraint::Ratio(1, 4), LayoutTree::leaf(A)),
1665            (Constraint::Ratio(1, 4), LayoutTree::leaf(B)),
1666        ]);
1667        assert_eq!(tree.natural_size((80, 24)), (40, 0));
1668    }
1669
1670    #[test]
1671    fn natural_size_fill_contributes_zero() {
1672        let tree = LayoutTree::vbox(vec![
1673            (Constraint::Length(3), LayoutTree::leaf(A)),
1674            (Constraint::Fill, LayoutTree::leaf(B)),
1675        ]);
1676        assert_eq!(tree.natural_size((80, 24)), (0, 3));
1677    }
1678
1679    #[test]
1680    fn natural_size_clamps_to_cap() {
1681        let tree = LayoutTree::vbox(vec![(Constraint::Length(100), LayoutTree::leaf(A))]);
1682        assert_eq!(tree.natural_size((80, 24)), (0, 24));
1683    }
1684
1685    #[test]
1686    fn leaves_in_order_walks_depth_first() {
1687        let tree = LayoutTree::vbox(vec![
1688            (Constraint::Fill, LayoutTree::leaf(A)),
1689            (
1690                Constraint::Length(5),
1691                LayoutTree::hbox(vec![
1692                    (Constraint::Fill, LayoutTree::leaf(B)),
1693                    (Constraint::Fill, LayoutTree::leaf(C)),
1694                ]),
1695            ),
1696        ]);
1697        assert_eq!(tree.leaves_in_order(), vec![A, B, C]);
1698    }
1699
1700    #[test]
1701    fn leaves_in_order_single_leaf() {
1702        let tree = LayoutTree::leaf(A);
1703        assert_eq!(tree.leaves_in_order(), vec![A]);
1704    }
1705
1706    #[test]
1707    fn leaf_carries_its_own_chrome() {
1708        let tree = LayoutTree::leaf(A)
1709            .with_border(Border::SINGLE)
1710            .with_title("hi");
1711        assert_eq!(tree.leaves_in_order(), vec![A]);
1712        assert!(tree.contains_leaf(A));
1713        match &tree {
1714            LayoutTree::Leaf { chrome, .. } => {
1715                assert!(chrome.border.is_some());
1716                assert!(chrome.title.is_some());
1717            }
1718            _ => panic!("expected Leaf with chrome"),
1719        }
1720    }
1721
1722    #[test]
1723    fn leaf_with_chrome_resolves_inside_inset_rect() {
1724        let tree = LayoutTree::leaf(A).with_border(Border::SINGLE);
1725        let area = Rect::new(0, 0, 10, 6);
1726        let rects = resolve_layout(&tree, area);
1727        let inner = rects.get(&A).copied().expect("leaf rect resolved");
1728        assert_eq!(inner, Rect::new(1, 1, 8, 4));
1729    }
1730
1731    #[test]
1732    fn contains_leaf_finds_direct_leaf() {
1733        let tree = LayoutTree::leaf(A);
1734        assert!(tree.contains_leaf(A));
1735        assert!(!tree.contains_leaf(B));
1736    }
1737
1738    #[test]
1739    fn contains_leaf_walks_nested_containers() {
1740        let tree = LayoutTree::vbox(vec![
1741            (Constraint::Fill, LayoutTree::leaf(A)),
1742            (
1743                Constraint::Length(5),
1744                LayoutTree::hbox(vec![(Constraint::Fill, LayoutTree::leaf(B))]),
1745            ),
1746        ]);
1747        assert!(tree.contains_leaf(A));
1748        assert!(tree.contains_leaf(B));
1749        assert!(!tree.contains_leaf(C));
1750    }
1751
1752    #[test]
1753    fn natural_size_nested_chrome_composes() {
1754        let tree = LayoutTree::vbox(vec![(
1755            Constraint::Length(5),
1756            LayoutTree::hbox(vec![
1757                (Constraint::Length(20), LayoutTree::leaf(A)),
1758                (Constraint::Length(10), LayoutTree::leaf(B)),
1759            ]),
1760        )])
1761        .with_border(Border::SINGLE);
1762        assert_eq!(tree.natural_size((80, 24)), (32, 7));
1763    }
1764
1765    #[test]
1766    fn paint_chrome_no_border_is_noop() {
1767        let mut grid = crate::grid::Grid::new(10, 5);
1768        let chrome = Chrome::default();
1769        paint_chrome(
1770            &mut grid,
1771            Rect::new(0, 0, 10, 5),
1772            &chrome,
1773            &crate::Theme::default(),
1774        );
1775        assert_eq!(grid.cell(0, 0).symbol, ' ');
1776    }
1777
1778    #[test]
1779    fn paint_chrome_single_border_draws_corners_and_edges() {
1780        let mut grid = crate::grid::Grid::new(10, 5);
1781        let chrome = Chrome {
1782            border: Some(Border::SINGLE),
1783            ..Chrome::default()
1784        };
1785        paint_chrome(
1786            &mut grid,
1787            Rect::new(0, 0, 10, 5),
1788            &chrome,
1789            &crate::Theme::default(),
1790        );
1791        assert_eq!(grid.cell(0, 0).symbol, '┌');
1792        assert_eq!(grid.cell(9, 0).symbol, '┐');
1793        assert_eq!(grid.cell(0, 4).symbol, '└');
1794        assert_eq!(grid.cell(9, 4).symbol, '┘');
1795        assert_eq!(grid.cell(5, 0).symbol, '─');
1796        assert_eq!(grid.cell(0, 2).symbol, '│');
1797    }
1798
1799    #[test]
1800    fn paint_chrome_title_paints_styled_spans() {
1801        use crate::grid::Color;
1802        use crate::line::{Line, Span};
1803        let mut grid = crate::grid::Grid::new(20, 3);
1804        let red = crate::grid::Style::new().fg(Color::Red);
1805        let chrome = Chrome {
1806            border: Some(Border::ROUNDED),
1807            title: Some(Line::from_spans([
1808                Span::raw("ok "),
1809                Span::styled("FAIL", red),
1810            ])),
1811            ..Chrome::default()
1812        };
1813        paint_chrome(
1814            &mut grid,
1815            Rect::new(0, 0, 20, 3),
1816            &chrome,
1817            &crate::Theme::default(),
1818        );
1819        assert_eq!(grid.cell(1, 0).symbol, 'o');
1820        assert_eq!(grid.cell(1, 0).style.fg, None);
1821        assert_eq!(grid.cell(4, 0).symbol, 'F');
1822        assert_eq!(grid.cell(4, 0).style.fg, Some(Color::Red));
1823    }
1824
1825    #[test]
1826    fn paint_chrome_title_lands_on_top_border() {
1827        let mut grid = crate::grid::Grid::new(20, 5);
1828        let chrome = Chrome {
1829            border: Some(Border::ROUNDED),
1830            title: Some("hello".into()),
1831            ..Chrome::default()
1832        };
1833        paint_chrome(
1834            &mut grid,
1835            Rect::new(0, 0, 20, 5),
1836            &chrome,
1837            &crate::Theme::default(),
1838        );
1839        assert_eq!(grid.cell(0, 0).symbol, '╭');
1840        assert_eq!(grid.cell(1, 0).symbol, 'h');
1841        assert_eq!(grid.cell(5, 0).symbol, 'o');
1842        assert_eq!(grid.cell(6, 0).symbol, '─');
1843    }
1844
1845    #[test]
1846    fn paint_chrome_truncates_title_to_inner_width() {
1847        let mut grid = crate::grid::Grid::new(8, 3);
1848        let chrome = Chrome {
1849            border: Some(Border::SINGLE),
1850            title: Some("muchtoolong".into()),
1851            ..Chrome::default()
1852        };
1853        paint_chrome(
1854            &mut grid,
1855            Rect::new(0, 0, 8, 3),
1856            &chrome,
1857            &crate::Theme::default(),
1858        );
1859        assert_eq!(grid.cell(0, 0).symbol, '┌');
1860        assert_eq!(grid.cell(1, 0).symbol, 'm');
1861        assert_eq!(grid.cell(6, 0).symbol, 'o');
1862        assert_eq!(grid.cell(7, 0).symbol, '┐');
1863    }
1864}