Skip to main content

hjkl_layout/
lib.rs

1//! Renderer-agnostic window/split layout machinery for the hjkl editor stack.
2//!
3//! A [`LayoutTree`] holds either a single [`Leaf`](LayoutTree::Leaf) (one window) or a
4//! [`Split`](LayoutTree::Split) that recursively divides space between two sub-trees.
5//! All geometry is expressed as [`LayoutRect`] — a plain `u16` rectangle that TUI
6//! hosts convert from `ratatui::layout::Rect` and GUI hosts convert from their own
7//! coordinate types at the boundary.
8//!
9//! # Quick start
10//!
11//! ```rust
12//! use hjkl_layout::{LayoutTree, SplitDir, LayoutRect};
13//!
14//! let mut tree = LayoutTree::Leaf(0);
15//! assert_eq!(tree.leaves(), vec![0]);
16//!
17//! // Split window 0 horizontally — window 1 below window 0.
18//! tree.replace_leaf(0, |id| {
19//!     LayoutTree::split(
20//!         SplitDir::Horizontal,
21//!         0.5,
22//!         LayoutTree::Leaf(id),
23//!         LayoutTree::Leaf(1),
24//!     )
25//! });
26//! assert_eq!(tree.leaves(), vec![0, 1]);
27//! assert_eq!(tree.neighbor_below(0), Some(1));
28//! assert_eq!(tree.neighbor_below(1), None);
29//! ```
30
31/// Stable id into the host window list. Never reused — new windows get the next
32/// value from the host's `next_window_id` counter.
33pub type WindowId = usize;
34
35/// Renderer-agnostic rectangle used by the layout tree.
36///
37/// TUI hosts convert `ratatui::layout::Rect` at the boundary; GUI hosts convert
38/// from their floating-point coordinate space. All fields are `u16` which matches
39/// ratatui's field types directly and is sufficient for terminal geometry (max
40/// 65535 columns/rows).
41///
42/// `#[non_exhaustive]` — additional fields may be added in minor releases.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44#[non_exhaustive]
45pub struct LayoutRect {
46    /// Column offset from the top-left corner of the terminal/window.
47    pub x: u16,
48    /// Row offset from the top-left corner of the terminal/window.
49    pub y: u16,
50    /// Width in columns.
51    pub w: u16,
52    /// Height in rows.
53    pub h: u16,
54}
55
56impl LayoutRect {
57    /// Construct a new [`LayoutRect`].
58    pub fn new(x: u16, y: u16, w: u16, h: u16) -> Self {
59        Self { x, y, w, h }
60    }
61}
62
63/// Per-tab layout + focus state.
64///
65/// Each tab owns one [`LayoutTree`] (the window arrangement within that tab)
66/// and records which window in that tree currently has focus. Windows and
67/// slots are shared across tabs — a `WindowId` refers into the host's window
68/// list regardless of which tab it lives in.
69///
70/// `#[non_exhaustive]` — additional fields may be added in minor releases.
71#[derive(Debug, Clone)]
72#[non_exhaustive]
73pub struct Tab {
74    /// Spatial layout tree for this tab. Leaves reference [`WindowId`]s.
75    pub layout: LayoutTree,
76    /// The window that has focus within this tab.
77    pub focused_window: WindowId,
78}
79
80impl Tab {
81    /// Create a new tab with the given layout and focused window.
82    pub fn new(layout: LayoutTree, focused_window: WindowId) -> Self {
83        Self {
84            layout,
85            focused_window,
86        }
87    }
88}
89
90impl Default for Tab {
91    fn default() -> Self {
92        Self {
93            layout: LayoutTree::Leaf(0),
94            focused_window: 0,
95        }
96    }
97}
98
99/// Per-window scroll + geometry state.
100///
101/// `#[non_exhaustive]` — additional fields may be added in minor releases.
102#[derive(Debug, Clone)]
103#[non_exhaustive]
104pub struct Window {
105    /// Index into the host's slot list for the buffer this window displays.
106    pub slot: usize,
107    /// The rect this window occupied in the last rendered frame.  Written
108    /// by the renderer every frame; used by direction-navigation.
109    /// `None` until the first render.
110    pub last_rect: Option<LayoutRect>,
111}
112
113impl Window {
114    /// Create a new window pointing at the given slot index.
115    ///
116    /// Per-window cursor + scroll live on the host's per-window `Editor`
117    /// (#151 Phase D) — a `Window` is pure geometry (slot + last rendered rect).
118    pub fn new(slot: usize) -> Self {
119        Self {
120            slot,
121            last_rect: None,
122        }
123    }
124}
125
126impl Default for Window {
127    fn default() -> Self {
128        Self::new(0)
129    }
130}
131
132/// Direction of a split.
133///
134/// `#[non_exhaustive]` — new directions may be added in minor releases.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136#[non_exhaustive]
137pub enum SplitDir {
138    /// Stacked top-to-bottom (`:split` / `:sp`).
139    Horizontal,
140    /// Side-by-side left-to-right (`:vsplit` / `:vsp`).
141    Vertical,
142}
143
144/// Which geometric axis a split is oriented along.
145///
146/// Returned by [`SplitDir::axis`] so consumers outside the crate can match
147/// exhaustively without bumping into the `#[non_exhaustive]` restriction on
148/// [`SplitDir`] itself.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum Axis {
151    /// The split divides space top-to-bottom (rows).
152    Row,
153    /// The split divides space left-to-right (columns).
154    Col,
155}
156
157impl SplitDir {
158    /// Map this direction to its [`Axis`].
159    ///
160    /// `Horizontal` splits stacked vertically → they separate **rows**
161    /// (`Axis::Row`). `Vertical` splits arranged side-by-side → they
162    /// separate **columns** (`Axis::Col`).
163    ///
164    /// Unknown future variants fall back to `Axis::Row` so callers always
165    /// receive a valid answer without panicking.
166    pub fn axis(self) -> Axis {
167        // `#[allow(unreachable_patterns)]` because from INSIDE this crate all
168        // variants are known; the wildcard exists so external consumers relying
169        // on `axis()` are future-proof when new variants are added.
170        #[allow(unreachable_patterns)]
171        match self {
172            Self::Horizontal => Axis::Row,
173            Self::Vertical => Axis::Col,
174            _ => Axis::Row,
175        }
176    }
177}
178
179/// An exact size for one child of a [`Split`](LayoutTree::Split), overriding
180/// that split's `ratio`.
181///
182/// The number is the child's **rendered** extent along the split axis (columns
183/// for [`SplitDir::Vertical`], rows for [`SplitDir::Horizontal`]) — the size
184/// that comes back from [`LayoutTree::window_rects`], not an allocation the
185/// separator is later taken out of. `First(30)` and `Second(30)` both render 30
186/// cells; the extra cell the separator needs is found for you.
187///
188/// This is deliberate: a dock's width comes from user config, and the caller
189/// must never have to add one to compensate for a separator it can't see.
190///
191/// The request is clamped so the sibling always keeps at least one cell — see
192/// [`LayoutTree::window_rects`] for the full geometry contract.
193///
194/// `#[non_exhaustive]` — additional variants may be added in minor releases.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196#[non_exhaustive]
197pub enum Fixed {
198    /// Render the first (top / left) child at exactly this many cells.
199    First(u16),
200    /// Render the second (bottom / right) child at exactly this many cells.
201    Second(u16),
202}
203
204/// A binary spatial tree that partitions the editor area into windows.
205///
206/// # Examples
207///
208/// ```rust
209/// use hjkl_layout::{LayoutTree, SplitDir};
210///
211/// let tree = LayoutTree::split(
212///     SplitDir::Horizontal,
213///     0.5,
214///     LayoutTree::Leaf(0),
215///     LayoutTree::Leaf(1),
216/// );
217/// assert_eq!(tree.leaves(), vec![0, 1]);
218/// assert_eq!(tree.neighbor_below(0), Some(1));
219/// ```
220#[derive(Debug, Clone)]
221#[non_exhaustive]
222pub enum LayoutTree {
223    /// A single window occupying the full allocated area.
224    Leaf(WindowId),
225    /// Two sub-trees dividing the available space.
226    Split {
227        /// Axis along which the space is divided.
228        dir: SplitDir,
229        /// Fraction of the available space allocated to `a`. `0.0 < ratio < 1.0`.
230        ///
231        /// Ignored when `fixed` is `Some` — but still retained (and still
232        /// mutated by resize commands) so that clearing `fixed` restores the
233        /// previous proportional layout.
234        ratio: f32,
235        /// Exact cell allocation for one child, overriding `ratio` when set.
236        ///
237        /// This is how a dock-style window keeps a constant width/height while
238        /// its sibling absorbs every resize.
239        fixed: Option<Fixed>,
240        /// The first (top / left) sub-tree.
241        a: Box<Self>,
242        /// The second (bottom / right) sub-tree.
243        b: Box<Self>,
244        /// Rect this split last occupied. Filled by the renderer each frame;
245        /// read by resize commands to convert line/col deltas to ratio updates.
246        /// None before the first render.
247        last_rect: Option<LayoutRect>,
248    },
249}
250
251impl Default for LayoutTree {
252    fn default() -> Self {
253        Self::Leaf(0)
254    }
255}
256
257impl LayoutTree {
258    /// Create a new single-leaf layout tree containing `id`.
259    pub fn new(id: WindowId) -> Self {
260        Self::Leaf(id)
261    }
262
263    /// Convenience constructor for an ordinary proportional split
264    /// (`fixed: None`, `last_rect: None`).
265    ///
266    /// # Examples
267    ///
268    /// ```rust
269    /// use hjkl_layout::{LayoutTree, SplitDir};
270    ///
271    /// let tree = LayoutTree::split(
272    ///     SplitDir::Vertical,
273    ///     0.5,
274    ///     LayoutTree::Leaf(0),
275    ///     LayoutTree::Leaf(1),
276    /// );
277    /// assert_eq!(tree.leaves(), vec![0, 1]);
278    /// ```
279    pub fn split(dir: SplitDir, ratio: f32, a: Self, b: Self) -> Self {
280        Self::Split {
281            dir,
282            ratio,
283            fixed: None,
284            a: Box::new(a),
285            b: Box::new(b),
286            last_rect: None,
287        }
288    }
289
290    /// Convenience constructor for a split that renders one child at a fixed
291    /// number of cells along the split axis.
292    ///
293    /// `ratio` is still stored (and used if `fixed` is later cleared); see
294    /// [`Fixed`] for the exact geometry.
295    ///
296    /// # Examples
297    ///
298    /// ```rust
299    /// use hjkl_layout::{Fixed, LayoutRect, LayoutTree, SplitDir};
300    ///
301    /// // A 30-column dock on the left, the rest for window 1.
302    /// let tree = LayoutTree::split_fixed(
303    ///     SplitDir::Vertical,
304    ///     0.5,
305    ///     Fixed::First(30),
306    ///     LayoutTree::Leaf(0),
307    ///     LayoutTree::Leaf(1),
308    /// );
309    /// let rects = tree.window_rects(LayoutRect::new(0, 0, 80, 24));
310    /// // 30 columns of dock, 1 separator column, 49 columns for window 1.
311    /// assert_eq!(rects[0].1.w, 30);
312    /// assert_eq!(rects[1].1.w, 49);
313    /// ```
314    pub fn split_fixed(dir: SplitDir, ratio: f32, fixed: Fixed, a: Self, b: Self) -> Self {
315        Self::Split {
316            dir,
317            ratio,
318            fixed: Some(fixed),
319            a: Box::new(a),
320            b: Box::new(b),
321            last_rect: None,
322        }
323    }
324
325    /// Pre-order traversal — returns all leaf ids in the order they appear
326    /// top-to-bottom / left-to-right in the layout.
327    ///
328    /// # Examples
329    ///
330    /// ```rust
331    /// use hjkl_layout::{LayoutTree, SplitDir};
332    ///
333    /// let tree = LayoutTree::split(
334    ///     SplitDir::Horizontal,
335    ///     0.5,
336    ///     LayoutTree::Leaf(0),
337    ///     LayoutTree::Leaf(1),
338    /// );
339    /// assert_eq!(tree.leaves(), vec![0, 1]);
340    /// ```
341    pub fn leaves(&self) -> Vec<WindowId> {
342        let mut out = Vec::new();
343        self.collect_leaves(&mut out);
344        out
345    }
346
347    fn collect_leaves(&self, out: &mut Vec<WindowId>) {
348        match self {
349            Self::Leaf(id) => out.push(*id),
350            Self::Split { a, b, .. } => {
351                a.collect_leaves(out);
352                b.collect_leaves(out);
353            }
354        }
355    }
356
357    /// Return the next leaf in pre-order traversal, wrapping around.
358    ///
359    /// Returns `None` only if `id` is not in the tree (shouldn't happen in
360    /// practice).
361    pub fn next_leaf(&self, id: WindowId) -> Option<WindowId> {
362        let leaves = self.leaves();
363        let pos = leaves.iter().position(|&l| l == id)?;
364        Some(leaves[(pos + 1) % leaves.len()])
365    }
366
367    /// Return the previous leaf in pre-order traversal, wrapping around.
368    ///
369    /// Returns `None` only if `id` is not in the tree (shouldn't happen in
370    /// practice).
371    pub fn prev_leaf(&self, id: WindowId) -> Option<WindowId> {
372        let leaves = self.leaves();
373        let pos = leaves.iter().position(|&l| l == id)?;
374        let len = leaves.len();
375        Some(leaves[(pos + len - 1) % len])
376    }
377
378    /// Return `true` if `id` appears anywhere in the tree.
379    pub fn contains(&self, id: WindowId) -> bool {
380        match self {
381            Self::Leaf(leaf_id) => *leaf_id == id,
382            Self::Split { a, b, .. } => a.contains(id) || b.contains(id),
383        }
384    }
385
386    /// Find the leaf for `id` and replace it in-place with `f(id)`.
387    /// Returns `true` if the leaf was found and replaced.
388    pub fn replace_leaf<F: FnOnce(WindowId) -> Self + 'static>(
389        &mut self,
390        id: WindowId,
391        f: F,
392    ) -> bool {
393        self.replace_leaf_boxed(id, Box::new(f))
394    }
395
396    fn replace_leaf_boxed(&mut self, id: WindowId, f: Box<dyn FnOnce(WindowId) -> Self>) -> bool {
397        match self {
398            Self::Leaf(leaf_id) if *leaf_id == id => {
399                *self = f(id);
400                true
401            }
402            Self::Leaf(_) => false,
403            Self::Split { a, b, .. } => {
404                // We need to check `a` first; if not found, check `b`.
405                // Because `Box<dyn FnOnce>` is not `Copy`, we do this by
406                // checking containment first, then calling.
407                if a.contains(id) {
408                    a.replace_leaf_boxed(id, f)
409                } else {
410                    b.replace_leaf_boxed(id, f)
411                }
412            }
413        }
414    }
415
416    /// Return the id of the next leaf below `id` in a `Horizontal` split,
417    /// using pre-order traversal semantics.
418    ///
419    /// "Below" means: walking up from `id`, find the innermost enclosing
420    /// `Horizontal` split where `id` lives in `a`; the answer is then the
421    /// first (leftmost) leaf of `b`.  If `id` is the bottom-most window
422    /// (or there are no horizontal splits above it), returns `None`.
423    pub fn neighbor_below(&self, id: WindowId) -> Option<WindowId> {
424        self.neighbor_direction(id, NavDir::Below)
425    }
426
427    /// Return the id of the next leaf above `id` in a `Horizontal` split.
428    pub fn neighbor_above(&self, id: WindowId) -> Option<WindowId> {
429        self.neighbor_direction(id, NavDir::Above)
430    }
431
432    /// Return the id of the next leaf to the left of `id` in a `Vertical`
433    /// split.  Horizontal splits are passed through.
434    pub fn neighbor_left(&self, id: WindowId) -> Option<WindowId> {
435        self.neighbor_direction(id, NavDir::Left)
436    }
437
438    /// Return the id of the next leaf to the right of `id` in a `Vertical`
439    /// split.  Horizontal splits are passed through.
440    pub fn neighbor_right(&self, id: WindowId) -> Option<WindowId> {
441        self.neighbor_direction(id, NavDir::Right)
442    }
443
444    /// Internal unified helper for directional navigation.
445    ///
446    /// - `Below` / `Above` act on `Horizontal` splits; `Vertical` is a pass-through.
447    /// - `Left` / `Right` act on `Vertical` splits; `Horizontal` is a pass-through.
448    ///
449    /// In each "active" split direction:
450    /// - For the "forward" direction (Below / Right), when `id` is in `a`:
451    ///   try to find a deeper neighbour inside `a` first; failing that, cross to `b`.
452    ///   When `id` is in `b`: recurse into `b` only (no cross available).
453    /// - For the "backward" direction (Above / Left), symmetric.
454    fn neighbor_direction(&self, id: WindowId, dir: NavDir) -> Option<WindowId> {
455        match self {
456            Self::Leaf(_) => None,
457            Self::Split {
458                dir: split_dir,
459                a,
460                b,
461                ..
462            } => {
463                // Which split direction is "active" for this nav direction?
464                let active_split = match dir {
465                    NavDir::Below | NavDir::Above => SplitDir::Horizontal,
466                    NavDir::Left | NavDir::Right => SplitDir::Vertical,
467                };
468                // Is this a "forward" traversal (a→b) or "backward" (b→a)?
469                let forward = matches!(dir, NavDir::Below | NavDir::Right);
470
471                if *split_dir == active_split {
472                    if a.contains(id) {
473                        if forward {
474                            // Try deeper forward-neighbour inside `a`.
475                            let inner = a.neighbor_direction(id, dir);
476                            if inner.is_some() {
477                                return inner;
478                            }
479                            // Cross to `b`.
480                            Some(first_leaf(b))
481                        } else {
482                            // Backward, `id` in `a` (the "first" half) — recurse only.
483                            a.neighbor_direction(id, dir)
484                        }
485                    } else if b.contains(id) {
486                        if forward {
487                            // Forward, `id` in `b` (the "second" half) — recurse only.
488                            b.neighbor_direction(id, dir)
489                        } else {
490                            // Try deeper backward-neighbour inside `b`.
491                            let inner = b.neighbor_direction(id, dir);
492                            if inner.is_some() {
493                                return inner;
494                            }
495                            // Cross to `a`.
496                            Some(last_leaf(a))
497                        }
498                    } else {
499                        None
500                    }
501                } else {
502                    // Pass-through: this split axis is orthogonal — recurse without offering a sibling.
503                    if a.contains(id) {
504                        a.neighbor_direction(id, dir)
505                    } else if b.contains(id) {
506                        b.neighbor_direction(id, dir)
507                    } else {
508                        None
509                    }
510                }
511            }
512        }
513    }
514
515    /// Walk the tree looking for the innermost enclosing Split with matching
516    /// `dir` that contains `id`. Returns a mutable reference to the ratio,
517    /// a copy of the last_rect, and whether the focused leaf is in `a`.
518    /// Returns None if no such enclosing Split exists.
519    ///
520    /// **Splits carrying a [`Fixed`] allocation are never candidates.** Their
521    /// size belongs to whoever set it (a dock's configured width, say), not to
522    /// a resize command, and `ratio` is ignored while `fixed` is set — writing
523    /// it would do nothing now and make the layout jump later, when `fixed` is
524    /// cleared. The search simply continues outward, so a `<C-w>+`-style resize
525    /// inside a fixed pane moves the nearest resizable ancestor instead. That
526    /// is vim's `winfixwidth` / `winfixheight` behaviour.
527    pub fn enclosing_split_mut(
528        &mut self,
529        id: WindowId,
530        dir: SplitDir,
531    ) -> Option<(&mut f32, Option<LayoutRect>, bool)> {
532        match self {
533            Self::Leaf(_) => None,
534            Self::Split {
535                dir: my_dir,
536                ratio,
537                fixed,
538                a,
539                b,
540                last_rect,
541            } => {
542                let in_a = a.contains(id);
543                let in_b = b.contains(id);
544                if !in_a && !in_b {
545                    return None;
546                }
547
548                let my_dir = *my_dir;
549                let saved_rect = *last_rect;
550                let is_fixed = fixed.is_some();
551
552                // Try deeper first (innermost wins).
553                let inner = if in_a {
554                    a.enclosing_split_mut(id, dir)
555                } else {
556                    b.enclosing_split_mut(id, dir)
557                };
558                if inner.is_some() {
559                    return inner;
560                }
561
562                // No deeper match — am I a candidate? A fixed split never is;
563                // returning None here hands the search to my parent.
564                if my_dir == dir && !is_fixed {
565                    Some((ratio, saved_rect, in_a))
566                } else {
567                    None
568                }
569            }
570        }
571    }
572
573    /// Reset all splits in the tree to 0.5 ratio, leaving pinned leaves at
574    /// their current size.
575    ///
576    /// `pinned` lists the leaves that must survive equalization unchanged
577    /// (docks and other fixed-size windows). A split is left alone when it
578    /// carries a [`Fixed`] allocation or when either of its immediate children
579    /// is a pinned leaf; recursion still descends into both children so
580    /// ordinary splits underneath a pinned one are still equalized.
581    ///
582    /// Pass `&[]` for the plain "equalize everything" behaviour.
583    ///
584    /// # Examples
585    ///
586    /// ```rust
587    /// use hjkl_layout::{Fixed, LayoutRect, LayoutTree, SplitDir};
588    ///
589    /// let mut tree = LayoutTree::split_fixed(
590    ///     SplitDir::Vertical,
591    ///     0.5,
592    ///     Fixed::First(30),
593    ///     LayoutTree::Leaf(0),
594    ///     LayoutTree::Leaf(1),
595    /// );
596    /// let area = LayoutRect::new(0, 0, 80, 24);
597    /// let before = tree.window_rects(area);
598    /// tree.equalize_all(&[0]);
599    /// assert_eq!(tree.window_rects(area), before);
600    /// ```
601    pub fn equalize_all(&mut self, pinned: &[WindowId]) {
602        if let Self::Split {
603            ratio, fixed, a, b, ..
604        } = self
605        {
606            let touches_pinned =
607                fixed.is_some() || is_pinned_leaf(a, pinned) || is_pinned_leaf(b, pinned);
608            if !touches_pinned {
609                *ratio = 0.5;
610            }
611            a.equalize_all(pinned);
612            b.equalize_all(pinned);
613        }
614    }
615
616    /// Collapse the tree down to `keep` plus every pinned leaf (vim's `:only`).
617    ///
618    /// Leaves that are neither `keep` nor listed in `pinned` are dropped and
619    /// their parent splits collapse onto the surviving sibling, so the relative
620    /// arrangement of the retained leaves (and the geometry of the splits that
621    /// join them) is preserved.
622    ///
623    /// Returns the ids of the removed leaves so the caller can dispose of the
624    /// corresponding window state. Returns an empty vector — and leaves the
625    /// tree untouched — when `keep` is not in the tree.
626    ///
627    /// # Examples
628    ///
629    /// ```rust
630    /// use hjkl_layout::{LayoutTree, SplitDir};
631    ///
632    /// // dock | (1 | 2)
633    /// let mut tree = LayoutTree::split(
634    ///     SplitDir::Vertical,
635    ///     0.5,
636    ///     LayoutTree::Leaf(9),
637    ///     LayoutTree::split(SplitDir::Vertical, 0.5, LayoutTree::Leaf(1), LayoutTree::Leaf(2)),
638    /// );
639    /// let removed = tree.only(1, &[9]);
640    /// assert_eq!(removed, vec![2]);
641    /// assert_eq!(tree.leaves(), vec![9, 1]);
642    /// ```
643    pub fn only(&mut self, keep: WindowId, pinned: &[WindowId]) -> Vec<WindowId> {
644        if !self.contains(keep) {
645            return Vec::new();
646        }
647        let mut removed = Vec::new();
648        self.prune_to(keep, pinned, &mut removed);
649        removed
650    }
651
652    /// Recursive helper for [`only`](Self::only). Every subtree it is invoked
653    /// on retains at least one leaf, which `only` guarantees at the root by
654    /// checking that `keep` is present.
655    fn prune_to(&mut self, keep: WindowId, pinned: &[WindowId], removed: &mut Vec<WindowId>) {
656        let Self::Split { a, b, .. } = self else {
657            return;
658        };
659        let a_keeps = a.retains_any(keep, pinned);
660        let b_keeps = b.retains_any(keep, pinned);
661        match (a_keeps, b_keeps) {
662            (true, true) => {
663                a.prune_to(keep, pinned, removed);
664                b.prune_to(keep, pinned, removed);
665            }
666            (true, false) => {
667                b.collect_leaves(removed);
668                let mut survivor = std::mem::replace(a.as_mut(), Self::Leaf(keep));
669                survivor.prune_to(keep, pinned, removed);
670                *self = survivor;
671            }
672            (false, true) => {
673                a.collect_leaves(removed);
674                let mut survivor = std::mem::replace(b.as_mut(), Self::Leaf(keep));
675                survivor.prune_to(keep, pinned, removed);
676                *self = survivor;
677            }
678            // Unreachable from `only` (the root always retains `keep`), but a
679            // defensive no-op beats a panic if a caller reaches it directly.
680            (false, false) => {}
681        }
682    }
683
684    /// Does this subtree contain `keep` or any leaf in `pinned`?
685    fn retains_any(&self, keep: WindowId, pinned: &[WindowId]) -> bool {
686        match self {
687            Self::Leaf(id) => *id == keep || pinned.contains(id),
688            Self::Split { a, b, .. } => a.retains_any(keep, pinned) || b.retains_any(keep, pinned),
689        }
690    }
691
692    /// For each enclosing Split on the path from root to leaf `id`, invoke
693    /// `f` with the split's mutable state. Order: outermost first.
694    ///
695    /// Splits carrying a [`Fixed`] allocation are **skipped** (recursion still
696    /// descends through them) for the same reason
697    /// [`enclosing_split_mut`](Self::enclosing_split_mut) refuses them: their
698    /// size is not a ratio anyone may rewrite.
699    pub fn for_each_ancestor<F>(&mut self, id: WindowId, f: &mut F)
700    where
701        F: FnMut(SplitDir, &mut f32, bool, Option<LayoutRect>),
702    {
703        if let Self::Split {
704            dir,
705            ratio,
706            fixed,
707            a,
708            b,
709            last_rect,
710        } = self
711        {
712            let in_a = a.contains(id);
713            let in_b = b.contains(id);
714            if !in_a && !in_b {
715                return;
716            }
717            // Outermost first: call f on this node before recursing.
718            if fixed.is_none() {
719                f(*dir, ratio, in_a, *last_rect);
720            }
721            if in_a {
722                a.for_each_ancestor(id, f);
723            } else {
724                b.for_each_ancestor(id, f);
725            }
726        }
727    }
728
729    /// Walk the tree and compute the [`LayoutRect`] each leaf window occupies
730    /// within `area`. Every step goes through [`split_geometry`], which is also
731    /// what the TUI renderer descends with, so headless geometry is the same
732    /// geometry the user sees rather than a copy of it.
733    ///
734    /// # Split math (see [`split_geometry`])
735    ///
736    /// For a **Horizontal** split (stacks top-to-bottom, `Axis::Row`):
737    ///   `a_h = round(area.h * ratio).clamp(1, area.h - 1)`
738    ///   `b_h = area.h - a_h`
739    ///   If `a_h >= 2` and `b_h > 0`: the bottom row of `a` becomes a separator;
740    ///   `a` shrinks by 1 (height becomes `a_h - 1`), `b` starts after the separator.
741    ///
742    /// For a **Vertical** split (side-by-side, `Axis::Col`):
743    ///   `a_w = round(area.w * ratio).clamp(1, area.w - 1)`
744    ///   `b_w = area.w - a_w`
745    ///   If `a_w >= 2` and `b_w > 0`: the rightmost column of `a` becomes a separator;
746    ///   `a` shrinks by 1 (width becomes `a_w - 1`), `b` starts right after `a`.
747    ///
748    /// Leaf → single entry `(id, area)`.
749    ///
750    /// # Fixed sizes
751    ///
752    /// When the split carries `fixed: Some(..)`, the `round(len * ratio)` step
753    /// above is replaced by whatever allocation makes the named child *render*
754    /// the requested number of cells, and the sibling takes the remainder. The
755    /// two variants are symmetric: on an 80-column vertical split both
756    /// `Fixed::First(30)` and `Fixed::Second(30)` produce a 30-column rect for
757    /// their child, with the separator absorbed on the `a` side either way
758    /// (`First(30)` → `a` = 30, `b` = 49; `Fixed::Second(30)` → `a` = 49,
759    /// `b` = 30).
760    ///
761    /// The allocation is clamped to `1 ..= axis_len - 1` — the same clamp the
762    /// ratio path applies — so an oversized fixed size degrades to "as large as
763    /// possible while the sibling still gets one cell" instead of underflowing
764    /// or starving the sibling.
765    ///
766    /// # Examples
767    ///
768    /// ```rust
769    /// use hjkl_layout::{LayoutTree, SplitDir, LayoutRect};
770    ///
771    /// let tree = LayoutTree::Leaf(0);
772    /// let area = LayoutRect::new(0, 0, 80, 23);
773    /// let rects = tree.window_rects(area);
774    /// assert_eq!(rects, vec![(0, area)]);
775    /// ```
776    pub fn window_rects(&self, area: LayoutRect) -> Vec<(WindowId, LayoutRect)> {
777        let mut out = Vec::new();
778        self.collect_rects(area, &mut out);
779        out
780    }
781
782    fn collect_rects(&self, area: LayoutRect, out: &mut Vec<(WindowId, LayoutRect)>) {
783        match self {
784            Self::Leaf(id) => out.push((*id, area)),
785            Self::Split {
786                dir,
787                ratio,
788                fixed,
789                a,
790                b,
791                ..
792            } => {
793                let geo = split_geometry(area, *dir, *ratio, *fixed);
794                a.collect_rects(geo.a, out);
795                b.collect_rects(geo.b, out);
796            }
797        }
798    }
799
800    /// Swap the two children of the deepest Split that directly contains
801    /// `Leaf(id)` as one of its `a` or `b` children.
802    ///
803    /// Returns `true` if the swap was applied (i.e. there is an enclosing
804    /// Split — `false` when `id` is the only window).
805    ///
806    /// Refuses (returns `false`, tree untouched) when either side of the swap
807    /// is pinned: `id` itself being in `pinned`, or the sibling subtree
808    /// containing any pinned leaf. A dock must not be dragged across the
809    /// layout, nor another window dragged through it.
810    ///
811    /// Pass `&[]` for the plain "swap anything" behaviour.
812    pub fn swap_with_sibling(&mut self, id: WindowId, pinned: &[WindowId]) -> bool {
813        if pinned.contains(&id) {
814            return false;
815        }
816        self.swap_with_sibling_inner(id, pinned)
817    }
818
819    fn swap_with_sibling_inner(&mut self, id: WindowId, pinned: &[WindowId]) -> bool {
820        match self {
821            Self::Leaf(_) => false,
822            Self::Split { a, b, .. } => {
823                let a_is_focused_leaf = matches!(a.as_ref(), Self::Leaf(leaf) if *leaf == id);
824                let b_is_focused_leaf = matches!(b.as_ref(), Self::Leaf(leaf) if *leaf == id);
825                if a_is_focused_leaf || b_is_focused_leaf {
826                    // The sibling is whichever side isn't the focused leaf.
827                    let sibling_pinned = if a_is_focused_leaf {
828                        contains_pinned(b, pinned)
829                    } else {
830                        contains_pinned(a, pinned)
831                    };
832                    if sibling_pinned {
833                        return false;
834                    }
835                    std::mem::swap(a, b);
836                    return true;
837                }
838                // Recurse into whichever side contains id.
839                if a.contains(id) {
840                    return a.swap_with_sibling_inner(id, pinned);
841                }
842                if b.contains(id) {
843                    return b.swap_with_sibling_inner(id, pinned);
844                }
845                false
846            }
847        }
848    }
849
850    /// Remove the leaf `id` from the tree.  When its parent `Split` is left
851    /// with only the sibling, that split is replaced by the sibling subtree
852    /// (collapse).
853    ///
854    /// Returns the `WindowId` of the leaf that should receive focus after
855    /// removal (the sibling that survived the collapse), or `Err` if `id` is
856    /// the only remaining leaf.
857    ///
858    /// # Errors
859    ///
860    /// Returns `Err("E444: Cannot close last window")` when attempting to
861    /// remove the only leaf in the tree.
862    pub fn remove_leaf(&mut self, id: WindowId) -> Result<WindowId, &'static str> {
863        if matches!(self, Self::Leaf(_)) {
864            return Err("E444: Cannot close last window");
865        }
866        match self.try_remove_leaf(id) {
867            Some(focus) => Ok(focus),
868            None => Err("E444: Cannot close last window"),
869        }
870    }
871
872    /// Recursive helper for `remove_leaf`.  Returns `Some(new_focus)` when
873    /// `id` was found and removed (or the caller needs to collapse this node),
874    /// `None` when `id` was not in this subtree.
875    fn try_remove_leaf(&mut self, id: WindowId) -> Option<WindowId> {
876        match self {
877            Self::Leaf(_) => None, // can't remove the only leaf
878            Self::Split { a, b, .. } => {
879                // Case 1: `a` is the leaf we want to remove.
880                if matches!(a.as_ref(), Self::Leaf(leaf) if *leaf == id) {
881                    let new_focus = first_leaf(b);
882                    // Collapse: replace self with b.
883                    *self = *b.clone();
884                    return Some(new_focus);
885                }
886                // Case 2: `b` is the leaf we want to remove.
887                if matches!(b.as_ref(), Self::Leaf(leaf) if *leaf == id) {
888                    let new_focus = last_leaf(a);
889                    // Collapse: replace self with a.
890                    *self = *a.clone();
891                    return Some(new_focus);
892                }
893                // Case 3: recurse into `a`.
894                if a.contains(id) {
895                    return a.try_remove_leaf(id);
896                }
897                // Case 4: recurse into `b`.
898                if b.contains(id) {
899                    return b.try_remove_leaf(id);
900                }
901                None
902            }
903        }
904    }
905}
906
907/// Where one split's two children and its separator land inside a parent rect.
908///
909/// Produced by [`split_geometry`]. `a` and `b` are the rects the children are
910/// rendered into — already shrunk for the separator — and `separator` is the
911/// 1-cell strip between them, or `None` when the area was too small for one to
912/// be drawn.
913///
914/// `#[non_exhaustive]` — additional fields may be added in minor releases.
915#[derive(Debug, Clone, Copy, PartialEq, Eq)]
916#[non_exhaustive]
917pub struct SplitGeometry {
918    /// Rect for the first (top / left) child.
919    pub a: LayoutRect,
920    /// Rect for the second (bottom / right) child.
921    pub b: LayoutRect,
922    /// The 1-cell separator strip between the children, if one fits. Its
923    /// orientation follows the split: a single column for [`SplitDir::Vertical`],
924    /// a single row for [`SplitDir::Horizontal`].
925    pub separator: Option<LayoutRect>,
926}
927
928/// Divide `area` between one split's two children, carving out the separator.
929///
930/// This is the **single source of truth** for split geometry: the headless
931/// [`LayoutTree::window_rects`] walk, the TUI renderer (which also needs the
932/// separator rect to draw it) and the mouse border hit-test all call it, so a
933/// border the user can see is always a border they can grab.
934///
935/// ## Separator rules
936///
937/// **Vertical** (side-by-side, `Axis::Col`): separator is the rightmost cell
938/// of `a`'s allocation. Applied only when that allocation is `>= 2` columns AND
939/// `b` gets `> 0`; `a` shrinks by 1 column, `b` position/size are unchanged.
940///
941/// **Horizontal** (stacked, `Axis::Row`): separator is the bottom cell of `a`'s
942/// allocation. Applied only when that allocation is `>= 2` rows AND `b` gets
943/// `> 0`; `a` shrinks by 1 row, `b` position/size are unchanged.
944///
945/// ## Fixed sizes
946///
947/// `fixed` replaces the `round(len * ratio)` term with the allocation that
948/// makes the named child render exactly the requested number of cells (see
949/// [`Fixed`] and `first_child_cells`). It goes through the identical clamp, so
950/// an oversized request can never underflow `u16` or leave the sibling with
951/// zero cells in an area that could hold both.
952///
953/// # Examples
954///
955/// ```rust
956/// use hjkl_layout::{Fixed, LayoutRect, SplitDir, split_geometry};
957///
958/// let geo = split_geometry(
959///     LayoutRect::new(0, 0, 80, 24),
960///     SplitDir::Vertical,
961///     0.5,
962///     Some(Fixed::First(30)),
963/// );
964/// assert_eq!(geo.a.w, 30);
965/// assert_eq!(geo.separator.map(|s| s.x), Some(30));
966/// assert_eq!((geo.b.x, geo.b.w), (31, 49));
967/// ```
968pub fn split_geometry(
969    area: LayoutRect,
970    dir: SplitDir,
971    ratio: f32,
972    fixed: Option<Fixed>,
973) -> SplitGeometry {
974    match dir.axis() {
975        Axis::Row => {
976            // A zero-height parent can't be split; the clamp below would
977            // otherwise force a size-1 child that overflows the parent.
978            if area.h == 0 {
979                let empty = LayoutRect::new(area.x, area.y, area.w, 0);
980                return SplitGeometry {
981                    a: empty,
982                    b: empty,
983                    separator: None,
984                };
985            }
986            // Horizontal split: divide height.
987            let a_h = first_child_cells(area.h, ratio, fixed);
988            let a_h = a_h.clamp(1, area.h.saturating_sub(1).max(1));
989            let b_h = area.h.saturating_sub(a_h);
990            let mut rect_a = LayoutRect::new(area.x, area.y, area.w, a_h);
991            let rect_b = LayoutRect::new(area.x, area.y + a_h, area.w, b_h);
992            // Carve separator: bottom row of rect_a, only when safe.
993            let separator = if rect_a.h >= 2 && rect_b.h > 0 {
994                rect_a.h -= 1;
995                Some(LayoutRect::new(rect_a.x, rect_a.y + rect_a.h, rect_a.w, 1))
996            } else {
997                None
998            };
999            SplitGeometry {
1000                a: rect_a,
1001                b: rect_b,
1002                separator,
1003            }
1004        }
1005        Axis::Col => {
1006            // A zero-width parent can't be split; see the Row branch.
1007            if area.w == 0 {
1008                let empty = LayoutRect::new(area.x, area.y, 0, area.h);
1009                return SplitGeometry {
1010                    a: empty,
1011                    b: empty,
1012                    separator: None,
1013                };
1014            }
1015            // Vertical split: divide width.
1016            let a_w = first_child_cells(area.w, ratio, fixed);
1017            let a_w = a_w.clamp(1, area.w.saturating_sub(1).max(1));
1018            let b_w = area.w.saturating_sub(a_w);
1019            let mut rect_a = LayoutRect::new(area.x, area.y, a_w, area.h);
1020            let rect_b = LayoutRect::new(area.x + a_w, area.y, b_w, area.h);
1021            // Carve separator: rightmost column of rect_a, only when safe.
1022            let separator = if rect_a.w >= 2 && rect_b.w > 0 {
1023                rect_a.w -= 1;
1024                Some(LayoutRect::new(rect_a.x + rect_a.w, rect_a.y, 1, rect_a.h))
1025            } else {
1026                None
1027            };
1028            SplitGeometry {
1029                a: rect_a,
1030                b: rect_b,
1031                separator,
1032            }
1033        }
1034    }
1035}
1036
1037/// Cells to allocate to the first child along the split axis, before the
1038/// caller's `1 ..= len - 1` clamp.
1039///
1040/// `len` is the parent's extent along the split axis. Without a `fixed` the
1041/// answer is the historical `round(len * ratio)`. With one:
1042///
1043/// - `Fixed::First(n)` → `n + 1`: [`Fixed`] counts **rendered** cells and the
1044///   separator is carved out of the first child, so the first child needs one
1045///   more cell than it renders. Saturating, so `u16::MAX` can't wrap.
1046/// - `Fixed::Second(n)` → `len - n`, saturating at 0 so a request larger than
1047///   the parent degrades (via the caller's clamp) to "everything but one cell"
1048///   instead of wrapping around `u16`. No `+ 1` here: the separator already
1049///   comes out of the *first* child, so the second renders its whole share.
1050///
1051/// The `+ 1` is unconditional because the caller's `1 ..= len - 1` clamp
1052/// already collapses it in exactly the cases where no separator gets carved.
1053/// A separator is skipped only when the first child ends up with a single cell
1054/// (`rect_a < 2`) — the clamp's own floor — or when the second child gets zero,
1055/// which the clamp's ceiling makes impossible for `len >= 2`. So `First(n)`
1056/// renders `n` whenever the area can hold it, and the largest size that fits
1057/// otherwise.
1058///
1059/// Unknown future `Fixed` variants fall back to the ratio, so they degrade to
1060/// an ordinary split rather than a panic.
1061fn first_child_cells(len: u16, ratio: f32, fixed: Option<Fixed>) -> u16 {
1062    match fixed {
1063        Some(Fixed::First(n)) => n.saturating_add(1),
1064        Some(Fixed::Second(n)) => len.saturating_sub(n),
1065        _ => ((len as f32) * ratio).round() as u16,
1066    }
1067}
1068
1069/// Is this subtree a single leaf that the caller asked to leave alone?
1070fn is_pinned_leaf(tree: &LayoutTree, pinned: &[WindowId]) -> bool {
1071    matches!(tree, LayoutTree::Leaf(id) if pinned.contains(id))
1072}
1073
1074/// Does this subtree contain any leaf the caller asked to leave alone?
1075fn contains_pinned(tree: &LayoutTree, pinned: &[WindowId]) -> bool {
1076    match tree {
1077        LayoutTree::Leaf(id) => pinned.contains(id),
1078        LayoutTree::Split { a, b, .. } => contains_pinned(a, pinned) || contains_pinned(b, pinned),
1079    }
1080}
1081
1082/// Internal direction enum used by `neighbor_direction`.
1083#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1084enum NavDir {
1085    Below,
1086    Above,
1087    Left,
1088    Right,
1089}
1090
1091/// First (top / left) leaf in a subtree.
1092fn first_leaf(tree: &LayoutTree) -> WindowId {
1093    match tree {
1094        LayoutTree::Leaf(id) => *id,
1095        LayoutTree::Split { a, .. } => first_leaf(a),
1096    }
1097}
1098
1099/// Last (bottom / right) leaf in a subtree.
1100fn last_leaf(tree: &LayoutTree) -> WindowId {
1101    match tree {
1102        LayoutTree::Leaf(id) => *id,
1103        LayoutTree::Split { b, .. } => last_leaf(b),
1104    }
1105}
1106
1107// ── Unit tests ────────────────────────────────────────────────────────────────
1108
1109#[cfg(test)]
1110mod tests {
1111    use super::*;
1112
1113    fn leaf(id: WindowId) -> LayoutTree {
1114        LayoutTree::Leaf(id)
1115    }
1116
1117    fn hsplit(ratio: f32, a: LayoutTree, b: LayoutTree) -> LayoutTree {
1118        LayoutTree::split(SplitDir::Horizontal, ratio, a, b)
1119    }
1120
1121    fn vsplit(ratio: f32, a: LayoutTree, b: LayoutTree) -> LayoutTree {
1122        LayoutTree::split(SplitDir::Vertical, ratio, a, b)
1123    }
1124
1125    fn hsplit_with_rect(ratio: f32, a: LayoutTree, b: LayoutTree, rect: LayoutRect) -> LayoutTree {
1126        LayoutTree::Split {
1127            dir: SplitDir::Horizontal,
1128            ratio,
1129            fixed: None,
1130            a: Box::new(a),
1131            b: Box::new(b),
1132            last_rect: Some(rect),
1133        }
1134    }
1135
1136    fn vsplit_with_rect(ratio: f32, a: LayoutTree, b: LayoutTree, rect: LayoutRect) -> LayoutTree {
1137        LayoutTree::Split {
1138            dir: SplitDir::Vertical,
1139            ratio,
1140            fixed: None,
1141            a: Box::new(a),
1142            b: Box::new(b),
1143            last_rect: Some(rect),
1144        }
1145    }
1146
1147    /// Ratio of the split at the root, for equalize assertions.
1148    fn root_ratio(t: &LayoutTree) -> f32 {
1149        match t {
1150            LayoutTree::Split { ratio, .. } => *ratio,
1151            LayoutTree::Leaf(_) => panic!("expected a split at the root"),
1152        }
1153    }
1154
1155    // ── LayoutRect ────────────────────────────────────────────────────────────
1156
1157    #[test]
1158    fn layout_rect_new_roundtrips_fields() {
1159        let r = LayoutRect::new(1, 2, 80, 24);
1160        assert_eq!(r.x, 1);
1161        assert_eq!(r.y, 2);
1162        assert_eq!(r.w, 80);
1163        assert_eq!(r.h, 24);
1164    }
1165
1166    #[test]
1167    fn layout_rect_default_is_zero() {
1168        let r = LayoutRect::default();
1169        assert_eq!(r, LayoutRect::new(0, 0, 0, 0));
1170    }
1171
1172    #[test]
1173    fn headless_split_zero_size_parent_does_not_overflow() {
1174        // A zero-height parent must yield zero-height children, not a size-1
1175        // child that exceeds the parent.
1176        let geo = split_geometry(
1177            LayoutRect::new(0, 0, 10, 0),
1178            SplitDir::Horizontal,
1179            0.5,
1180            None,
1181        );
1182        assert_eq!((geo.a.h, geo.b.h), (0, 0), "row split of h=0 must stay 0");
1183        assert_eq!(
1184            geo.separator, None,
1185            "no separator fits in a zero-height row"
1186        );
1187        // Same for a zero-width parent under a vertical split.
1188        let geo = split_geometry(LayoutRect::new(0, 0, 0, 10), SplitDir::Vertical, 0.5, None);
1189        assert_eq!((geo.a.w, geo.b.w), (0, 0), "col split of w=0 must stay 0");
1190        assert_eq!(geo.separator, None, "no separator fits in a zero-width col");
1191    }
1192
1193    // ── Tab ───────────────────────────────────────────────────────────────────
1194
1195    #[test]
1196    fn tab_new_and_default() {
1197        let t = Tab::new(LayoutTree::Leaf(5), 5);
1198        assert_eq!(t.focused_window, 5);
1199        assert_eq!(t.layout.leaves(), vec![5]);
1200
1201        let d = Tab::default();
1202        assert_eq!(d.focused_window, 0);
1203        assert_eq!(d.layout.leaves(), vec![0]);
1204    }
1205
1206    // ── Window ────────────────────────────────────────────────────────────────
1207
1208    #[test]
1209    fn window_new_and_default() {
1210        let w = Window::new(3);
1211        assert_eq!(w.slot, 3);
1212        assert!(w.last_rect.is_none());
1213
1214        let d = Window::default();
1215        assert_eq!(d.slot, 0);
1216    }
1217
1218    // ── LayoutTree::new / default ─────────────────────────────────────────────
1219
1220    #[test]
1221    fn layout_tree_new_creates_leaf() {
1222        let t = LayoutTree::new(7);
1223        assert_eq!(t.leaves(), vec![7]);
1224    }
1225
1226    #[test]
1227    fn layout_tree_default_is_leaf_zero() {
1228        let t = LayoutTree::default();
1229        assert_eq!(t.leaves(), vec![0]);
1230    }
1231
1232    // ── leaves() ─────────────────────────────────────────────────────────────
1233
1234    #[test]
1235    fn leaves_single_leaf() {
1236        let tree = leaf(0);
1237        assert_eq!(tree.leaves(), vec![0]);
1238    }
1239
1240    #[test]
1241    fn leaves_two_leaf_split() {
1242        let tree = hsplit(0.5, leaf(0), leaf(1));
1243        assert_eq!(tree.leaves(), vec![0, 1]);
1244    }
1245
1246    #[test]
1247    fn leaves_nested_horizontal_splits() {
1248        // 0 / (1 / 2)
1249        let tree = hsplit(0.5, leaf(0), hsplit(0.5, leaf(1), leaf(2)));
1250        assert_eq!(tree.leaves(), vec![0, 1, 2]);
1251    }
1252
1253    #[test]
1254    fn leaves_nested_left_split() {
1255        let tree = hsplit(0.5, hsplit(0.5, leaf(0), leaf(1)), leaf(2));
1256        assert_eq!(tree.leaves(), vec![0, 1, 2]);
1257    }
1258
1259    // ── contains() ───────────────────────────────────────────────────────────
1260
1261    #[test]
1262    fn contains_returns_true_for_present_id() {
1263        let tree = hsplit(0.5, leaf(0), leaf(1));
1264        assert!(tree.contains(0));
1265        assert!(tree.contains(1));
1266        assert!(!tree.contains(2));
1267    }
1268
1269    // ── replace_leaf() ───────────────────────────────────────────────────────
1270
1271    #[test]
1272    fn replace_leaf_on_single_leaf() {
1273        let mut tree = leaf(0);
1274        let replaced = tree.replace_leaf(0, |_| leaf(99));
1275        assert!(replaced);
1276        assert_eq!(tree.leaves(), vec![99]);
1277    }
1278
1279    #[test]
1280    fn replace_leaf_in_split_left() {
1281        let mut tree = hsplit(0.5, leaf(0), leaf(1));
1282        let replaced = tree.replace_leaf(0, |id| hsplit(0.5, leaf(id + 10), leaf(id)));
1283        assert!(replaced);
1284        assert_eq!(tree.leaves(), vec![10, 0, 1]);
1285    }
1286
1287    #[test]
1288    fn replace_leaf_not_found_returns_false() {
1289        let mut tree = hsplit(0.5, leaf(0), leaf(1));
1290        let replaced = tree.replace_leaf(99, |_| leaf(99));
1291        assert!(!replaced);
1292        assert_eq!(tree.leaves(), vec![0, 1]);
1293    }
1294
1295    // ── neighbor_below() / neighbor_above() ──────────────────────────────────
1296
1297    #[test]
1298    fn neighbor_below_two_leaf() {
1299        let tree = hsplit(0.5, leaf(0), leaf(1));
1300        assert_eq!(tree.neighbor_below(0), Some(1));
1301        assert_eq!(tree.neighbor_below(1), None);
1302    }
1303
1304    #[test]
1305    fn neighbor_above_two_leaf() {
1306        let tree = hsplit(0.5, leaf(0), leaf(1));
1307        assert_eq!(tree.neighbor_above(0), None);
1308        assert_eq!(tree.neighbor_above(1), Some(0));
1309    }
1310
1311    #[test]
1312    fn neighbor_below_three_leaf_nested_bottom() {
1313        // 0 / (1 / 2)
1314        let tree = hsplit(0.5, leaf(0), hsplit(0.5, leaf(1), leaf(2)));
1315        assert_eq!(tree.neighbor_below(0), Some(1));
1316        assert_eq!(tree.neighbor_below(1), Some(2));
1317        assert_eq!(tree.neighbor_below(2), None);
1318    }
1319
1320    #[test]
1321    fn neighbor_above_three_leaf_nested_bottom() {
1322        let tree = hsplit(0.5, leaf(0), hsplit(0.5, leaf(1), leaf(2)));
1323        assert_eq!(tree.neighbor_above(0), None);
1324        assert_eq!(tree.neighbor_above(1), Some(0));
1325        assert_eq!(tree.neighbor_above(2), Some(1));
1326    }
1327
1328    #[test]
1329    fn neighbor_below_three_leaf_nested_top() {
1330        let tree = hsplit(0.5, hsplit(0.5, leaf(0), leaf(1)), leaf(2));
1331        assert_eq!(tree.neighbor_below(0), Some(1));
1332        assert_eq!(tree.neighbor_below(1), Some(2));
1333        assert_eq!(tree.neighbor_below(2), None);
1334    }
1335
1336    #[test]
1337    fn neighbor_above_three_leaf_nested_top() {
1338        let tree = hsplit(0.5, hsplit(0.5, leaf(0), leaf(1)), leaf(2));
1339        assert_eq!(tree.neighbor_above(0), None);
1340        assert_eq!(tree.neighbor_above(1), Some(0));
1341        assert_eq!(tree.neighbor_above(2), Some(1));
1342    }
1343
1344    // ── remove_leaf() ────────────────────────────────────────────────────────
1345
1346    #[test]
1347    fn remove_leaf_only_leaf_errors() {
1348        let mut tree = leaf(0);
1349        assert!(tree.remove_leaf(0).is_err());
1350    }
1351
1352    #[test]
1353    fn remove_leaf_collapses_parent_keeps_sibling() {
1354        let mut tree = hsplit(0.5, leaf(0), leaf(1));
1355        let focus = tree.remove_leaf(0).unwrap();
1356        assert_eq!(focus, 1);
1357        assert_eq!(tree.leaves(), vec![1]);
1358    }
1359
1360    #[test]
1361    fn remove_leaf_b_side_collapses_to_a() {
1362        let mut tree = hsplit(0.5, leaf(0), leaf(1));
1363        let focus = tree.remove_leaf(1).unwrap();
1364        assert_eq!(focus, 0);
1365        assert_eq!(tree.leaves(), vec![0]);
1366    }
1367
1368    #[test]
1369    fn remove_leaf_nested_middle() {
1370        // 0 / (1 / 2)  → remove 1 → 0 / 2
1371        let mut tree = hsplit(0.5, leaf(0), hsplit(0.5, leaf(1), leaf(2)));
1372        let focus = tree.remove_leaf(1).unwrap();
1373        assert_eq!(focus, 2);
1374        assert_eq!(tree.leaves(), vec![0, 2]);
1375    }
1376
1377    // ── neighbor_left() / neighbor_right() ───────────────────────────────────
1378
1379    #[test]
1380    fn neighbor_left_in_vertical_split() {
1381        let tree = vsplit(0.5, leaf(0), leaf(1));
1382        assert_eq!(tree.neighbor_left(0), None);
1383        assert_eq!(tree.neighbor_left(1), Some(0));
1384    }
1385
1386    #[test]
1387    fn neighbor_right_in_vertical_split() {
1388        let tree = vsplit(0.5, leaf(0), leaf(1));
1389        assert_eq!(tree.neighbor_right(0), Some(1));
1390        assert_eq!(tree.neighbor_right(1), None);
1391    }
1392
1393    #[test]
1394    fn neighbor_left_no_op_in_horizontal_split() {
1395        let tree = hsplit(0.5, leaf(0), leaf(1));
1396        assert_eq!(tree.neighbor_left(0), None);
1397        assert_eq!(tree.neighbor_left(1), None);
1398        assert_eq!(tree.neighbor_right(0), None);
1399        assert_eq!(tree.neighbor_right(1), None);
1400    }
1401
1402    #[test]
1403    fn neighbor_left_three_leaf_vertical() {
1404        let tree = vsplit(0.5, leaf(0), vsplit(0.5, leaf(1), leaf(2)));
1405        assert_eq!(tree.neighbor_left(0), None);
1406        assert_eq!(tree.neighbor_left(1), Some(0));
1407        assert_eq!(tree.neighbor_left(2), Some(1));
1408    }
1409
1410    #[test]
1411    fn neighbor_right_three_leaf_vertical() {
1412        let tree = vsplit(0.5, leaf(0), vsplit(0.5, leaf(1), leaf(2)));
1413        assert_eq!(tree.neighbor_right(0), Some(1));
1414        assert_eq!(tree.neighbor_right(1), Some(2));
1415        assert_eq!(tree.neighbor_right(2), None);
1416    }
1417
1418    // ── next_leaf() / prev_leaf() ─────────────────────────────────────────────
1419
1420    #[test]
1421    fn next_leaf_cycles_through_all_leaves() {
1422        let tree = vsplit(0.5, leaf(0), hsplit(0.5, leaf(1), leaf(2)));
1423        assert_eq!(tree.next_leaf(0), Some(1));
1424        assert_eq!(tree.next_leaf(1), Some(2));
1425        assert_eq!(tree.next_leaf(2), Some(0));
1426    }
1427
1428    #[test]
1429    fn prev_leaf_wraps_around() {
1430        let tree = vsplit(0.5, leaf(0), hsplit(0.5, leaf(1), leaf(2)));
1431        assert_eq!(tree.prev_leaf(0), Some(2));
1432        assert_eq!(tree.prev_leaf(1), Some(0));
1433        assert_eq!(tree.prev_leaf(2), Some(1));
1434    }
1435
1436    #[test]
1437    fn next_leaf_single_leaf_wraps_to_self() {
1438        let tree = leaf(0);
1439        assert_eq!(tree.next_leaf(0), Some(0));
1440    }
1441
1442    #[test]
1443    fn next_prev_returns_none_for_unknown_id() {
1444        let tree = vsplit(0.5, leaf(0), leaf(1));
1445        assert_eq!(tree.next_leaf(99), None);
1446        assert_eq!(tree.prev_leaf(99), None);
1447    }
1448
1449    // ── enclosing_split_mut() ────────────────────────────────────────────────
1450
1451    #[test]
1452    fn enclosing_split_mut_returns_innermost() {
1453        // outer: hsplit 0 / inner: hsplit 1 / 2
1454        let outer_rect = LayoutRect::new(0, 0, 80, 40);
1455        let inner_rect = LayoutRect::new(0, 20, 80, 20);
1456        let mut tree = hsplit_with_rect(
1457            0.4,
1458            leaf(0),
1459            hsplit_with_rect(0.6, leaf(1), leaf(2), inner_rect),
1460            outer_rect,
1461        );
1462        let result = tree.enclosing_split_mut(1, SplitDir::Horizontal);
1463        assert!(result.is_some(), "should find enclosing horizontal split");
1464        let (ratio, rect, in_a) = result.unwrap();
1465        assert!(
1466            (*ratio - 0.6).abs() < 1e-5,
1467            "innermost split ratio should be 0.6, got {ratio}"
1468        );
1469        assert_eq!(
1470            rect,
1471            Some(inner_rect),
1472            "should return inner rect, not outer"
1473        );
1474        assert!(in_a, "id=1 is in the 'a' side of the inner split");
1475    }
1476
1477    #[test]
1478    fn enclosing_split_mut_skips_wrong_dir() {
1479        let mut tree = vsplit(0.5, leaf(0), leaf(1));
1480        let result = tree.enclosing_split_mut(0, SplitDir::Horizontal);
1481        assert!(
1482            result.is_none(),
1483            "should not match a Vertical split for Horizontal dir"
1484        );
1485    }
1486
1487    #[test]
1488    fn enclosing_split_mut_returns_none_for_only_leaf() {
1489        let mut tree = leaf(0);
1490        let result = tree.enclosing_split_mut(0, SplitDir::Horizontal);
1491        assert!(result.is_none(), "single leaf has no enclosing split");
1492    }
1493
1494    #[test]
1495    fn equalize_all_resets_nested_splits_to_half() {
1496        let mut tree = hsplit(0.3, leaf(0), hsplit(0.7, leaf(1), leaf(2)));
1497        tree.equalize_all(&[]);
1498        fn check_all_half(t: &LayoutTree) {
1499            if let LayoutTree::Split { ratio, a, b, .. } = t {
1500                assert!(
1501                    (ratio - 0.5).abs() < 1e-5,
1502                    "ratio should be 0.5, got {ratio}"
1503                );
1504                check_all_half(a);
1505                check_all_half(b);
1506            }
1507        }
1508        check_all_half(&tree);
1509    }
1510
1511    #[test]
1512    fn for_each_ancestor_visits_outermost_first() {
1513        let outer_rect = LayoutRect::new(0, 0, 80, 24);
1514        let inner_rect = LayoutRect::new(24, 0, 56, 24);
1515        let mut tree = vsplit_with_rect(
1516            0.3,
1517            leaf(0),
1518            hsplit_with_rect(0.7, leaf(1), leaf(2), inner_rect),
1519            outer_rect,
1520        );
1521        let mut visited_dirs: Vec<SplitDir> = Vec::new();
1522        let mut visited_ratios: Vec<f32> = Vec::new();
1523        tree.for_each_ancestor(1, &mut |dir, ratio, _in_a, _rect| {
1524            visited_dirs.push(dir);
1525            visited_ratios.push(*ratio);
1526        });
1527        assert_eq!(
1528            visited_dirs,
1529            vec![SplitDir::Vertical, SplitDir::Horizontal],
1530            "outermost (Vertical) should be visited first"
1531        );
1532        assert!(
1533            (visited_ratios[0] - 0.3).abs() < 1e-5,
1534            "outer ratio should be 0.3"
1535        );
1536        assert!(
1537            (visited_ratios[1] - 0.7).abs() < 1e-5,
1538            "inner ratio should be 0.7"
1539        );
1540    }
1541
1542    // ── swap_with_sibling() ───────────────────────────────────────────────────
1543
1544    #[test]
1545    fn swap_with_sibling_swaps_two_leaves() {
1546        let mut tree = hsplit(0.5, leaf(0), leaf(1));
1547        let swapped = tree.swap_with_sibling(0, &[]);
1548        assert!(swapped, "swap should succeed in a two-leaf split");
1549        assert_eq!(tree.leaves(), vec![1, 0], "leaves should be swapped");
1550    }
1551
1552    #[test]
1553    fn swap_with_sibling_in_nested_split_swaps_at_focused_parent() {
1554        let mut tree = hsplit(0.5, leaf(0), vsplit(0.5, leaf(1), leaf(2)));
1555        let swapped = tree.swap_with_sibling(1, &[]);
1556        assert!(swapped, "swap should succeed");
1557        assert_eq!(
1558            tree.leaves(),
1559            vec![0, 2, 1],
1560            "inner leaves should be swapped"
1561        );
1562    }
1563
1564    #[test]
1565    fn swap_with_sibling_returns_false_for_only_leaf() {
1566        let mut tree = leaf(0);
1567        let swapped = tree.swap_with_sibling(0, &[]);
1568        assert!(!swapped, "single leaf has no sibling to swap with");
1569    }
1570
1571    #[test]
1572    fn swap_with_sibling_refuses_when_the_moving_leaf_is_pinned() {
1573        let mut tree = vsplit(0.5, leaf(9), leaf(0));
1574        let swapped = tree.swap_with_sibling(9, &[9]);
1575        assert!(!swapped, "a pinned leaf must not move");
1576        assert_eq!(tree.leaves(), vec![9, 0], "tree must be untouched");
1577    }
1578
1579    #[test]
1580    fn swap_with_sibling_refuses_when_the_sibling_is_pinned() {
1581        let mut tree = vsplit(0.5, leaf(9), leaf(0));
1582        let swapped = tree.swap_with_sibling(0, &[9]);
1583        assert!(!swapped, "must not swap a pinned sibling out of place");
1584        assert_eq!(tree.leaves(), vec![9, 0], "tree must be untouched");
1585    }
1586
1587    #[test]
1588    fn swap_with_sibling_refuses_when_the_sibling_subtree_holds_a_pin() {
1589        // 0 | (9 | 1) — swapping 0 with its sibling would drag the pinned 9.
1590        let mut tree = vsplit(0.5, leaf(0), vsplit(0.5, leaf(9), leaf(1)));
1591        let swapped = tree.swap_with_sibling(0, &[9]);
1592        assert!(!swapped, "a pin anywhere in the sibling blocks the swap");
1593        assert_eq!(tree.leaves(), vec![0, 9, 1]);
1594    }
1595
1596    #[test]
1597    fn swap_with_sibling_still_works_beside_an_unrelated_pin() {
1598        // 9 | (0 | 1) — swapping 0 and 1 leaves the pinned 9 where it is.
1599        let mut tree = vsplit(0.5, leaf(9), vsplit(0.5, leaf(0), leaf(1)));
1600        let swapped = tree.swap_with_sibling(0, &[9]);
1601        assert!(swapped, "the pin is not on either side of this swap");
1602        assert_eq!(tree.leaves(), vec![9, 1, 0]);
1603    }
1604
1605    // ── equalize_all() with pins ──────────────────────────────────────────────
1606
1607    #[test]
1608    fn equalize_all_leaves_a_pinned_leaf_at_its_size() {
1609        // dock | (1 / 2), dock fixed at 30 columns.
1610        let area = LayoutRect::new(0, 0, 80, 24);
1611        let mut tree = LayoutTree::split_fixed(
1612            SplitDir::Vertical,
1613            0.9,
1614            Fixed::First(30),
1615            leaf(9),
1616            hsplit(0.8, leaf(1), leaf(2)),
1617        );
1618        let dock_before = tree.window_rects(area)[0].1;
1619        tree.equalize_all(&[9]);
1620        let after = tree.window_rects(area);
1621        assert_eq!(after[0].0, 9);
1622        assert_eq!(after[0].1, dock_before, "pinned dock must keep its rect");
1623        // The dock's own split kept its ratio; the regular split below did not.
1624        assert!((root_ratio(&tree) - 0.9).abs() < 1e-5);
1625        if let LayoutTree::Split { b, .. } = &tree {
1626            assert!(
1627                (root_ratio(b) - 0.5).abs() < 1e-5,
1628                "ordinary splits under a pin still equalize"
1629            );
1630        } else {
1631            panic!("root should still be a split");
1632        }
1633    }
1634
1635    #[test]
1636    fn equalize_all_protects_a_ratio_split_next_to_a_pinned_leaf() {
1637        // No `fixed` here — the pin alone must stop the ratio being reset.
1638        let mut tree = vsplit(0.2, leaf(9), leaf(0));
1639        tree.equalize_all(&[9]);
1640        assert!(
1641            (root_ratio(&tree) - 0.2).abs() < 1e-5,
1642            "a split with a pinned child keeps its ratio"
1643        );
1644    }
1645
1646    // ── only() ────────────────────────────────────────────────────────────────
1647
1648    #[test]
1649    fn only_collapses_to_the_kept_leaf_when_nothing_is_pinned() {
1650        let mut tree = hsplit(0.5, leaf(0), vsplit(0.5, leaf(1), leaf(2)));
1651        let mut removed = tree.only(1, &[]);
1652        removed.sort_unstable();
1653        assert_eq!(removed, vec![0, 2]);
1654        assert_eq!(tree.leaves(), vec![1]);
1655    }
1656
1657    #[test]
1658    fn only_retains_pinned_leaves_and_their_arrangement() {
1659        // dock | ((1 / 2) / qf) → only(1) keeps dock | (1 / qf), in that order.
1660        let mut tree = vsplit(
1661            0.5,
1662            leaf(9),
1663            hsplit(0.5, hsplit(0.5, leaf(1), leaf(2)), leaf(8)),
1664        );
1665        let removed = tree.only(1, &[9, 8]);
1666        assert_eq!(removed, vec![2]);
1667        assert_eq!(
1668            tree.leaves(),
1669            vec![9, 1, 8],
1670            "dock stays left of the kept window, quickfix stays below it"
1671        );
1672    }
1673
1674    #[test]
1675    fn only_on_a_single_leaf_is_a_no_op() {
1676        let mut tree = leaf(0);
1677        assert!(tree.only(0, &[]).is_empty());
1678        assert_eq!(tree.leaves(), vec![0]);
1679    }
1680
1681    #[test]
1682    fn only_with_an_absent_keep_changes_nothing() {
1683        let mut tree = hsplit(0.5, leaf(0), leaf(1));
1684        assert!(tree.only(99, &[]).is_empty());
1685        assert_eq!(tree.leaves(), vec![0, 1]);
1686    }
1687
1688    #[test]
1689    fn only_keeping_a_pinned_leaf_drops_the_rest() {
1690        let mut tree = vsplit(0.5, leaf(9), hsplit(0.5, leaf(0), leaf(1)));
1691        let mut removed = tree.only(9, &[9]);
1692        removed.sort_unstable();
1693        assert_eq!(removed, vec![0, 1]);
1694        assert_eq!(tree.leaves(), vec![9]);
1695    }
1696
1697    #[test]
1698    fn only_preserves_the_geometry_of_the_surviving_split() {
1699        let mut tree = vsplit(0.25, leaf(9), hsplit(0.5, leaf(0), leaf(1)));
1700        tree.only(0, &[9]);
1701        assert!(
1702            (root_ratio(&tree) - 0.25).abs() < 1e-5,
1703            "the split joining the retained leaves keeps its ratio"
1704        );
1705    }
1706
1707    // ── fixed sizing ──────────────────────────────────────────────────────────
1708
1709    #[test]
1710    fn fixed_first_renders_exact_cells_along_a_vertical_split() {
1711        // The dock renders the full 30 columns it asked for; the separator
1712        // costs the sibling, not the dock.
1713        let tree =
1714            LayoutTree::split_fixed(SplitDir::Vertical, 0.5, Fixed::First(30), leaf(0), leaf(1));
1715        let rects = tree.window_rects(LayoutRect::new(0, 0, 80, 24));
1716        assert_eq!(rects[0].1, LayoutRect::new(0, 0, 30, 24));
1717        assert_eq!(rects[1].1, LayoutRect::new(31, 0, 49, 24));
1718    }
1719
1720    #[test]
1721    fn fixed_second_renders_exact_cells_along_a_vertical_split() {
1722        // Mirror image of the `First` case: same requested size, same rendered
1723        // size, only the side differs. `Fixed` must not mean two things.
1724        let tree =
1725            LayoutTree::split_fixed(SplitDir::Vertical, 0.5, Fixed::Second(30), leaf(0), leaf(1));
1726        let rects = tree.window_rects(LayoutRect::new(0, 0, 80, 24));
1727        assert_eq!(
1728            rects[1].1.w, 30,
1729            "Second(30) must render 30, like First(30)"
1730        );
1731        assert_eq!(rects[0].1, LayoutRect::new(0, 0, 49, 24));
1732        assert_eq!(rects[1].1, LayoutRect::new(50, 0, 30, 24));
1733    }
1734
1735    #[test]
1736    fn fixed_first_and_second_render_the_same_size_on_both_axes() {
1737        let area = LayoutRect::new(0, 0, 80, 24);
1738        let along = |dir: SplitDir, r: LayoutRect| match dir.axis() {
1739            Axis::Col => r.w,
1740            Axis::Row => r.h,
1741        };
1742        for dir in [SplitDir::Vertical, SplitDir::Horizontal] {
1743            for n in [1u16, 2, 10, 20] {
1744                let first = LayoutTree::split_fixed(dir, 0.5, Fixed::First(n), leaf(0), leaf(1));
1745                let second = LayoutTree::split_fixed(dir, 0.5, Fixed::Second(n), leaf(0), leaf(1));
1746                assert_eq!(
1747                    along(dir, first.window_rects(area)[0].1),
1748                    n,
1749                    "First({n}) on {dir:?} must render {n}"
1750                );
1751                assert_eq!(
1752                    along(dir, second.window_rects(area)[1].1),
1753                    n,
1754                    "Second({n}) on {dir:?} must render {n}"
1755                );
1756            }
1757        }
1758    }
1759
1760    #[test]
1761    fn fixed_second_renders_exact_cells_along_a_horizontal_split() {
1762        let tree = LayoutTree::split_fixed(
1763            SplitDir::Horizontal,
1764            0.5,
1765            Fixed::Second(10),
1766            leaf(0),
1767            leaf(1),
1768        );
1769        let rects = tree.window_rects(LayoutRect::new(0, 0, 80, 24));
1770        assert_eq!(rects[0].1, LayoutRect::new(0, 0, 80, 13));
1771        assert_eq!(rects[1].1, LayoutRect::new(0, 14, 80, 10));
1772    }
1773
1774    #[test]
1775    fn fixed_wins_over_ratio() {
1776        let ratio_only = LayoutTree::split(SplitDir::Vertical, 0.5, leaf(0), leaf(1));
1777        let fixed =
1778            LayoutTree::split_fixed(SplitDir::Vertical, 0.5, Fixed::First(20), leaf(0), leaf(1));
1779        let area = LayoutRect::new(0, 0, 80, 24);
1780        assert_eq!(ratio_only.window_rects(area)[0].1.w, 39);
1781        assert_eq!(fixed.window_rects(area)[0].1.w, 20);
1782    }
1783
1784    #[test]
1785    fn fixed_is_independent_of_the_parent_size() {
1786        let tree =
1787            LayoutTree::split_fixed(SplitDir::Vertical, 0.5, Fixed::First(30), leaf(0), leaf(1));
1788        for total in [60u16, 80, 120, 200] {
1789            let rects = tree.window_rects(LayoutRect::new(0, 0, total, 24));
1790            assert_eq!(rects[0].1.w, 30, "dock width must not track the parent");
1791            assert_eq!(rects[1].1.w, total - 31);
1792        }
1793    }
1794
1795    #[test]
1796    fn fixed_renders_the_requested_size_when_no_separator_is_carved() {
1797        // A 2-cell axis is too small for a separator (`rect_a < 2`), so the
1798        // `+ 1` the `First` path adds must be absorbed by the clamp rather
1799        // than stealing the sibling's only cell.
1800        let area = LayoutRect::new(0, 0, 2, 2);
1801        for dir in [SplitDir::Vertical, SplitDir::Horizontal] {
1802            let first = LayoutTree::split_fixed(dir, 0.5, Fixed::First(1), leaf(0), leaf(1));
1803            let second = LayoutTree::split_fixed(dir, 0.5, Fixed::Second(1), leaf(0), leaf(1));
1804            for tree in [first, second] {
1805                let rects = tree.window_rects(area);
1806                let (a, b) = (rects[0].1, rects[1].1);
1807                let (a_len, b_len) = match dir.axis() {
1808                    Axis::Col => (a.w, b.w),
1809                    Axis::Row => (a.h, b.h),
1810                };
1811                assert_eq!((a_len, b_len), (1, 1), "{dir:?}: both children render 1");
1812            }
1813        }
1814
1815        // One cell more and the separator does get carved — the requested size
1816        // is still exactly what renders, on both sides.
1817        let area = LayoutRect::new(0, 0, 3, 24);
1818        let first =
1819            LayoutTree::split_fixed(SplitDir::Vertical, 0.5, Fixed::First(1), leaf(0), leaf(1));
1820        let rects = first.window_rects(area);
1821        assert_eq!(rects[0].1, LayoutRect::new(0, 0, 1, 24));
1822        assert_eq!(rects[1].1, LayoutRect::new(2, 0, 1, 24));
1823        let second =
1824            LayoutTree::split_fixed(SplitDir::Vertical, 0.5, Fixed::Second(1), leaf(0), leaf(1));
1825        let rects = second.window_rects(area);
1826        assert_eq!(rects[0].1, LayoutRect::new(0, 0, 1, 24));
1827        assert_eq!(rects[1].1, LayoutRect::new(2, 0, 1, 24));
1828    }
1829
1830    #[test]
1831    fn oversized_fixed_clamps_to_leave_the_sibling_one_cell() {
1832        let area = LayoutRect::new(0, 0, 80, 24);
1833
1834        // First(200) in an 80-column area → a is clamped to 79 allocated cells
1835        // (78 after the separator) and b keeps exactly 1.
1836        let first =
1837            LayoutTree::split_fixed(SplitDir::Vertical, 0.5, Fixed::First(200), leaf(0), leaf(1));
1838        let rects = first.window_rects(area);
1839        assert_eq!(rects[0].1.w, 78);
1840        assert_eq!(rects[1].1.w, 1);
1841
1842        // Second(200) is the mirror image: `a` keeps 1 allocated cell (no
1843        // separator is carved, since a.w < 2) and `b` takes the other 79.
1844        let second = LayoutTree::split_fixed(
1845            SplitDir::Vertical,
1846            0.5,
1847            Fixed::Second(200),
1848            leaf(0),
1849            leaf(1),
1850        );
1851        let rects = second.window_rects(area);
1852        assert_eq!(rects[0].1.w, 1);
1853        assert_eq!(rects[1].1.w, 79);
1854        assert_eq!(
1855            rects[0].1.w + rects[1].1.w,
1856            80,
1857            "no cells may be lost or invented"
1858        );
1859    }
1860
1861    #[test]
1862    fn fixed_on_a_degenerate_area_does_not_underflow() {
1863        // u16::MAX request against a 1-cell and a 0-cell axis: must not panic
1864        // and must not wrap.
1865        for dir in [SplitDir::Vertical, SplitDir::Horizontal] {
1866            for fixed in [Fixed::First(u16::MAX), Fixed::Second(u16::MAX)] {
1867                for area in [
1868                    LayoutRect::new(0, 0, 0, 0),
1869                    LayoutRect::new(0, 0, 1, 1),
1870                    LayoutRect::new(0, 0, 2, 2),
1871                ] {
1872                    let tree = LayoutTree::split_fixed(dir, 0.5, fixed, leaf(0), leaf(1));
1873                    let rects = tree.window_rects(area);
1874                    let (a, b) = (rects[0].1, rects[1].1);
1875                    assert!(a.w <= area.w && b.w <= area.w, "child wider than parent");
1876                    assert!(a.h <= area.h && b.h <= area.h, "child taller than parent");
1877                }
1878            }
1879        }
1880    }
1881
1882    #[test]
1883    fn fixed_nested_under_an_ordinary_split() {
1884        // (0 | dock-fixed(20 rows)) stacked over 1.
1885        let tree = hsplit(
1886            0.5,
1887            LayoutTree::split_fixed(SplitDir::Vertical, 0.5, Fixed::Second(20), leaf(0), leaf(9)),
1888            leaf(1),
1889        );
1890        let rects = tree.window_rects(LayoutRect::new(0, 0, 80, 24));
1891        assert_eq!(rects.len(), 3);
1892        let dock = rects.iter().find(|(id, _)| *id == 9).unwrap().1;
1893        assert_eq!(dock.w, 20, "nested fixed child keeps its exact width");
1894    }
1895
1896    // ── mixed_layout_navigation ───────────────────────────────────────────────
1897
1898    #[test]
1899    fn mixed_layout_navigation() {
1900        // Layout:
1901        //   ┌───┬───┐
1902        //   │ 0 │ 1 │
1903        //   ├───┴───┤
1904        //   │   2   │
1905        //   └───────┘
1906        let tree = hsplit(0.5, vsplit(0.5, leaf(0), leaf(1)), leaf(2));
1907
1908        assert_eq!(tree.neighbor_right(0), Some(1));
1909        assert_eq!(tree.neighbor_left(1), Some(0));
1910        assert_eq!(tree.neighbor_right(1), None);
1911        assert_eq!(tree.neighbor_left(0), None);
1912
1913        assert_eq!(tree.neighbor_below(0), Some(2));
1914        assert_eq!(tree.neighbor_below(1), Some(2));
1915        assert_eq!(tree.neighbor_below(2), None);
1916        assert_eq!(tree.neighbor_above(2), Some(1));
1917        assert_eq!(tree.neighbor_above(0), None);
1918        assert_eq!(tree.neighbor_above(1), None);
1919
1920        assert_eq!(tree.next_leaf(0), Some(1));
1921        assert_eq!(tree.next_leaf(1), Some(2));
1922        assert_eq!(tree.next_leaf(2), Some(0));
1923        assert_eq!(tree.prev_leaf(0), Some(2));
1924        assert_eq!(tree.prev_leaf(2), Some(1));
1925    }
1926
1927    // ── window_rects() ────────────────────────────────────────────────────────
1928
1929    #[test]
1930    fn window_rects_single_leaf_gets_full_area() {
1931        let tree = leaf(0);
1932        let area = LayoutRect::new(0, 0, 80, 23);
1933        let rects = tree.window_rects(area);
1934        assert_eq!(rects, vec![(0, area)]);
1935    }
1936
1937    #[test]
1938    fn window_rects_vsplit_two_side_by_side() {
1939        // Vertical split (side-by-side): area.w=80, ratio=0.5
1940        // a_w = round(80 * 0.5) = 40, clamped => 40
1941        // b_w = 80 - 40 = 40
1942        // Separator: rect_a.w(40) >= 2 and rect_b.w(40) > 0 → rect_a.w = 39
1943        // rect_a = (0,0,39,23), rect_b = (40,0,40,23)
1944        let tree = vsplit(0.5, leaf(0), leaf(1));
1945        let area = LayoutRect::new(0, 0, 80, 23);
1946        let rects = tree.window_rects(area);
1947        assert_eq!(rects.len(), 2);
1948        let (id_a, ra) = rects[0];
1949        let (id_b, rb) = rects[1];
1950        assert_eq!(id_a, 0);
1951        assert_eq!(id_b, 1);
1952        // widths: 39 + 1 (sep) + 40 = 80
1953        assert_eq!(
1954            ra.w + 1 + rb.w,
1955            80,
1956            "widths + separator must sum to parent width"
1957        );
1958        assert_eq!(ra.h, 23);
1959        assert_eq!(rb.h, 23);
1960        // rect_b starts right after rect_a + separator
1961        assert_eq!(rb.x, ra.x + ra.w + 1);
1962    }
1963
1964    #[test]
1965    fn window_rects_hsplit_stacked() {
1966        // Horizontal split (stacked): area.h=23, ratio=0.5
1967        // a_h = round(23 * 0.5) = 12 (banker's rounding on some platforms; 11.5 → 12)
1968        // Actually: 23 * 0.5 = 11.5, round() = 12 in Rust (round half away from zero)
1969        // b_h = 23 - 12 = 11
1970        // Separator: rect_a.h(12) >= 2 and rect_b.h(11) > 0 → rect_a.h = 11
1971        let tree = hsplit(0.5, leaf(0), leaf(1));
1972        let area = LayoutRect::new(0, 0, 80, 23);
1973        let rects = tree.window_rects(area);
1974        assert_eq!(rects.len(), 2);
1975        let (_, ra) = rects[0];
1976        let (_, rb) = rects[1];
1977        // heights: ra.h + 1 (sep) + rb.h == area.h
1978        assert_eq!(
1979            ra.h + 1 + rb.h,
1980            23,
1981            "heights + separator must sum to parent height"
1982        );
1983        assert_eq!(ra.w, 80);
1984        assert_eq!(rb.w, 80);
1985        // rect_b starts after rect_a + separator row
1986        assert_eq!(rb.y, ra.y + ra.h + 1);
1987    }
1988
1989    #[test]
1990    fn window_rects_nested_vsplit_inside_vsplit() {
1991        // vsplit(0.5, leaf(0), vsplit(0.5, leaf(1), leaf(2))) over 80x23
1992        // Outer: a_w=40, sep → rect_a.w=39; b_w=40 starting at x=40
1993        // Inner (over 40-wide area starting at x=40): a_w=20, sep → 19; b_w=20 at x=60
1994        let tree = vsplit(0.5, leaf(0), vsplit(0.5, leaf(1), leaf(2)));
1995        let area = LayoutRect::new(0, 0, 80, 23);
1996        let rects = tree.window_rects(area);
1997        assert_eq!(rects.len(), 3);
1998        // total width coverage: sum(widths) + 2 separators = 80
1999        let total_w: u16 = rects.iter().map(|(_, r)| r.w).sum::<u16>() + 2;
2000        assert_eq!(total_w, 80, "all window widths + 2 separators == 80");
2001    }
2002
2003    // ── remove_leaf error message ─────────────────────────────────────────────
2004
2005    #[test]
2006    fn remove_leaf_error_contains_e444() {
2007        let mut tree = leaf(0);
2008        let err = tree.remove_leaf(0).unwrap_err();
2009        assert!(err.contains("E444"), "error must mention E444, got: {err}");
2010    }
2011
2012    // ── Layout update via enclosing_split_mut ─────────────────────────────────
2013
2014    #[test]
2015    fn enclosing_split_mut_ratio_update_persists() {
2016        let mut tree = hsplit(0.5, leaf(0), leaf(1));
2017        {
2018            let (ratio, _, _) = tree.enclosing_split_mut(0, SplitDir::Horizontal).unwrap();
2019            *ratio = 0.75;
2020        }
2021        // Verify the ratio was actually mutated.
2022        if let LayoutTree::Split { ratio, .. } = &tree {
2023            assert!((*ratio - 0.75).abs() < 1e-5, "ratio should now be 0.75");
2024        } else {
2025            panic!("tree should still be a Split");
2026        }
2027    }
2028}
2029
2030#[cfg(test)]
2031mod fixed_sizing_sweep {
2032    use super::*;
2033
2034    /// Exhaustive sweep of the `Fixed` contract, added during review of the
2035    /// original implementation — which allocated cells rather than rendered
2036    /// cells, so `First(n)` came out one short while `Second(n)` was exact.
2037    /// A per-case example test let that through; only a sweep makes the
2038    /// invariant unmissable.
2039    ///
2040    /// Two properties, over axis lengths 0..40 and requests 0..45:
2041    ///
2042    /// - **Everywhere** (degenerate sizes included): neither child may exceed
2043    ///   the parent, and nothing may panic or underflow.
2044    /// - **Where the request is satisfiable** (axis holds two children plus
2045    ///   the separator): both variants render EXACTLY the requested size, so
2046    ///   a config-sourced width needs no caller-side compensation.
2047    ///
2048    /// Outside that domain the two sides legitimately differ: clamping has to
2049    /// pick a side to starve, and which one depends on the variant.
2050    #[test]
2051    fn fixed_renders_requested_size_and_never_exceeds_parent() {
2052        let mut asym = Vec::new();
2053        for len in 0u16..40 {
2054            for n in 0u16..45 {
2055                for dir in [SplitDir::Vertical, SplitDir::Horizontal] {
2056                    let area = match dir.axis() {
2057                        Axis::Col => LayoutRect::new(0, 0, len, 10),
2058                        Axis::Row => LayoutRect::new(0, 0, 10, len),
2059                    };
2060                    let ext = |r: LayoutRect| match dir.axis() {
2061                        Axis::Col => r.w,
2062                        Axis::Row => r.h,
2063                    };
2064                    let fa = split_geometry(area, dir, 0.5, Some(Fixed::First(n))).a;
2065                    let sb = split_geometry(area, dir, 0.5, Some(Fixed::Second(n))).b;
2066                    let (first, second) = (ext(fa), ext(sb));
2067                    // Meaningful domain only: the axis must hold two
2068                    // children plus the separator, and the request must fit.
2069                    let meaningful = len >= 3 && n >= 1 && n < len - 1;
2070                    if meaningful && first != second {
2071                        asym.push((len, n, format!("{dir:?}"), first, second));
2072                    }
2073                    if meaningful {
2074                        assert_eq!(first, n, "First({n}) on len {len} rendered {first}");
2075                        assert_eq!(second, n, "Second({n}) on len {len} rendered {second}");
2076                    }
2077                    assert!(first <= len && second <= len, "child exceeds parent");
2078                }
2079            }
2080        }
2081        assert!(
2082            asym.is_empty(),
2083            "First/Second render differently in {} cases, e.g. {:?}",
2084            asym.len(),
2085            &asym[..asym.len().min(6)]
2086        );
2087    }
2088
2089    // ── split_geometry: the separator (#63 Phase 2) ───────────────────────────
2090
2091    /// `a`, the separator and `b` must tile the parent exactly, with no gap and
2092    /// no overlap — for ratio splits and fixed splits alike. An off-by-one here
2093    /// is a divider drawn over a window's last column, or one the user can see
2094    /// but not grab.
2095    #[test]
2096    fn split_geometry_children_and_separator_tile_the_parent() {
2097        let area = LayoutRect::new(3, 5, 40, 20);
2098        let fixings = [
2099            None,
2100            Some(Fixed::First(10)),
2101            Some(Fixed::Second(10)),
2102            Some(Fixed::First(1)),
2103            Some(Fixed::Second(1)),
2104        ];
2105        for dir in [SplitDir::Vertical, SplitDir::Horizontal] {
2106            for fixed in fixings {
2107                let geo = split_geometry(area, dir, 0.5, fixed);
2108                let sep = geo
2109                    .separator
2110                    .unwrap_or_else(|| panic!("{dir:?}/{fixed:?} must fit a separator"));
2111                match dir.axis() {
2112                    Axis::Col => {
2113                        assert_eq!(geo.a.x, area.x, "a starts at the parent's left edge");
2114                        assert_eq!(sep.x, geo.a.x + geo.a.w, "separator abuts a's right edge");
2115                        assert_eq!(sep.w, 1, "separator is one column");
2116                        assert_eq!(geo.b.x, sep.x + 1, "b starts right after the separator");
2117                        assert_eq!(
2118                            geo.b.x + geo.b.w,
2119                            area.x + area.w,
2120                            "b reaches the right edge"
2121                        );
2122                        assert_eq!((sep.y, sep.h), (area.y, area.h), "separator spans the rows");
2123                    }
2124                    Axis::Row => {
2125                        assert_eq!(geo.a.y, area.y, "a starts at the parent's top edge");
2126                        assert_eq!(sep.y, geo.a.y + geo.a.h, "separator abuts a's bottom edge");
2127                        assert_eq!(sep.h, 1, "separator is one row");
2128                        assert_eq!(geo.b.y, sep.y + 1, "b starts right after the separator");
2129                        assert_eq!(
2130                            geo.b.y + geo.b.h,
2131                            area.y + area.h,
2132                            "b reaches the bottom edge"
2133                        );
2134                        assert_eq!(
2135                            (sep.x, sep.w),
2136                            (area.x, area.w),
2137                            "separator spans the columns"
2138                        );
2139                    }
2140                }
2141            }
2142        }
2143    }
2144
2145    /// The separator is reported only when it is actually drawn: an area with
2146    /// no room for one yields `None`, not a phantom border cell.
2147    #[test]
2148    fn split_geometry_reports_no_separator_when_none_is_drawn() {
2149        // 1 column: `a` gets the single column, `b` gets nothing.
2150        let geo = split_geometry(LayoutRect::new(0, 0, 1, 10), SplitDir::Vertical, 0.5, None);
2151        assert_eq!(geo.separator, None);
2152        // Same along the row axis.
2153        let geo = split_geometry(
2154            LayoutRect::new(0, 0, 10, 1),
2155            SplitDir::Horizontal,
2156            0.5,
2157            None,
2158        );
2159        assert_eq!(geo.separator, None);
2160    }
2161
2162    // ── Fixed splits refuse resizing (#63 Phase 2) ────────────────────────────
2163
2164    /// A fixed split is never the target of a resize command: the search walks
2165    /// past it to the nearest resizable ancestor (vim's `winfixwidth`).
2166    #[test]
2167    fn enclosing_split_mut_skips_fixed_splits() {
2168        // Vertical(ratio 0.25) { leaf 9 , Vertical(fixed) { leaf 0, leaf 1 } }
2169        let inner = LayoutTree::Split {
2170            dir: SplitDir::Vertical,
2171            ratio: 0.5,
2172            fixed: Some(Fixed::First(20)),
2173            a: Box::new(LayoutTree::Leaf(0)),
2174            b: Box::new(LayoutTree::Leaf(1)),
2175            last_rect: Some(LayoutRect::new(20, 0, 60, 24)),
2176        };
2177        let mut tree = LayoutTree::Split {
2178            dir: SplitDir::Vertical,
2179            ratio: 0.25,
2180            fixed: None,
2181            a: Box::new(LayoutTree::Leaf(9)),
2182            b: Box::new(inner),
2183            last_rect: Some(LayoutRect::new(0, 0, 80, 24)),
2184        };
2185
2186        let (ratio, rect, in_a) = tree
2187            .enclosing_split_mut(0, SplitDir::Vertical)
2188            .expect("the outer ratio split is still resizable");
2189        assert!(
2190            (*ratio - 0.25).abs() < 1e-5,
2191            "must be the OUTER split's ratio"
2192        );
2193        assert_eq!(rect, Some(LayoutRect::new(0, 0, 80, 24)));
2194        assert!(!in_a, "leaf 0 lives in the outer split's `b` branch");
2195    }
2196
2197    /// When the *only* enclosing split is fixed there is nothing to resize —
2198    /// `<C-w><` becomes a no-op rather than silently rewriting a dead ratio.
2199    #[test]
2200    fn enclosing_split_mut_returns_none_for_a_lone_fixed_split() {
2201        let mut tree = LayoutTree::Split {
2202            dir: SplitDir::Vertical,
2203            ratio: 0.5,
2204            fixed: Some(Fixed::First(20)),
2205            a: Box::new(LayoutTree::Leaf(0)),
2206            b: Box::new(LayoutTree::Leaf(1)),
2207            last_rect: Some(LayoutRect::new(0, 0, 80, 24)),
2208        };
2209        assert!(tree.enclosing_split_mut(0, SplitDir::Vertical).is_none());
2210        assert!(tree.enclosing_split_mut(1, SplitDir::Vertical).is_none());
2211    }
2212
2213    /// `for_each_ancestor` (maximize height/width) skips fixed splits too, so a
2214    /// dock can't be squashed to one cell by `<C-w>_` in a neighbouring pane.
2215    #[test]
2216    fn for_each_ancestor_skips_fixed_splits() {
2217        let inner = LayoutTree::Split {
2218            dir: SplitDir::Vertical,
2219            ratio: 0.7,
2220            fixed: Some(Fixed::First(20)),
2221            a: Box::new(LayoutTree::Leaf(1)),
2222            b: Box::new(LayoutTree::Leaf(2)),
2223            last_rect: Some(LayoutRect::new(0, 12, 80, 12)),
2224        };
2225        let mut tree = LayoutTree::Split {
2226            dir: SplitDir::Horizontal,
2227            ratio: 0.3,
2228            fixed: None,
2229            a: Box::new(LayoutTree::Leaf(0)),
2230            b: Box::new(inner),
2231            last_rect: Some(LayoutRect::new(0, 0, 80, 24)),
2232        };
2233
2234        let mut seen = Vec::new();
2235        tree.for_each_ancestor(1, &mut |dir, ratio, _in_a, _rect| {
2236            seen.push((dir, *ratio));
2237            *ratio = 0.9;
2238        });
2239        assert_eq!(seen.len(), 1, "only the non-fixed ancestor is visited");
2240        assert_eq!(seen[0].0, SplitDir::Horizontal);
2241        // The fixed split's ratio survived untouched.
2242        let LayoutTree::Split { b, .. } = &tree else {
2243            panic!("expected a split at the root");
2244        };
2245        let LayoutTree::Split { ratio, .. } = b.as_ref() else {
2246            panic!("expected a split at b");
2247        };
2248        assert!((*ratio - 0.7).abs() < 1e-5, "fixed split's ratio untouched");
2249    }
2250}