Skip to main content

tear_types/
layout.rs

1//! Binary-tree layout for a window's panes.
2//!
3//! Every [`crate::TearWindow`] holds exactly one [`LayoutNode`] —
4//! either a leaf (a single pane) or an internal node that splits the
5//! window into two children with a given orientation. The recursive
6//! structure mirrors how operators reason about tmux/screen panes:
7//! "split this pane to my right, then split the bottom-half down".
8//!
9//! mado's existing pane.rs/tab.rs uses a flat Vec<PaneRect>; the
10//! eventual M5 rebase swaps that for [`LayoutNode`] so a single
11//! algorithm computes pixel rects for both apps.
12
13use serde::{Deserialize, Serialize};
14
15use crate::{
16    direction::{Direction, SplitOrientation},
17    geometry::Rect,
18    id::PaneId,
19};
20
21/// Minimum fraction a side of a split may hold — keeps a resize from
22/// collapsing a pane to nothing while still letting the *cell*
23/// arithmetic squeeze it (a 2-cell window splits 1/1 regardless).
24pub const MIN_RATIO: f32 = 0.05;
25
26/// The fraction of a split's area allotted to side `a`, refined so that
27/// the illegal values have no representation.
28///
29/// ## Why a newtype and not a validated `f32`
30///
31/// The field used to be a public `f32`, which meant `0.0`, `1.0`, `-3.0`,
32/// `inf` and `NaN` were all constructible **and all deserialisable from the
33/// wire**. `validate()` rejected them — but `validate()` is called by a
34/// caller who remembers to, and CBOR deserialisation writes the field
35/// directly, so the guard was one forgotten call away from useless.
36///
37/// `NaN` was the worst of them because it fails *silently in the wrong
38/// direction*. `split_extent` computes `(total * ratio).round()` and then
39/// clamps — and **`f32::clamp` returns `NaN` for a `NaN` input** (it only
40/// panics when the *bounds* are NaN), after which `NaN as u16` saturates to
41/// `0`. So a NaN ratio did not panic and did not error: side `a` silently
42/// got zero cells and the pane vanished. The old comment on `split_extent`
43/// claimed the clamp "pins NaN-free bounds"; it did not.
44///
45/// Now the only way in is [`SplitRatio::new`], which normalises non-finite
46/// input to the balanced default and clamps the rest into
47/// `[MIN_RATIO, 1 - MIN_RATIO]`. The field is private, `Deserialize` routes
48/// through the same constructor, and `Serialize` is transparent — so the
49/// **CBOR wire shape is byte-identical to the bare `f32` it replaces.**
50///
51/// Tier: **truly-unrepresentable** in-process (no constructor produces an
52/// out-of-range or non-finite value); **parse-time-normalised** on the wire.
53#[derive(Copy, Clone, Debug, PartialEq, Serialize)]
54#[serde(transparent)]
55pub struct SplitRatio(f32);
56
57impl SplitRatio {
58    /// An even split. The value every balanced constructor uses.
59    pub const BALANCED: Self = Self(0.5);
60
61    /// Refine an arbitrary `f32`.
62    ///
63    /// Non-finite input (`NaN`, `±inf`) becomes [`Self::BALANCED`] rather
64    /// than propagating: there is no sensible clamp for a value that is not
65    /// on the number line, and silently yielding a zero-width pane is the
66    /// bug this type exists to remove.
67    #[must_use]
68    pub fn new(v: f32) -> Self {
69        if v.is_finite() {
70            Self(v.clamp(MIN_RATIO, 1.0 - MIN_RATIO))
71        } else {
72            Self::BALANCED
73        }
74    }
75
76    /// The refined value. Always finite and always within
77    /// `[MIN_RATIO, 1 - MIN_RATIO]`.
78    #[must_use]
79    pub const fn get(self) -> f32 {
80        self.0
81    }
82}
83
84impl Default for SplitRatio {
85    fn default() -> Self {
86        Self::BALANCED
87    }
88}
89
90impl From<f32> for SplitRatio {
91    fn from(v: f32) -> Self {
92        Self::new(v)
93    }
94}
95
96impl<'de> Deserialize<'de> for SplitRatio {
97    /// Routes through [`SplitRatio::new`], so a hostile or merely stale
98    /// peer cannot put an out-of-range or `NaN` ratio into a live tree.
99    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
100        Ok(Self::new(f32::deserialize(d)?))
101    }
102}
103
104/// Outcome of [`LayoutNode::remove_leaf`]. Removing a pane either
105/// collapses its parent split into the surviving sibling, removes the
106/// only pane (leaving nothing the tree can represent — the caller must
107/// close the window), or finds no such pane.
108#[derive(Copy, Clone, Debug, PartialEq, Eq)]
109pub enum LeafRemoval {
110    /// The leaf was removed and its parent split collapsed into the
111    /// sibling subtree. The tree still holds ≥1 pane.
112    Removed,
113    /// The leaf was the *root* — the tree is a single pane and cannot
114    /// represent emptiness. The caller closes the window/pane instead.
115    WasRoot,
116    /// No leaf with that id exists in the tree.
117    NotFound,
118}
119
120/// Why a [`LayoutNode`] failed [`LayoutNode::validate`]. Every variant
121/// names a structurally-illegal tree so the invariant violation is a
122/// typed value, not a silent mis-render (UNREPRESENTABILITY: a tree
123/// that fails to validate never reaches the renderer).
124// No `Eq` — `BadRatio(f32)` carries a float (NaN breaks total equality);
125// `PartialEq` is enough for tests and consumers.
126#[derive(Clone, Debug, PartialEq)]
127pub enum LayoutError {
128    /// A leaf carries [`PaneId::NULL`] — a dangling reference left by a
129    /// botched removal. The renderer would draw a blank hole.
130    NullLeaf,
131    /// The same [`PaneId`] appears in two leaves. A pane belongs to
132    /// exactly one slot; aliasing means two views fight over one PTY.
133    DuplicatePane(PaneId),
134    /// A split's ratio is not in the open interval `(0.0, 1.0)` — a 0 or
135    /// 1 (or NaN/out-of-range) ratio means one side is unrepresentable.
136    BadRatio(f32),
137}
138
139/// One node in a window's layout tree.
140#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
141#[serde(tag = "kind", rename_all = "lowercase")]
142pub enum LayoutNode {
143    /// A single pane filling its bounding box.
144    Leaf {
145        pane: PaneId,
146    },
147    /// A split that divides its area between [`LayoutNode::Split::a`]
148    /// (top/left) and [`LayoutNode::Split::b`] (bottom/right). `ratio`
149    /// is a in 0.0..=1.0 — the fraction of the parent area allotted
150    /// to side `a`.
151    Split {
152        orientation: SplitOrientation,
153        ratio: SplitRatio,
154        a: Box<LayoutNode>,
155        b: Box<LayoutNode>,
156    },
157}
158
159impl LayoutNode {
160    /// Convenience constructor for a leaf.
161    #[must_use]
162    pub fn leaf(pane: PaneId) -> Self {
163        Self::Leaf { pane }
164    }
165
166    /// Convenience constructor for a balanced (`ratio = 0.5`) split.
167    #[must_use]
168    pub fn split(orientation: SplitOrientation, a: LayoutNode, b: LayoutNode) -> Self {
169        Self::Split {
170            orientation,
171            ratio: SplitRatio::BALANCED,
172            a: Box::new(a),
173            b: Box::new(b),
174        }
175    }
176
177    /// Collect every pane id reachable from this node, in left-to-
178    /// right (then top-to-bottom) traversal order. Useful for
179    /// rendering a status bar that wants "the active window's panes
180    /// in display order".
181    #[must_use]
182    pub fn panes(&self) -> Vec<PaneId> {
183        let mut out = Vec::new();
184        self.collect(&mut out);
185        out
186    }
187
188    fn collect(&self, out: &mut Vec<PaneId>) {
189        match self {
190            Self::Leaf { pane } => out.push(*pane),
191            Self::Split { a, b, .. } => {
192                a.collect(out);
193                b.collect(out);
194            }
195        }
196    }
197
198    /// Number of leaves in the tree (= number of panes).
199    #[must_use]
200    pub fn pane_count(&self) -> usize {
201        match self {
202            Self::Leaf { .. } => 1,
203            Self::Split { a, b, .. } => a.pane_count() + b.pane_count(),
204        }
205    }
206
207    /// True when this node is exactly a leaf holding `pane`.
208    #[must_use]
209    fn is_leaf_of(&self, pane: PaneId) -> bool {
210        matches!(self, Self::Leaf { pane: p } if *p == pane)
211    }
212
213    /// True when `pane` is one of this subtree's leaves.
214    #[must_use]
215    pub fn contains_pane(&self, pane: PaneId) -> bool {
216        match self {
217            Self::Leaf { pane: p } => *p == pane,
218            Self::Split { a, b, .. } => a.contains_pane(pane) || b.contains_pane(pane),
219        }
220    }
221
222    /// Arrange an ordered slice of panes into a named [`LayoutKind`]
223    /// preset — the live-tree twin of [`crate::LayoutPlan::realize`]. The
224    /// result always [`validate`](Self::validate)s and (by the shipped
225    /// tiling property) `compute_rects` tiles its bounds exactly. `Even*`
226    /// arrangements give every pane a `1/n` share (ratio `1/(n-k)` per
227    /// split level), so an even-3 row is three equal thirds, not a
228    /// balanced binary `0.5` split.
229    ///
230    /// Returns `None` for an empty slice and for [`LayoutKind::Custom`]
231    /// (whose tree is its own source of truth — there is no canonical
232    /// arrangement to build). A single pane yields a leaf for any kind.
233    #[must_use]
234    pub fn from_kind(kind: LayoutKind, panes: &[PaneId]) -> Option<Self> {
235        match panes {
236            [] => None,
237            [only] => Some(Self::leaf(*only)),
238            [main, rest @ ..] => match kind {
239                // A horizontal ROW = panes side by side = Vertical splits.
240                LayoutKind::EvenHorizontal => {
241                    even_chain(SplitOrientation::Vertical, &leaves(panes))
242                }
243                // A vertical COLUMN = panes stacked = Horizontal splits.
244                LayoutKind::EvenVertical => {
245                    even_chain(SplitOrientation::Horizontal, &leaves(panes))
246                }
247                // One big pane on top; the rest share the bottom row.
248                LayoutKind::MainHorizontal => {
249                    let bottom = even_chain(SplitOrientation::Vertical, &leaves(rest))?;
250                    Some(Self::Split {
251                        orientation: SplitOrientation::Horizontal,
252                        ratio: SplitRatio::BALANCED,
253                        a: Box::new(Self::leaf(*main)),
254                        b: Box::new(bottom),
255                    })
256                }
257                // One big pane on the left; the rest stack on the right.
258                LayoutKind::MainVertical => {
259                    let right = even_chain(SplitOrientation::Horizontal, &leaves(rest))?;
260                    Some(Self::Split {
261                        orientation: SplitOrientation::Vertical,
262                        ratio: SplitRatio::BALANCED,
263                        a: Box::new(Self::leaf(*main)),
264                        b: Box::new(right),
265                    })
266                }
267                LayoutKind::Tiled => tiled(panes),
268                // No canonical arrangement — the operator's manual tree wins.
269                LayoutKind::Custom => None,
270            },
271        }
272    }
273
274    /// Replace the leaf holding `target` with a split whose two children
275    /// are the original pane and a fresh `new_pane`, ordered by
276    /// `direction`. This is the *correct* split — it replaces only the
277    /// matched leaf, leaving every other pane's position untouched
278    /// (unlike a whole-window re-wrap). `origin_ratio` is the fraction
279    /// the *target* keeps (clamped to `[MIN_RATIO, 1 - MIN_RATIO]`);
280    /// pass `0.5` for a balanced split.
281    ///
282    /// Returns `true` if `target` was found and split. A `Right`/`Below`
283    /// split puts the new pane after the target (right/below); a
284    /// `Left`/`Above` split puts it before.
285    pub fn split_leaf(
286        &mut self,
287        target: PaneId,
288        new_pane: PaneId,
289        direction: Direction,
290        origin_ratio: f32,
291    ) -> bool {
292        match self {
293            Self::Leaf { pane } if *pane == target => {
294                let origin = Self::leaf(*pane);
295                let fresh = Self::leaf(new_pane);
296                // Refinement (non-finite → balanced, then clamp) now lives
297                // in `SplitRatio::new`, so this site cannot get it wrong and
298                // neither can any future one.
299                let keep = SplitRatio::new(origin_ratio).get();
300                let orientation = direction.orientation();
301                // The new pane lands after the origin for Right/Below,
302                // before it for Left/Above. `ratio` is always the
303                // fraction held by side `a` (top/left), so it flips
304                // depending on which side the origin ends up on.
305                let (a, b, ratio) = match direction {
306                    Direction::Right | Direction::Below => (origin, fresh, keep),
307                    Direction::Left | Direction::Above => (fresh, origin, 1.0 - keep),
308                };
309                *self = Self::Split {
310                    orientation,
311                    ratio: SplitRatio::new(ratio),
312                    a: Box::new(a),
313                    b: Box::new(b),
314                };
315                true
316            }
317            Self::Leaf { .. } => false,
318            Self::Split { a, b, .. } => {
319                a.split_leaf(target, new_pane, direction, origin_ratio)
320                    || b.split_leaf(target, new_pane, direction, origin_ratio)
321            }
322        }
323    }
324
325    /// Remove the leaf holding `target` and collapse its parent split
326    /// into the surviving sibling — no dangling `NULL` leaf, no blank
327    /// hole. See [`LeafRemoval`] for the three outcomes.
328    pub fn remove_leaf(&mut self, target: PaneId) -> LeafRemoval {
329        match self {
330            Self::Leaf { pane } if *pane == target => LeafRemoval::WasRoot,
331            Self::Leaf { .. } => LeafRemoval::NotFound,
332            Self::Split { a, b, .. } => {
333                // A direct child that *is* the target leaf collapses this
334                // split into the other child.
335                if a.is_leaf_of(target) {
336                    *self = std::mem::replace(b.as_mut(), Self::leaf(PaneId::NULL));
337                    return LeafRemoval::Removed;
338                }
339                if b.is_leaf_of(target) {
340                    *self = std::mem::replace(a.as_mut(), Self::leaf(PaneId::NULL));
341                    return LeafRemoval::Removed;
342                }
343                // Otherwise recurse — a deeper split collapses in place.
344                match a.remove_leaf(target) {
345                    LeafRemoval::NotFound => b.remove_leaf(target),
346                    other => other,
347                }
348            }
349        }
350    }
351
352    /// Move the divider of the split governing `target` along `direction`
353    /// by `delta_frac` (a fraction of that split). Finds the deepest
354    /// ancestor split whose orientation matches the direction's axis and
355    /// contains `target`, then slides its divider: Right/Below raise the
356    /// ratio (the upper/left side grows), Left/Above lower it. Returns
357    /// `true` if such a divider was found.
358    ///
359    /// For the natural gesture — grow a left pane Right, a right pane
360    /// Left, a top pane Below, a bottom pane Above — this enlarges the
361    /// focused pane, because `direction` then points across the shared
362    /// divider toward `target`'s neighbour. The ratio is clamped to
363    /// `[MIN_RATIO, 1 - MIN_RATIO]`, so a divider never pins a pane to
364    /// zero (cell rounding may still squeeze a too-small window).
365    pub fn resize_leaf(&mut self, target: PaneId, direction: Direction, delta_frac: f32) -> bool {
366        let want = direction.orientation();
367        // We enlarge `target` toward `direction`, so we want the divider
368        // it shares with its neighbour ON THAT SIDE. The neighbour lives
369        // on side `b` for Right/Below and side `a` for Left/Above — so we
370        // need the deepest matching-orientation split where `target`
371        // descends into the OPPOSITE (its own) side: `a` when growing
372        // toward b, `b` when growing toward a. If no such split exists,
373        // `target` is at the window edge that way — a no-op (false).
374        let toward_b = matches!(direction, Direction::Right | Direction::Below);
375        // Two-pass to satisfy the borrow checker: locate the path (shared
376        // borrow), then mutate the ratio (exclusive borrow).
377        let Some(path) = self.governing_split_path(target, want, toward_b) else {
378            return false;
379        };
380        let Some(Self::Split { ratio, .. }) = self.split_at_path(&path) else {
381            return false;
382        };
383        // Growing toward b raises the ratio (side `a` = target grows);
384        // toward a lowers it (side `b` = target grows). A non-finite delta
385        // leaves the ratio untouched — `SplitRatio::new` would coerce it to
386        // BALANCED, which for a *resize* would silently re-centre a divider
387        // the operator had placed, so that case is caught here instead.
388        let sign = if toward_b { 1.0 } else { -1.0 };
389        let next = ratio.get() + sign * delta_frac;
390        if next.is_finite() {
391            *ratio = SplitRatio::new(next);
392        }
393        true
394    }
395
396    /// Path to the deepest split of orientation `want` that contains
397    /// `target` on the required side (`need_side_a` ⇒ `target` in side
398    /// `a`, else side `b`). That is the divider `target` shares with its
399    /// neighbour on the opposite side. `true` in a path step means "into
400    /// side a". `None` when no such split exists (window edge that way).
401    fn governing_split_path(
402        &self,
403        target: PaneId,
404        want: SplitOrientation,
405        need_side_a: bool,
406    ) -> Option<Vec<bool>> {
407        let mut best: Option<Vec<bool>> = None;
408        let mut path: Vec<bool> = Vec::new();
409        self.walk_governing(target, want, need_side_a, &mut path, &mut best);
410        best
411    }
412
413    fn walk_governing(
414        &self,
415        target: PaneId,
416        want: SplitOrientation,
417        need_side_a: bool,
418        path: &mut Vec<bool>,
419        best: &mut Option<Vec<bool>>,
420    ) {
421        if let Self::Split {
422            orientation, a, b, ..
423        } = self
424        {
425            let in_a = a.contains_pane(target);
426            let in_b = b.contains_pane(target);
427            // Record only a split of the wanted orientation where target
428            // is on the required side — that's the divider toward the
429            // neighbour we grow into. Deeper (closer) overwrites shallower.
430            if *orientation == want && ((in_a && need_side_a) || (in_b && !need_side_a)) {
431                *best = Some(path.clone());
432            }
433            if in_a {
434                path.push(true);
435                a.walk_governing(target, want, need_side_a, path, best);
436                path.pop();
437            } else if in_b {
438                path.push(false);
439                b.walk_governing(target, want, need_side_a, path, best);
440                path.pop();
441            }
442        }
443    }
444
445    fn split_at_path(&mut self, path: &[bool]) -> Option<&mut Self> {
446        let mut node = self;
447        for &into_a in path {
448            match node {
449                Self::Split { a, b, .. } => {
450                    node = if into_a { a.as_mut() } else { b.as_mut() };
451                }
452                Self::Leaf { .. } => return None,
453            }
454        }
455        Some(node)
456    }
457
458    /// Compute the pixel/cell rectangle of every pane, laying the tree
459    /// out within `bounds`. This is *the* layout renderer — mado draws
460    /// from it, tear-core sizes PTYs from it. Division is gap-free and
461    /// overlap-free: side `b` gets exactly the remainder side `a` left.
462    #[must_use]
463    pub fn compute_rects(&self, bounds: Rect) -> Vec<(PaneId, Rect)> {
464        let mut out = Vec::with_capacity(self.pane_count());
465        self.lay_out(bounds, &mut out);
466        out
467    }
468
469    fn lay_out(&self, bounds: Rect, out: &mut Vec<(PaneId, Rect)>) {
470        match self {
471            Self::Leaf { pane } => out.push((*pane, bounds)),
472            Self::Split {
473                orientation,
474                ratio,
475                a,
476                b,
477            } => {
478                let (ra, rb) = split_rect(bounds, *orientation, ratio.get());
479                a.lay_out(ra, out);
480                b.lay_out(rb, out);
481            }
482        }
483    }
484
485    /// The pane the operator would land on by moving `direction` from
486    /// `target`, laid out within `bounds` (tmux `select-pane -L/-R/-U/-D`).
487    /// Among every pane on that side that shares perpendicular edge with
488    /// `target`, picks the **nearest** (smallest gap along the axis of
489    /// motion), tie-broken by the **largest** shared edge. The result is
490    /// independent of tree shape and traversal order. Returns `None` at
491    /// the window edge or for an unknown `target`.
492    #[must_use]
493    pub fn neighbor(&self, target: PaneId, direction: Direction, bounds: Rect) -> Option<PaneId> {
494        let rects = self.compute_rects(bounds);
495        let me = rects.iter().find(|(p, _)| *p == target).map(|(_, r)| *r)?;
496        // Score each candidate as (gap, overlap): a candidate is better
497        // when it is closer (smaller gap) or — at equal gap — shares more
498        // edge (larger overlap). `best` holds the winning (gap, overlap).
499        let mut best: Option<(PaneId, u32, u32)> = None;
500        for (pane, r) in &rects {
501            if *pane == target {
502                continue;
503            }
504            // (on the correct side, gap along the axis, perpendicular
505            // overlap). `gap` is the clearance between the two edges
506            // facing each other; the immediate neighbour of a gap-free
507            // tiling has gap 0.
508            let (on_side, gap, overlap) = match direction {
509                Direction::Left => (
510                    r.right() <= u32::from(me.x),
511                    u32::from(me.x).saturating_sub(r.right()),
512                    Rect::span_overlap(u32::from(r.y), r.bottom(), u32::from(me.y), me.bottom()),
513                ),
514                Direction::Right => (
515                    u32::from(r.x) >= me.right(),
516                    u32::from(r.x).saturating_sub(me.right()),
517                    Rect::span_overlap(u32::from(r.y), r.bottom(), u32::from(me.y), me.bottom()),
518                ),
519                Direction::Above => (
520                    r.bottom() <= u32::from(me.y),
521                    u32::from(me.y).saturating_sub(r.bottom()),
522                    Rect::span_overlap(u32::from(r.x), r.right(), u32::from(me.x), me.right()),
523                ),
524                Direction::Below => (
525                    u32::from(r.y) >= me.bottom(),
526                    u32::from(r.y).saturating_sub(me.bottom()),
527                    Rect::span_overlap(u32::from(r.x), r.right(), u32::from(me.x), me.right()),
528                ),
529            };
530            if on_side && overlap > 0 {
531                let better = match best {
532                    None => true,
533                    Some((_, best_gap, best_overlap)) => {
534                        gap < best_gap || (gap == best_gap && overlap > best_overlap)
535                    }
536                };
537                if better {
538                    best = Some((*pane, gap, overlap));
539                }
540            }
541        }
542        best.map(|(p, _, _)| p)
543    }
544
545    /// Verify the tree's structural invariants. A tree that validates is
546    /// safe to render; one that fails carries a typed [`LayoutError`]
547    /// naming the illegal shape instead of silently mis-drawing.
548    pub fn validate(&self) -> Result<(), LayoutError> {
549        let mut seen = Vec::new();
550        self.validate_into(&mut seen)
551    }
552
553    fn validate_into(&self, seen: &mut Vec<PaneId>) -> Result<(), LayoutError> {
554        match self {
555            Self::Leaf { pane } => {
556                if *pane == PaneId::NULL {
557                    return Err(LayoutError::NullLeaf);
558                }
559                if seen.contains(pane) {
560                    return Err(LayoutError::DuplicatePane(*pane));
561                }
562                seen.push(*pane);
563                Ok(())
564            }
565            Self::Split { ratio, a, b, .. } => {
566                // Defence in depth only. `SplitRatio` has no constructor that
567                // yields a value outside `[MIN_RATIO, 1 - MIN_RATIO]`, and
568                // its `Deserialize` routes through that constructor, so this
569                // branch is now UNREACHABLE from any real tree — kept so a
570                // future change that widens the refinement is caught here
571                // rather than in `split_extent`.
572                let r = ratio.get();
573                if !(r > 0.0 && r < 1.0) {
574                    return Err(LayoutError::BadRatio(r));
575                }
576                a.validate_into(seen)?;
577                b.validate_into(seen)
578            }
579        }
580    }
581}
582
583/// Divide `bounds` between a split's two children. Horizontal splits
584/// stack rows (divide the height); vertical splits sit side by side
585/// (divide the width). Side `a` gets a clamped extent, side `b` the
586/// exact remainder — so the two rects tile `bounds` with no gap and no
587/// overlap.
588fn split_rect(bounds: Rect, orientation: SplitOrientation, ratio: f32) -> (Rect, Rect) {
589    match orientation {
590        SplitOrientation::Horizontal => {
591            let a_h = split_extent(bounds.h, ratio);
592            let b_h = bounds.h - a_h;
593            (
594                Rect::new(bounds.x, bounds.y, bounds.w, a_h),
595                Rect::new(bounds.x, bounds.y + a_h, bounds.w, b_h),
596            )
597        }
598        SplitOrientation::Vertical => {
599            let a_w = split_extent(bounds.w, ratio);
600            let b_w = bounds.w - a_w;
601            (
602                Rect::new(bounds.x, bounds.y, a_w, bounds.h),
603                Rect::new(bounds.x + a_w, bounds.y, b_w, bounds.h),
604            )
605        }
606    }
607}
608
609/// The number of cells side `a` gets from `total` at `ratio`. Clamped so
610/// both sides keep ≥1 cell whenever `total >= 2`; a 1-cell total can't be
611/// divided (side `a` takes it, `b` gets 0) and a 0-cell total yields 0.
612fn split_extent(total: u16, ratio: f32) -> u16 {
613    if total <= 1 {
614        return total;
615    }
616    let raw = (f32::from(total) * ratio).round();
617    // round() of a finite product in [0, total] is in range; clamp pins
618    // NaN-free bounds and guarantees neither side vanishes.
619    let a = raw.clamp(1.0, f32::from(total) - 1.0);
620    a as u16
621}
622
623/// Wrap each pane id in a leaf node.
624fn leaves(panes: &[PaneId]) -> Vec<LayoutNode> {
625    panes.iter().map(|p| LayoutNode::leaf(*p)).collect()
626}
627
628/// Fold a slice of subtrees into an *even* chain along `orientation`: the
629/// first child gets `1/n` of the area and the remainder recurses, so —
630/// because each level's `1/(n-k)` is taken of the previous remainder —
631/// every leaf ends with exactly `1/n`. `None` for an empty slice.
632fn even_chain(orientation: SplitOrientation, nodes: &[LayoutNode]) -> Option<LayoutNode> {
633    match nodes {
634        [] => None,
635        [single] => Some(single.clone()),
636        [first, rest @ ..] => {
637            let n = nodes.len() as f32;
638            let rest_tree = even_chain(orientation, rest)?;
639            Some(LayoutNode::Split {
640                orientation,
641                ratio: SplitRatio::new(1.0 / n),
642                a: Box::new(first.clone()),
643                b: Box::new(rest_tree),
644            })
645        }
646    }
647}
648
649/// Arrange panes in an approximate square grid: `ceil(sqrt(n))` rows, each
650/// an even row (side by side), the rows stacked evenly. `None` for empty.
651fn tiled(panes: &[PaneId]) -> Option<LayoutNode> {
652    let n = panes.len();
653    if n == 0 {
654        return None;
655    }
656    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
657    let rows = (n as f64).sqrt().ceil() as usize;
658    let per = n / rows;
659    let extra = n % rows;
660    let mut row_trees = Vec::with_capacity(rows);
661    let mut i = 0;
662    for r in 0..rows {
663        // The first `extra` rows carry one more pane than the rest.
664        let cnt = per + usize::from(r < extra);
665        let row = even_chain(SplitOrientation::Vertical, &leaves(&panes[i..i + cnt]))?;
666        row_trees.push(row);
667        i += cnt;
668    }
669    even_chain(SplitOrientation::Horizontal, &row_trees)
670}
671
672/// Named tmux-style layout presets. tmux ships five built-ins; tear
673/// supports the same plus a `tatami`-style auto-balance.
674#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
675#[serde(rename_all = "kebab-case")]
676pub enum LayoutKind {
677    /// All panes in one horizontal row.
678    EvenHorizontal,
679    /// All panes in one vertical column.
680    EvenVertical,
681    /// One large pane on top, all others on the bottom row.
682    MainHorizontal,
683    /// One large pane on the left, all others stacked on the right.
684    MainVertical,
685    /// Tiled: arrange panes in an approximate square grid.
686    Tiled,
687    /// Custom: the [`LayoutNode`] tree on the [`crate::TearWindow`] is
688    /// the source of truth. Operators reach this state after manual
689    /// splits / resizes; tear-core serialises the tree as the canonical
690    /// shape.
691    Custom,
692}
693
694/// Size specification for a pane within a layout.
695#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
696#[serde(rename_all = "lowercase")]
697pub enum Size {
698    /// Fixed number of terminal cells.
699    Cells(u16),
700    /// Fraction of the parent's available space (`0.0..=1.0`).
701    Fraction(f32),
702    /// Automatic — the parent layout decides.
703    Auto,
704}
705
706impl Default for Size {
707    fn default() -> Self {
708        Self::Auto
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use crate::direction::SplitOrientation;
716
717    #[test]
718    fn leaf_has_one_pane() {
719        let n = LayoutNode::leaf(PaneId(7));
720        assert_eq!(n.pane_count(), 1);
721        assert_eq!(n.panes(), vec![PaneId(7)]);
722    }
723
724    #[test]
725    fn split_aggregates_panes_left_then_right() {
726        let n = LayoutNode::split(
727            SplitOrientation::Vertical,
728            LayoutNode::leaf(PaneId(1)),
729            LayoutNode::leaf(PaneId(2)),
730        );
731        assert_eq!(n.pane_count(), 2);
732        assert_eq!(n.panes(), vec![PaneId(1), PaneId(2)]);
733    }
734
735    #[test]
736    fn nested_split_traversal_is_predictable() {
737        let n = LayoutNode::split(
738            SplitOrientation::Horizontal,
739            LayoutNode::leaf(PaneId(1)),
740            LayoutNode::split(
741                SplitOrientation::Vertical,
742                LayoutNode::leaf(PaneId(2)),
743                LayoutNode::leaf(PaneId(3)),
744            ),
745        );
746        assert_eq!(n.panes(), vec![PaneId(1), PaneId(2), PaneId(3)]);
747        assert_eq!(n.pane_count(), 3);
748    }
749
750    // ── split_leaf ───────────────────────────────────────────────
751
752    #[test]
753    fn split_leaf_targets_the_matched_leaf_only() {
754        // Tree: 1 | (2 / 3). Splitting pane 2 to the right must touch
755        // ONLY pane 2's slot — panes 1 and 3 keep their positions. This
756        // is the bug the old whole-window re-wrap had.
757        let mut n = LayoutNode::split(
758            SplitOrientation::Vertical,
759            LayoutNode::leaf(PaneId(1)),
760            LayoutNode::split(
761                SplitOrientation::Horizontal,
762                LayoutNode::leaf(PaneId(2)),
763                LayoutNode::leaf(PaneId(3)),
764            ),
765        );
766        assert!(n.split_leaf(PaneId(2), PaneId(9), Direction::Right, 0.5));
767        // Display order: 1, then (2-split-9), then 3.
768        assert_eq!(n.panes(), vec![PaneId(1), PaneId(2), PaneId(9), PaneId(3)]);
769        assert_eq!(n.pane_count(), 4);
770        n.validate().unwrap();
771    }
772
773    #[test]
774    fn split_leaf_orders_new_pane_by_direction() {
775        let mut right = LayoutNode::leaf(PaneId(1));
776        right.split_leaf(PaneId(1), PaneId(2), Direction::Right, 0.5);
777        assert_eq!(right.panes(), vec![PaneId(1), PaneId(2)]); // origin first
778
779        let mut left = LayoutNode::leaf(PaneId(1));
780        left.split_leaf(PaneId(1), PaneId(2), Direction::Left, 0.5);
781        assert_eq!(left.panes(), vec![PaneId(2), PaneId(1)]); // new first
782    }
783
784    #[test]
785    fn split_leaf_unknown_target_is_noop() {
786        let mut n = LayoutNode::leaf(PaneId(1));
787        assert!(!n.split_leaf(PaneId(99), PaneId(2), Direction::Right, 0.5));
788        assert_eq!(n.panes(), vec![PaneId(1)]);
789    }
790
791    #[test]
792    fn split_leaf_clamps_extreme_ratio() {
793        let mut n = LayoutNode::leaf(PaneId(1));
794        n.split_leaf(PaneId(1), PaneId(2), Direction::Right, 0.0);
795        // 0.0 origin-keep clamps to MIN_RATIO — still a valid split.
796        n.validate().unwrap();
797        if let LayoutNode::Split { ratio, .. } = n {
798            assert!(ratio.get() >= MIN_RATIO && ratio.get() <= 1.0 - MIN_RATIO);
799        } else {
800            panic!("expected a split");
801        }
802    }
803
804    // ── remove_leaf ──────────────────────────────────────────────
805
806    #[test]
807    fn remove_leaf_collapses_parent_into_sibling() {
808        // (1 | 2) → remove 1 → just 2 (no dangling NULL).
809        let mut n = LayoutNode::split(
810            SplitOrientation::Vertical,
811            LayoutNode::leaf(PaneId(1)),
812            LayoutNode::leaf(PaneId(2)),
813        );
814        assert_eq!(n.remove_leaf(PaneId(1)), LeafRemoval::Removed);
815        assert_eq!(n, LayoutNode::leaf(PaneId(2)));
816        n.validate().unwrap();
817    }
818
819    #[test]
820    fn remove_leaf_collapses_deep_node() {
821        // 1 | (2 / 3) → remove 3 → 1 | 2.
822        let mut n = LayoutNode::split(
823            SplitOrientation::Vertical,
824            LayoutNode::leaf(PaneId(1)),
825            LayoutNode::split(
826                SplitOrientation::Horizontal,
827                LayoutNode::leaf(PaneId(2)),
828                LayoutNode::leaf(PaneId(3)),
829            ),
830        );
831        assert_eq!(n.remove_leaf(PaneId(3)), LeafRemoval::Removed);
832        assert_eq!(n.panes(), vec![PaneId(1), PaneId(2)]);
833        assert!(!n.contains_pane(PaneId(3)));
834        // No PaneId::NULL leaf left behind.
835        n.validate().unwrap();
836    }
837
838    #[test]
839    fn remove_leaf_root_reports_was_root() {
840        let mut n = LayoutNode::leaf(PaneId(1));
841        assert_eq!(n.remove_leaf(PaneId(1)), LeafRemoval::WasRoot);
842        // Tree untouched — caller closes the window instead.
843        assert_eq!(n, LayoutNode::leaf(PaneId(1)));
844    }
845
846    #[test]
847    fn remove_leaf_unknown_is_not_found() {
848        let mut n = LayoutNode::split(
849            SplitOrientation::Vertical,
850            LayoutNode::leaf(PaneId(1)),
851            LayoutNode::leaf(PaneId(2)),
852        );
853        assert_eq!(n.remove_leaf(PaneId(99)), LeafRemoval::NotFound);
854        assert_eq!(n.pane_count(), 2);
855    }
856
857    // ── compute_rects ────────────────────────────────────────────
858
859    #[test]
860    fn compute_rects_single_pane_fills_bounds() {
861        let n = LayoutNode::leaf(PaneId(1));
862        let r = n.compute_rects(Rect::sized(80, 24));
863        assert_eq!(r, vec![(PaneId(1), Rect::new(0, 0, 80, 24))]);
864    }
865
866    #[test]
867    fn compute_rects_vertical_split_is_side_by_side_gapless() {
868        // Vertical split divides width; a left, b right; no gap/overlap.
869        let n = LayoutNode::split(
870            SplitOrientation::Vertical,
871            LayoutNode::leaf(PaneId(1)),
872            LayoutNode::leaf(PaneId(2)),
873        );
874        let r = n.compute_rects(Rect::sized(80, 24));
875        let a = r.iter().find(|(p, _)| *p == PaneId(1)).unwrap().1;
876        let b = r.iter().find(|(p, _)| *p == PaneId(2)).unwrap().1;
877        assert_eq!(a, Rect::new(0, 0, 40, 24));
878        assert_eq!(b, Rect::new(40, 0, 40, 24));
879        assert_eq!(a.right(), u32::from(b.x)); // gapless
880    }
881
882    #[test]
883    fn compute_rects_horizontal_split_stacks_rows() {
884        let n = LayoutNode::split(
885            SplitOrientation::Horizontal,
886            LayoutNode::leaf(PaneId(1)),
887            LayoutNode::leaf(PaneId(2)),
888        );
889        let r = n.compute_rects(Rect::sized(80, 24));
890        let a = r.iter().find(|(p, _)| *p == PaneId(1)).unwrap().1;
891        let b = r.iter().find(|(p, _)| *p == PaneId(2)).unwrap().1;
892        assert_eq!(a, Rect::new(0, 0, 80, 12));
893        assert_eq!(b, Rect::new(0, 12, 80, 12));
894        assert_eq!(a.bottom(), u32::from(b.y));
895    }
896
897    #[test]
898    fn compute_rects_tiles_bounds_exactly() {
899        // The union of all leaf rects must equal bounds.area() with no
900        // double-counting — proves gap-free + overlap-free for a nested
901        // tree at an odd size (forces rounding).
902        let n = LayoutNode::split(
903            SplitOrientation::Vertical,
904            LayoutNode::leaf(PaneId(1)),
905            LayoutNode::split(
906                SplitOrientation::Horizontal,
907                LayoutNode::leaf(PaneId(2)),
908                LayoutNode::leaf(PaneId(3)),
909            ),
910        );
911        let bounds = Rect::sized(81, 25);
912        let rects = n.compute_rects(bounds);
913        let total: u32 = rects.iter().map(|(_, r)| r.area()).sum();
914        assert_eq!(total, bounds.area());
915        // No overlap: every interior cell belongs to exactly one rect.
916        for y in 0..bounds.h {
917            for x in 0..bounds.w {
918                let owners = rects.iter().filter(|(_, r)| r.contains(x, y)).count();
919                assert_eq!(owners, 1, "cell ({x},{y}) owned by {owners} panes");
920            }
921        }
922    }
923
924    #[test]
925    fn compute_rects_tiny_window_never_panics() {
926        let n = LayoutNode::split(
927            SplitOrientation::Vertical,
928            LayoutNode::leaf(PaneId(1)),
929            LayoutNode::leaf(PaneId(2)),
930        );
931        // 1-cell width can't show two panes side-by-side; b is squeezed
932        // to 0 but the call is total and gapless.
933        let r = n.compute_rects(Rect::sized(1, 5));
934        let total: u32 = r.iter().map(|(_, rr)| rr.area()).sum();
935        assert_eq!(total, 5);
936    }
937
938    // ── neighbor ─────────────────────────────────────────────────
939
940    #[test]
941    fn neighbor_walks_left_and_right() {
942        // 1 | 2 | 3 (three columns).
943        let n = LayoutNode::split(
944            SplitOrientation::Vertical,
945            LayoutNode::leaf(PaneId(1)),
946            LayoutNode::split(
947                SplitOrientation::Vertical,
948                LayoutNode::leaf(PaneId(2)),
949                LayoutNode::leaf(PaneId(3)),
950            ),
951        );
952        let b = Rect::sized(90, 24);
953        assert_eq!(n.neighbor(PaneId(2), Direction::Left, b), Some(PaneId(1)));
954        assert_eq!(n.neighbor(PaneId(2), Direction::Right, b), Some(PaneId(3)));
955        assert_eq!(n.neighbor(PaneId(1), Direction::Left, b), None); // window edge
956        assert_eq!(n.neighbor(PaneId(3), Direction::Right, b), None);
957    }
958
959    #[test]
960    fn neighbor_crosses_split_boundary_by_edge_overlap() {
961        // Left column = pane 1 (full height). Right column split into
962        // 2 (top) / 3 (bottom). From 1 going Right, the neighbour with
963        // the most shared edge is whichever covers the focal band; both
964        // 2 and 3 are valid candidates — assert it returns one of them.
965        let n = LayoutNode::split(
966            SplitOrientation::Vertical,
967            LayoutNode::leaf(PaneId(1)),
968            LayoutNode::split(
969                SplitOrientation::Horizontal,
970                LayoutNode::leaf(PaneId(2)),
971                LayoutNode::leaf(PaneId(3)),
972            ),
973        );
974        let b = Rect::sized(80, 24);
975        let right = n.neighbor(PaneId(1), Direction::Right, b);
976        assert!(matches!(right, Some(PaneId(2)) | Some(PaneId(3))));
977        // And from 2 going Left lands back on 1.
978        assert_eq!(n.neighbor(PaneId(2), Direction::Left, b), Some(PaneId(1)));
979    }
980
981    #[test]
982    fn neighbor_unknown_target_is_none() {
983        let n = LayoutNode::leaf(PaneId(1));
984        assert_eq!(n.neighbor(PaneId(99), Direction::Left, Rect::sized(80, 24)), None);
985    }
986
987    #[test]
988    fn neighbor_prefers_nearest_not_just_max_overlap() {
989        // 1 | 2 | 3 — from 1, both 2 and 3 are to the Right with equal
990        // (full-height) overlap. The NEAREST (2) must win regardless of
991        // how the tree nests 2 and 3 (right-leaning vs left-leaning).
992        let right_leaning = LayoutNode::split(
993            SplitOrientation::Vertical,
994            LayoutNode::leaf(PaneId(1)),
995            LayoutNode::split(
996                SplitOrientation::Vertical,
997                LayoutNode::leaf(PaneId(2)),
998                LayoutNode::leaf(PaneId(3)),
999            ),
1000        );
1001        let left_leaning = LayoutNode::split(
1002            SplitOrientation::Vertical,
1003            LayoutNode::split(
1004                SplitOrientation::Vertical,
1005                LayoutNode::leaf(PaneId(1)),
1006                LayoutNode::leaf(PaneId(2)),
1007            ),
1008            LayoutNode::leaf(PaneId(3)),
1009        );
1010        let b = Rect::sized(90, 24);
1011        assert_eq!(right_leaning.neighbor(PaneId(1), Direction::Right, b), Some(PaneId(2)));
1012        assert_eq!(left_leaning.neighbor(PaneId(1), Direction::Right, b), Some(PaneId(2)));
1013    }
1014
1015    // ── from_kind ────────────────────────────────────────────────
1016
1017    #[test]
1018    fn from_kind_empty_and_custom_are_none() {
1019        assert_eq!(LayoutNode::from_kind(LayoutKind::Tiled, &[]), None);
1020        assert_eq!(
1021            LayoutNode::from_kind(LayoutKind::Custom, &[PaneId(1), PaneId(2)]),
1022            None
1023        );
1024    }
1025
1026    #[test]
1027    fn from_kind_single_pane_is_a_leaf_for_every_kind() {
1028        for kind in [
1029            LayoutKind::EvenHorizontal,
1030            LayoutKind::EvenVertical,
1031            LayoutKind::MainHorizontal,
1032            LayoutKind::MainVertical,
1033            LayoutKind::Tiled,
1034        ] {
1035            assert_eq!(
1036                LayoutNode::from_kind(kind, &[PaneId(1)]),
1037                Some(LayoutNode::leaf(PaneId(1)))
1038            );
1039        }
1040    }
1041
1042    #[test]
1043    fn from_kind_even_horizontal_gives_equal_thirds() {
1044        // The even-ratio refinement: 3 panes in a row are three EQUAL
1045        // widths, not a 50/25/25 balanced binary split.
1046        let panes = [PaneId(1), PaneId(2), PaneId(3)];
1047        let tree = LayoutNode::from_kind(LayoutKind::EvenHorizontal, &panes).unwrap();
1048        tree.validate().unwrap();
1049        let rects = tree.compute_rects(Rect::sized(90, 24));
1050        let mut widths: Vec<u16> = rects.iter().map(|(_, r)| r.w).collect();
1051        widths.sort_unstable();
1052        // 90/3 = 30 each (exactly, since 90 is divisible by 3).
1053        assert_eq!(widths, vec![30, 30, 30]);
1054    }
1055
1056    /// The first-brick proof: for EVERY non-Custom kind and pane-count
1057    /// 1..=7, `from_kind` produces a tree that validates AND whose
1058    /// `compute_rects` tiles its bounds exactly (the shipped tiling
1059    /// invariant) — gap-free, overlap-free, every pane present once.
1060    #[test]
1061    fn from_kind_every_preset_validates_and_tiles_exactly() {
1062        let kinds = [
1063            LayoutKind::EvenHorizontal,
1064            LayoutKind::EvenVertical,
1065            LayoutKind::MainHorizontal,
1066            LayoutKind::MainVertical,
1067            LayoutKind::Tiled,
1068        ];
1069        for kind in kinds {
1070            for n in 1..=7usize {
1071                let panes: Vec<PaneId> = (1..=n as u64).map(PaneId).collect();
1072                let tree = LayoutNode::from_kind(kind, &panes)
1073                    .unwrap_or_else(|| panic!("{kind:?} n={n} produced None"));
1074                // Structurally sound + every pane present exactly once.
1075                tree.validate()
1076                    .unwrap_or_else(|e| panic!("{kind:?} n={n} invalid: {e:?}"));
1077                assert_eq!(tree.pane_count(), n, "{kind:?} n={n} pane count");
1078                assert_eq!(tree.panes().len(), n);
1079                // Tiles bounds exactly at a rounding-forcing size.
1080                for &(w, h) in &[(80u16, 24u16), (81, 25), (97, 31)] {
1081                    let bounds = Rect::sized(w, h);
1082                    let rects = tree.compute_rects(bounds);
1083                    let total: u32 = rects.iter().map(|(_, r)| r.area()).sum();
1084                    assert_eq!(total, bounds.area(), "{kind:?} n={n} at {w}x{h}");
1085                }
1086            }
1087        }
1088    }
1089
1090    #[test]
1091    fn from_kind_main_vertical_keeps_main_on_the_left() {
1092        let panes = [PaneId(1), PaneId(2), PaneId(3)];
1093        let tree = LayoutNode::from_kind(LayoutKind::MainVertical, &panes).unwrap();
1094        // Outer split is vertical (left|right), side a is the main leaf.
1095        match &tree {
1096            LayoutNode::Split { orientation, a, .. } => {
1097                assert_eq!(*orientation, SplitOrientation::Vertical);
1098                assert_eq!(a.as_ref(), &LayoutNode::leaf(PaneId(1)));
1099            }
1100            LayoutNode::Leaf { .. } => panic!("expected a split"),
1101        }
1102        // The main pane is the leftmost on screen.
1103        let rects = tree.compute_rects(Rect::sized(80, 24));
1104        let main = rects.iter().find(|(p, _)| *p == PaneId(1)).unwrap().1;
1105        assert_eq!(main.x, 0);
1106    }
1107
1108    /// A pseudo-property test: across a spread of tree shapes and bounds
1109    /// (including odd sizes that force rounding), `compute_rects` must
1110    /// tile `bounds` exactly — total area equals `bounds.area()` and no
1111    /// interior cell is owned by two panes. This is the load-bearing
1112    /// invariant both apps depend on (no seams, no double-draws).
1113    #[test]
1114    fn compute_rects_tiles_exactly_across_shapes_and_sizes() {
1115        let shapes = [
1116            LayoutNode::leaf(PaneId(1)),
1117            LayoutNode::split(
1118                SplitOrientation::Vertical,
1119                LayoutNode::leaf(PaneId(1)),
1120                LayoutNode::leaf(PaneId(2)),
1121            ),
1122            LayoutNode::split(
1123                SplitOrientation::Horizontal,
1124                LayoutNode::split(
1125                    SplitOrientation::Vertical,
1126                    LayoutNode::leaf(PaneId(1)),
1127                    LayoutNode::leaf(PaneId(2)),
1128                ),
1129                LayoutNode::split(
1130                    SplitOrientation::Vertical,
1131                    LayoutNode::leaf(PaneId(3)),
1132                    LayoutNode::split(
1133                        SplitOrientation::Horizontal,
1134                        LayoutNode::leaf(PaneId(4)),
1135                        LayoutNode::leaf(PaneId(5)),
1136                    ),
1137                ),
1138            ),
1139        ];
1140        for shape in &shapes {
1141            for &(w, h) in &[(80u16, 24u16), (81, 25), (1, 1), (3, 200), (200, 3), (2, 2)] {
1142                let bounds = Rect::sized(w, h);
1143                let rects = shape.compute_rects(bounds);
1144                let total: u32 = rects.iter().map(|(_, r)| r.area()).sum();
1145                assert_eq!(total, bounds.area(), "area mismatch at {w}x{h}");
1146                // Every leaf appears exactly once.
1147                assert_eq!(rects.len(), shape.pane_count());
1148                // No interior cell double-owned (sample the full grid for
1149                // small bounds; the area equality covers the rest).
1150                if bounds.area() <= 8192 {
1151                    for y in 0..h {
1152                        for x in 0..w {
1153                            let owners = rects.iter().filter(|(_, r)| r.contains(x, y)).count();
1154                            assert!(owners <= 1, "cell ({x},{y}) owned by {owners} at {w}x{h}");
1155                        }
1156                    }
1157                }
1158            }
1159        }
1160    }
1161
1162    // ── resize_leaf ──────────────────────────────────────────────
1163
1164    #[test]
1165    fn resize_leaf_grows_focused_pane_rightward() {
1166        // 1 | 2, vertical. "grow 1 to the Right" enlarges pane 1.
1167        let mut n = LayoutNode::split(
1168            SplitOrientation::Vertical,
1169            LayoutNode::leaf(PaneId(1)),
1170            LayoutNode::leaf(PaneId(2)),
1171        );
1172        let before = n.compute_rects(Rect::sized(80, 24));
1173        let w1_before = before.iter().find(|(p, _)| *p == PaneId(1)).unwrap().1.w;
1174        assert!(n.resize_leaf(PaneId(1), Direction::Right, 0.2));
1175        let after = n.compute_rects(Rect::sized(80, 24));
1176        let w1_after = after.iter().find(|(p, _)| *p == PaneId(1)).unwrap().1.w;
1177        assert!(w1_after > w1_before, "{w1_after} !> {w1_before}");
1178    }
1179
1180    #[test]
1181    fn resize_leaf_grows_pane_on_side_b_too() {
1182        // 1 | 2; "grow 2 to the Left" enlarges pane 2 (side b).
1183        let mut n = LayoutNode::split(
1184            SplitOrientation::Vertical,
1185            LayoutNode::leaf(PaneId(1)),
1186            LayoutNode::leaf(PaneId(2)),
1187        );
1188        let before = n.compute_rects(Rect::sized(80, 24));
1189        let w2_before = before.iter().find(|(p, _)| *p == PaneId(2)).unwrap().1.w;
1190        assert!(n.resize_leaf(PaneId(2), Direction::Left, 0.2));
1191        let after = n.compute_rects(Rect::sized(80, 24));
1192        let w2_after = after.iter().find(|(p, _)| *p == PaneId(2)).unwrap().1.w;
1193        assert!(w2_after > w2_before, "{w2_after} !> {w2_before}");
1194    }
1195
1196    #[test]
1197    fn resize_leaf_grows_toward_outer_neighbour_across_a_deeper_split() {
1198        // 1 | (2 | 3): pane 2 is the LEFT child of the inner split, but
1199        // its on-screen left neighbour is pane 1 across the OUTER split.
1200        // "grow 2 Left" must enlarge pane 2 (regression: the old sign-only
1201        // logic slid the inner 2|3 divider and SHRANK pane 2).
1202        let mut n = LayoutNode::split(
1203            SplitOrientation::Vertical,
1204            LayoutNode::leaf(PaneId(1)),
1205            LayoutNode::split(
1206                SplitOrientation::Vertical,
1207                LayoutNode::leaf(PaneId(2)),
1208                LayoutNode::leaf(PaneId(3)),
1209            ),
1210        );
1211        let b = Rect::sized(90, 24);
1212        let w2_before = n.compute_rects(b).iter().find(|(p, _)| *p == PaneId(2)).unwrap().1.w;
1213        assert!(n.resize_leaf(PaneId(2), Direction::Left, 0.2));
1214        let w2_after = n.compute_rects(b).iter().find(|(p, _)| *p == PaneId(2)).unwrap().1.w;
1215        assert!(w2_after > w2_before, "grow-Left should enlarge pane 2: {w2_before} -> {w2_after}");
1216    }
1217
1218    #[test]
1219    fn resize_leaf_side_b_of_deeper_split_grows_right_toward_outer_neighbour() {
1220        // (1 | 2) | 3: pane 2 is the RIGHT child of the inner split; its
1221        // on-screen right neighbour is pane 3 across the OUTER split.
1222        // "grow 2 Right" must enlarge pane 2 (the symmetric regression).
1223        let mut n = LayoutNode::split(
1224            SplitOrientation::Vertical,
1225            LayoutNode::split(
1226                SplitOrientation::Vertical,
1227                LayoutNode::leaf(PaneId(1)),
1228                LayoutNode::leaf(PaneId(2)),
1229            ),
1230            LayoutNode::leaf(PaneId(3)),
1231        );
1232        let b = Rect::sized(90, 24);
1233        let w2_before = n.compute_rects(b).iter().find(|(p, _)| *p == PaneId(2)).unwrap().1.w;
1234        assert!(n.resize_leaf(PaneId(2), Direction::Right, 0.2));
1235        let w2_after = n.compute_rects(b).iter().find(|(p, _)| *p == PaneId(2)).unwrap().1.w;
1236        assert!(w2_after > w2_before, "grow-Right should enlarge pane 2: {w2_before} -> {w2_after}");
1237    }
1238
1239    #[test]
1240    fn resize_leaf_no_neighbour_that_way_is_noop() {
1241        // 1 | 2: pane 1 is leftmost — it has no left neighbour, so
1242        // "grow 1 Left" finds no governing divider and is a no-op.
1243        let mut n = LayoutNode::split(
1244            SplitOrientation::Vertical,
1245            LayoutNode::leaf(PaneId(1)),
1246            LayoutNode::leaf(PaneId(2)),
1247        );
1248        assert!(!n.resize_leaf(PaneId(1), Direction::Left, 0.2));
1249    }
1250
1251    #[test]
1252    fn resize_leaf_grows_focused_pane_in_all_four_directions() {
1253        // Exhaustive sign coverage. For each direction, build a layout
1254        // where the focused pane HAS a neighbour that way, and assert the
1255        // focused pane grows. This pins every (side × direction) sign
1256        // combination — the bug the original 3 tests missed.
1257        let cases: &[(SplitOrientation, Direction)] = &[
1258            (SplitOrientation::Vertical, Direction::Right),
1259            (SplitOrientation::Vertical, Direction::Left),
1260            (SplitOrientation::Horizontal, Direction::Below),
1261            (SplitOrientation::Horizontal, Direction::Above),
1262        ];
1263        for &(orient, dir) in cases {
1264            // Two-pane split on the matching axis; focus the pane that has
1265            // a neighbour in `dir` (the `a`-side pane for Right/Below, the
1266            // `b`-side pane for Left/Above).
1267            let (focus, other) = match dir {
1268                Direction::Right | Direction::Below => (PaneId(1), PaneId(2)),
1269                Direction::Left | Direction::Above => (PaneId(2), PaneId(1)),
1270            };
1271            let mut n = LayoutNode::split(
1272                orient,
1273                LayoutNode::leaf(PaneId(1)),
1274                LayoutNode::leaf(PaneId(2)),
1275            );
1276            let b = Rect::sized(80, 24);
1277            let axis = |r: Rect| if orient == SplitOrientation::Vertical { r.w } else { r.h };
1278            let before = axis(n.compute_rects(b).iter().find(|(p, _)| *p == focus).unwrap().1);
1279            assert!(n.resize_leaf(focus, dir, 0.2), "{dir:?} should find a divider");
1280            let after = axis(n.compute_rects(b).iter().find(|(p, _)| *p == focus).unwrap().1);
1281            assert!(after > before, "focus pane should grow {dir:?}: {before} -> {after}");
1282            let _ = other;
1283        }
1284    }
1285
1286    #[test]
1287    fn split_leaf_nan_ratio_coerces_to_valid_split() {
1288        // A NaN ratio survives f32::clamp; split_leaf must coerce it so the
1289        // resulting tree still validates (its doc promises a clamped ratio).
1290        let mut n = LayoutNode::leaf(PaneId(1));
1291        assert!(n.split_leaf(PaneId(1), PaneId(2), Direction::Right, f32::NAN));
1292        n.validate().unwrap();
1293    }
1294
1295    #[test]
1296    fn resize_leaf_nan_delta_leaves_tree_valid() {
1297        let mut n = LayoutNode::split(
1298            SplitOrientation::Vertical,
1299            LayoutNode::leaf(PaneId(1)),
1300            LayoutNode::leaf(PaneId(2)),
1301        );
1302        // A NaN delta must not poison the ratio.
1303        n.resize_leaf(PaneId(1), Direction::Right, f32::NAN);
1304        n.validate().unwrap();
1305    }
1306
1307    #[test]
1308    fn resize_leaf_ignores_wrong_axis() {
1309        // 1 | 2 vertical; resizing Up/Down finds no horizontal split.
1310        let mut n = LayoutNode::split(
1311            SplitOrientation::Vertical,
1312            LayoutNode::leaf(PaneId(1)),
1313            LayoutNode::leaf(PaneId(2)),
1314        );
1315        assert!(!n.resize_leaf(PaneId(1), Direction::Below, 0.2));
1316    }
1317
1318    #[test]
1319    fn resize_leaf_picks_deepest_governing_split() {
1320        // (1 | 2) stacked-over 3 — both a vertical and an enclosing
1321        // horizontal split exist. Resizing 1 to the Right must adjust
1322        // the inner VERTICAL split (1|2), not the outer horizontal one.
1323        let mut n = LayoutNode::split(
1324            SplitOrientation::Horizontal,
1325            LayoutNode::split(
1326                SplitOrientation::Vertical,
1327                LayoutNode::leaf(PaneId(1)),
1328                LayoutNode::leaf(PaneId(2)),
1329            ),
1330            LayoutNode::leaf(PaneId(3)),
1331        );
1332        let bounds = Rect::sized(80, 24);
1333        let w1_before = n.compute_rects(bounds).iter().find(|(p, _)| *p == PaneId(1)).unwrap().1.w;
1334        let h3_before = n.compute_rects(bounds).iter().find(|(p, _)| *p == PaneId(3)).unwrap().1.h;
1335        assert!(n.resize_leaf(PaneId(1), Direction::Right, 0.2));
1336        let after = n.compute_rects(bounds);
1337        let w1_after = after.iter().find(|(p, _)| *p == PaneId(1)).unwrap().1.w;
1338        let h3_after = after.iter().find(|(p, _)| *p == PaneId(3)).unwrap().1.h;
1339        assert!(w1_after > w1_before); // inner split moved
1340        assert_eq!(h3_after, h3_before); // outer split untouched
1341    }
1342
1343    // ── validate ─────────────────────────────────────────────────
1344
1345    #[test]
1346    fn validate_rejects_null_leaf() {
1347        let n = LayoutNode::leaf(PaneId::NULL);
1348        assert_eq!(n.validate(), Err(LayoutError::NullLeaf));
1349    }
1350
1351    #[test]
1352    fn validate_rejects_duplicate_pane() {
1353        let n = LayoutNode::split(
1354            SplitOrientation::Vertical,
1355            LayoutNode::leaf(PaneId(5)),
1356            LayoutNode::leaf(PaneId(5)),
1357        );
1358        assert_eq!(n.validate(), Err(LayoutError::DuplicatePane(PaneId(5))));
1359    }
1360
1361    /// This test used to construct `ratio: 0.0` and assert `validate()`
1362    /// returned `BadRatio`. **That tree can no longer be written** — the
1363    /// field is a `SplitRatio` whose only constructor refines its input, so
1364    /// the degenerate value has no representation and the runtime check it
1365    /// used to exercise is unreachable.
1366    ///
1367    /// The tier moved from *validated* to *unrepresentable*, so the test
1368    /// moves with it: it now proves the refinement instead of the rejection.
1369    #[test]
1370    fn a_degenerate_ratio_has_no_representation() {
1371        for bad in [0.0, 1.0, -3.0, 42.0] {
1372            let r = SplitRatio::new(bad);
1373            assert!(
1374                r.get() >= MIN_RATIO && r.get() <= 1.0 - MIN_RATIO,
1375                "{bad} must refine into range, got {}",
1376                r.get()
1377            );
1378        }
1379        // And a tree built from them still validates, because there is no
1380        // way to put the bad value in.
1381        let n = LayoutNode::Split {
1382            orientation: SplitOrientation::Vertical,
1383            ratio: SplitRatio::new(0.0),
1384            a: Box::new(LayoutNode::leaf(PaneId(1))),
1385            b: Box::new(LayoutNode::leaf(PaneId(2))),
1386        };
1387        n.validate().expect("a refined ratio always validates");
1388    }
1389
1390    /// The silent one. `NaN` used to pass straight through `f32::clamp`
1391    /// (which returns NaN for NaN input rather than panicking), and
1392    /// `NaN as u16` saturates to `0` — so a NaN ratio did not error, it
1393    /// silently gave one side ZERO cells and the pane vanished.
1394    #[test]
1395    fn a_nan_ratio_cannot_reach_the_geometry() {
1396        assert_eq!(SplitRatio::new(f32::NAN).get(), SplitRatio::BALANCED.get());
1397        assert_eq!(SplitRatio::new(f32::INFINITY).get(), SplitRatio::BALANCED.get());
1398        assert_eq!(
1399            SplitRatio::new(f32::NEG_INFINITY).get(),
1400            SplitRatio::BALANCED.get()
1401        );
1402
1403        // The end-to-end consequence: both sides get real cells.
1404        let n = LayoutNode::Split {
1405            orientation: SplitOrientation::Vertical,
1406            ratio: SplitRatio::new(f32::NAN),
1407            a: Box::new(LayoutNode::leaf(PaneId(1))),
1408            b: Box::new(LayoutNode::leaf(PaneId(2))),
1409        };
1410        let rects = n.compute_rects(Rect::sized(80, 24));
1411        assert_eq!(rects.len(), 2);
1412        for (pane, r) in rects {
1413            assert!(r.w > 0 && r.h > 0, "pane {pane:?} vanished: {r:?}");
1414        }
1415    }
1416
1417    /// A hostile or merely stale peer cannot put a bad ratio into a live
1418    /// tree: `Deserialize` routes through the same refinement.
1419    #[test]
1420    fn deserialisation_refines_a_hostile_ratio() {
1421        let zero: SplitRatio = serde_json::from_str("0.0").expect("deserialises");
1422        assert!(zero.get() >= MIN_RATIO, "wire value must be refined");
1423
1424        let huge: SplitRatio = serde_json::from_str("42.0").expect("deserialises");
1425        assert!(huge.get() <= 1.0 - MIN_RATIO, "wire value must be refined");
1426
1427        let neg: SplitRatio = serde_json::from_str("-3.0").expect("deserialises");
1428        assert!(neg.get() >= MIN_RATIO, "wire value must be refined");
1429    }
1430
1431    /// ★ The compatibility claim, tested rather than asserted.
1432    ///
1433    /// `SplitRatio` replaced a bare `f32` on a type that crosses the CBOR
1434    /// wire between daemon and client, and is persisted to `praca.json`. If
1435    /// the encoding changed, every already-running daemon would fail to
1436    /// talk to a new client — a flag day. `#[serde(transparent)]` is what
1437    /// prevents that, and this pins it.
1438    #[test]
1439    fn split_ratio_is_wire_identical_to_the_bare_f32_it_replaced() {
1440        let as_ratio = serde_json::to_string(&SplitRatio::new(0.25)).unwrap();
1441        let as_f32 = serde_json::to_string(&0.25_f32).unwrap();
1442        assert_eq!(
1443            as_ratio, as_f32,
1444            "SplitRatio must serialise exactly like the f32 it replaced, or \
1445             a running daemon cannot talk to a new client"
1446        );
1447
1448        // And a whole tree round-trips through the wire form unchanged.
1449        let tree = LayoutNode::Split {
1450            orientation: SplitOrientation::Vertical,
1451            ratio: SplitRatio::new(0.25),
1452            a: Box::new(LayoutNode::leaf(PaneId(1))),
1453            b: Box::new(LayoutNode::leaf(PaneId(2))),
1454        };
1455        let json = serde_json::to_string(&tree).unwrap();
1456        let back: LayoutNode = serde_json::from_str(&json).unwrap();
1457        assert_eq!(back, tree);
1458    }
1459
1460    #[test]
1461    fn validate_accepts_well_formed_tree() {
1462        let n = LayoutNode::split(
1463            SplitOrientation::Vertical,
1464            LayoutNode::leaf(PaneId(1)),
1465            LayoutNode::split(
1466                SplitOrientation::Horizontal,
1467                LayoutNode::leaf(PaneId(2)),
1468                LayoutNode::leaf(PaneId(3)),
1469            ),
1470        );
1471        n.validate().unwrap();
1472    }
1473
1474    // ── round-trip: split then remove returns to the original ────
1475
1476    #[test]
1477    fn split_then_remove_is_identity() {
1478        let original = LayoutNode::leaf(PaneId(1));
1479        let mut n = original.clone();
1480        n.split_leaf(PaneId(1), PaneId(2), Direction::Right, 0.5);
1481        assert_eq!(n.pane_count(), 2);
1482        assert_eq!(n.remove_leaf(PaneId(2)), LeafRemoval::Removed);
1483        assert_eq!(n, original);
1484    }
1485}