Skip to main content

lattice_core/ui/
pane.rs

1//! Pane tree (DESIGN.md §5.9 multi-buffer foundations).
2//!
3//! v1 status (B.1.b): a recursive binary-split tree of leaf panes;
4//! each leaf stashes per-pane viewport state (cursor + scroll) for
5//! its content buffer. The *active* pane's cursor / scroll live on
6//! `App` directly so motion code keeps working unchanged --
7//! switching the active pane snapshots the App's fields back to the
8//! source pane's stash and loads the destination pane's stash into
9//! the App.
10//!
11//! Splits are arbitrary: `<C-w>s` (horizontal) and `<C-w>v`
12//! (vertical) wrap the active leaf in a new internal node. Closing
13//! the active pane (`<C-w>c`) collapses it; if it had a sibling,
14//! the parent split is replaced by the sibling so the tree stays
15//! minimal.
16//!
17//! Concretely the data model is a `Vec<PaneState>` of leaves plus a
18//! `PaneNode` tree that references them by index. This avoids
19//! lifetime gymnastics during a navigate / close walk; pane indices
20//! are stable across the App's lifetime (never reused, even after
21//! close), so a stale pane index is detectable.
22//!
23//! `App` lives in `lattice-ui-tui` (the host crate); intra-doc
24//! links cross the crate boundary and aren't resolvable from
25//! `lattice-core` -- references stay as plain code-spans.
26
27use lattice_protocol::position::Position;
28
29use crate::{BufferId, BufferKind};
30
31crate::labeled_enum! {
32    /// `:set pane.zoom-indicator=...` — where the zoom marker shows while
33    /// a pane is zoomed (`<C-w>z`). Introduced in ZP.4.
34    ///
35    /// One option rather than a boolean per surface: zoom-indication
36    /// is one user concept, and splitting it across the `modeline`
37    /// and `tabline` config groups would read as two independent
38    /// knobs when it is one decision.
39    ///
40    /// The two surfaces are not redundant. The modeline marker sits
41    /// on the zoomed pane itself, where the state applies; the
42    /// tabline marker is the only one that can tell you a
43    /// *background* tab is zoomed before you switch to it, since
44    /// zoom is per-tab state.
45    pub enum ZoomIndicator {
46        /// Marker on both the zoomed pane's modeline and its tab.
47        #[default]
48        Both = "both" => "Show the zoom marker on the modeline and the tabline",
49        /// Modeline only.
50        Modeline = "modeline" => "Show the zoom marker on the modeline only",
51        /// Tabline only.
52        Tabline = "tabline" => "Show the zoom marker on the tabline only",
53        /// No marker anywhere.
54        None = "none" => "Never show a zoom marker",
55    }
56}
57
58impl ZoomIndicator {
59    /// Whether the zoomed pane's modeline carries the marker.
60    pub fn shows_modeline(self) -> bool {
61        matches!(self, Self::Both | Self::Modeline)
62    }
63
64    /// Whether the zoomed tab's tabline entry carries the marker.
65    pub fn shows_tabline(self) -> bool {
66        matches!(self, Self::Both | Self::Tabline)
67    }
68}
69
70/// The zoom marker itself. A plain `Z`, after tmux's
71/// window-status flag.
72///
73/// Deliberately not a Nerd Font glyph: the icon-degradation rule
74/// requires both palettes to occupy the same cell width, and one
75/// ASCII character satisfies that in every terminal font without a
76/// second palette to keep in sync.
77///
78/// Slice: ZP.4.
79pub const ZOOM_MARKER: &str = "Z";
80
81/// Process-monotonic pane id. Distinct from [`BufferId`]: a pane
82/// holds a buffer + viewport, but two panes can show the same
83/// buffer. Allocated by [`PaneId::next`] at split time.
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
85pub struct PaneId(pub u32);
86
87impl PaneId {
88    /// Mint a fresh id from a process-wide counter starting at 1, so
89    /// `PaneId::default()` (0) never names a real pane. Thread-safe.
90    pub fn next() -> Self {
91        use std::sync::atomic::{AtomicU32, Ordering};
92        static NEXT: AtomicU32 = AtomicU32::new(1);
93        Self(NEXT.fetch_add(1, Ordering::Relaxed))
94    }
95
96    /// Reserved synthetic id for the floating-popup
97    /// "pane". The floating help popup is an overlay, not a pane-tree
98    /// leaf, so the cells worker has no leaf to key its `DisplayMatrix`
99    /// on. `build_cells_panes` registers the popup buffer under this
100    /// sentinel id so BOTH popup states route through the shared
101    /// `compose_pane_lines` reading a real matrix (Fork 1, popup
102    /// unification). `next()` allocates from 1 upward and never reaches
103    /// `u32::MAX`, so the sentinel can never collide with a real leaf.
104    ///
105    /// Slice: PU.1b-3.
106    pub const POPUP: Self = Self(u32::MAX);
107
108    /// Reserved synthetic id for the Insert-mode completion-docs
109    /// side popup — a SECOND simultaneous overlay (it coexists with the
110    /// candidate list and, if open, the floating [`Self::POPUP`]). Like
111    /// `POPUP` it is not a pane-tree leaf, so `build_cells_panes`
112    /// registers its ephemeral backing buffer under this sentinel so the
113    /// docs content routes through the same `compose_pane_lines` seam.
114    /// `u32::MAX - 1` — still far above any `next()`-allocated leaf.
115    ///
116    /// Slice: PU.5.
117    pub const COMPLETION_DOCS: Self = Self(u32::MAX - 1);
118
119    /// Reserved synthetic id for the minibuffer BAND — the advisory
120    /// surface below all panes (which-key's grid). A THIRD simultaneous
121    /// overlay: it coexists with [`Self::POPUP`] by design, which is the whole
122    /// point of the band. A popup and a band are different mechanisms — the
123    /// popup slot is exclusive and the user asked for what is in it, while the
124    /// band is advisory and timer-driven — so sharing one slot meant the band
125    /// evicted whatever the user had open. `u32::MAX - 2`, still far above any
126    /// `next()`-allocated leaf.
127    ///
128    /// Slice: WK.12.
129    pub const MINIBUFFER_BAND: Self = Self(u32::MAX - 2);
130}
131
132/// Process-monotonic id for a scroll-binding pane group (slice D.4.a,
133/// 2026-05-29) — a set of panes whose scrolling is linked, such as the two
134/// sides of a diff. The
135/// `PaneGroup` struct itself lives in `lattice-host` (the trait
136/// underneath it needs host-side state); the id is hoisted into
137/// `lattice-core` so `lattice-core`-level code can hold and pass
138/// the handle without depending on the host crate.
139///
140/// See `docs/dev/architecture/pane-groups.md`.
141#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
142pub struct PaneGroupId(pub u32);
143
144impl PaneGroupId {
145    /// Mint a fresh id from a process-wide counter starting at 1, so
146    /// `PaneGroupId::default()` (0) never names a real group.
147    pub fn next() -> Self {
148        use std::sync::atomic::{AtomicU32, Ordering};
149        static NEXT: AtomicU32 = AtomicU32::new(1);
150        Self(NEXT.fetch_add(1, Ordering::Relaxed))
151    }
152}
153
154/// Pluggable row-mapping function for a pane group (slice D.4.a).
155///
156/// Indices are positions in the host `PaneGroup::members` vector
157/// (stable across pane re-ordering). Mappers consult their own state to
158/// translate; the identity mapper returns its input.
159///
160/// DX.3 (BC.6 diff extraction): moved DOWN from
161/// `lattice-host::pane_group` so `lattice-diff`'s `HunkRowMapper` can
162/// impl it without the host. The trait references only primitive types
163/// (`usize` / `u32`), so it sits cleanly at the bottom of the graph
164/// beside its `PaneGroupId` / `PaneId` identity siblings. The host keeps
165/// the `PaneGroup` registry + the `Identity`/`Offset` impls; it
166/// re-exports this trait so existing `crate::pane_group::RowMapper` call
167/// sites are unchanged.
168pub trait RowMapper: Send + Sync {
169    /// Translate `row` (a 0-based line) in the group member at
170    /// `from_member_idx` to the corresponding line in the member at
171    /// `to_member_idx` — e.g. the matching line on the other side of a
172    /// diff. Must be total: return a best-effort row rather than panic.
173    fn map_row(&self, from_member_idx: usize, to_member_idx: usize, row: u32) -> u32;
174}
175
176/// One leaf in the pane tree. Carries the per-pane viewport state
177/// for its content buffer; switching the active pane swaps these
178/// fields with `App::cursor` / `App::scroll` so motion code
179/// stays unchanged.
180#[derive(Debug, Clone, Copy, Default)]
181pub struct PaneState {
182    /// This pane's stable identity. Survives splits, closes of other
183    /// panes and zoom; leaf *indices* do not.
184    pub id: PaneId,
185    /// Kind of the buffer shown, cached alongside `buffer_id` so layout
186    /// code can read it without a registry lookup.
187    pub buffer: BufferKind,
188    /// The buffer this pane displays (on a published preview projection,
189    /// the *previewed* buffer — see `committed_buffer_id`).
190    pub buffer_id: BufferId,
191    /// Cursor inside the buffer. Loaded into `App::cursor` when the
192    /// pane becomes active; stashed back here when the pane goes
193    /// inactive.
194    pub cursor: Position,
195    /// First visible line in the pane. Loaded into `App::scroll`
196    /// when active.
197    pub scroll: u32,
198    /// First visible *display column* in the pane (horizontal
199    /// scroll). 0 = the line's first column is at the left edge.
200    /// Only meaningful when `wrap` is off; forced to 0 under wrap.
201    /// Loaded into `Editor::leftcol` when the pane is active.
202    pub leftcol: u32,
203    /// Per-pane visible-buffer height in screen rows. Issue #25
204    /// (2026-05-22): replaces the single `Editor::viewport_height`
205    /// global as the source of truth for each pane. Each leaf
206    /// gets its own height set by the renderer's per-frame
207    /// layout pass; the highlights worker reads
208    /// `active_pane.viewport_height` (mirrored into
209    /// `Editor::viewport_height`) so it computes the right
210    /// number of lines for the active pane, and
211    /// `ensure_cursor_visible` clamps against the active pane's
212    /// actual painted area regardless of how the tree is split.
213    pub viewport_height: u32,
214    /// Per-pane visible-buffer width in screen columns. Issue
215    /// #25 follow-up: vertical splits halve the width — without
216    /// per-pane width, line-wrap / clip math mismeasures the
217    /// cursor's end-of-line position in narrower panes. Set by
218    /// the same per-frame layout pass that populates
219    /// `viewport_height`.
220    pub viewport_width: u32,
221    /// When this leaf is a **published render
222    /// projection** of a pane that is currently *previewing* another
223    /// buffer, the `buffer_id` / `buffer` / `cursor` / `scroll` fields
224    /// above hold the DISPLAYED (previewed) buffer + its preview
225    /// viewport, and this holds the pane's COMMITTED buffer — what
226    /// `:ls`, the modeline / per-pane status line, dispatch, and an
227    /// accept all resolve. `None` means "not previewing" (`buffer_id`
228    /// is both committed and displayed).
229    ///
230    /// Always `None` in the live `Editor::pane_tree`; the authoritative
231    /// override lives host-side in `Editor::preview_overrides` and is
232    /// baked into the leaves only when the render state is published
233    /// (`build_render_state`). Ephemeral: never persisted, never
234    /// snapshotted. See `docs/dev/architecture/preview-isolation.md` §5.
235    ///
236    /// Slice: PI.1 (preview isolation).
237    pub committed_buffer_id: Option<BufferId>,
238    /// This WINDOW's `scroll` — how far `<C-d>` / `<C-u>` move, set
239    /// by a count to either key. `None` means "this window has none", and the
240    /// distance falls back to the `scroll` option, then to half the window.
241    ///
242    /// Per pane because vim's `scroll` is window-local: two windows on the
243    /// same buffer scroll by different amounts, and a count typed in one must
244    /// not change the other. (The `:set scroll=N` OPTION is still global here
245    /// — lattice has no window-local option layer — so it is the default a
246    /// window with no count of its own uses.)
247    ///
248    /// Cleared when the window is resized, as vim resets it to half the new
249    /// height.
250    ///
251    /// Slice: VM.3j-3.
252    pub scroll_lines: Option<u32>,
253}
254
255impl PaneState {
256    /// Is this published leaf a preview projection (displaying a
257    /// buffer other than the one it is committed to)?
258    ///
259    /// Slice: PI.1.
260    pub fn is_previewing(&self) -> bool {
261        self.committed_buffer_id.is_some()
262    }
263
264    /// The buffer a real switch / accept commits and that `:ls`,
265    /// the modeline, and the per-pane status line report. Equals
266    /// [`Self::buffer_id`] except on a published preview projection,
267    /// where `buffer_id` is the *displayed* buffer and this is the
268    /// committed one.
269    ///
270    /// Slice: PI.1.
271    pub fn committed_id(&self) -> BufferId {
272        self.committed_buffer_id.unwrap_or(self.buffer_id)
273    }
274}
275
276/// Internal node of the pane tree. Leaves reference a `PaneState`
277/// by index in [`PaneTree::leaves`]; splits hold two children with
278/// an explicit orientation and a ratio. New splits start at
279/// [`DEFAULT_SPLIT_RATIO`]; [`PaneTree::resize_active_split`] and
280/// [`PaneTree::equalize_ratios`] adjust them.
281#[derive(Debug, Clone)]
282pub enum PaneNode {
283    /// A concrete pane. The `usize` indexes into
284    /// [`PaneTree::leaves`].
285    Leaf(usize),
286    /// Two panes stacked top + bottom (a horizontal cut).
287    /// `ratio` is the top child's share of the total height,
288    /// clamped to `MIN_SPLIT_RATIO..=MAX_SPLIT_RATIO`. Default
289    /// 0.5 = even split. Issue #28 (2026-05-22): `<C-w>=`
290    /// resets every ratio to 0.5; `<C-w>+` / `<C-w>-` nudge
291    /// the nearest HorizontalSplit ancestor's ratio.
292    HorizontalSplit {
293        /// The upper child.
294        top: Box<PaneNode>,
295        /// The lower child.
296        bottom: Box<PaneNode>,
297        /// `top`'s share of the height, in `0.0..=1.0`.
298        ratio: f32,
299    },
300    /// Two panes side by side left + right (a vertical cut).
301    /// `ratio` is the left child's share of the total width.
302    /// `<C-w>>` / `<C-w><` nudge the nearest VerticalSplit
303    /// ancestor's ratio.
304    VerticalSplit {
305        /// The left child.
306        left: Box<PaneNode>,
307        /// The right child.
308        right: Box<PaneNode>,
309        /// `left`'s share of the width, in `0.0..=1.0`.
310        ratio: f32,
311    },
312}
313
314/// Default split ratio for newly-created splits. 0.5 = even.
315pub const DEFAULT_SPLIT_RATIO: f32 = 0.5;
316/// Clamp bounds: keep both children visible, never let one
317/// collapse to zero. Matches vim's `window_min_height`
318/// philosophy.
319pub const MIN_SPLIT_RATIO: f32 = 0.05;
320/// Upper clamp bound for a split ratio; see [`MIN_SPLIT_RATIO`].
321pub const MAX_SPLIT_RATIO: f32 = 0.95;
322
323/// Manual `Default` (tuple variants can't use `#[default]`).
324/// Default = `Leaf(0)`, matching the `PaneTree::single`
325/// shape for the trivial one-pane tree.
326impl Default for PaneNode {
327    fn default() -> Self {
328        PaneNode::Leaf(0)
329    }
330}
331
332impl PaneNode {
333    /// Leaf-only constructor used at App init when there's exactly
334    /// one pane.
335    pub fn leaf(idx: usize) -> Self {
336        PaneNode::Leaf(idx)
337    }
338
339    /// True for the trivial single-leaf tree.
340    pub fn is_single_leaf(&self) -> bool {
341        matches!(self, PaneNode::Leaf(_))
342    }
343
344    /// Walk the tree depth-first and call `visit` on every leaf
345    /// index, in left-to-right / top-to-bottom order.
346    pub fn for_each_leaf(&self, visit: &mut impl FnMut(usize)) {
347        match self {
348            PaneNode::Leaf(idx) => visit(*idx),
349            PaneNode::HorizontalSplit { top, bottom, .. } => {
350                top.for_each_leaf(visit);
351                bottom.for_each_leaf(visit);
352            }
353            PaneNode::VerticalSplit { left, right, .. } => {
354                left.for_each_leaf(visit);
355                right.for_each_leaf(visit);
356            }
357        }
358    }
359
360    /// Replace the leaf with `target_idx` by `replacement`. Returns
361    /// `true` if the leaf was found and replaced. Internal helper
362    /// used when splitting (replace leaf -> internal split node) or
363    /// when collapsing (replace internal node -> surviving leaf).
364    fn replace_leaf(&mut self, target_idx: usize, replacement: PaneNode) -> bool {
365        match self {
366            PaneNode::Leaf(idx) if *idx == target_idx => {
367                *self = replacement;
368                true
369            }
370            PaneNode::Leaf(_) => false,
371            PaneNode::HorizontalSplit { top, bottom, .. } => {
372                top.replace_leaf(target_idx, replacement.clone())
373                    || bottom.replace_leaf(target_idx, replacement)
374            }
375            PaneNode::VerticalSplit { left, right, .. } => {
376                left.replace_leaf(target_idx, replacement.clone())
377                    || right.replace_leaf(target_idx, replacement)
378            }
379        }
380    }
381
382    /// Walk the tree and remove the leaf with `target_idx`. The
383    /// parent split collapses to the surviving sibling. Returns
384    /// `true` if the leaf was found and removed.
385    fn remove_leaf(&mut self, target_idx: usize) -> bool {
386        match self {
387            PaneNode::Leaf(_) => false,
388            PaneNode::HorizontalSplit { top, bottom, .. } => {
389                if matches!(**top, PaneNode::Leaf(idx) if idx == target_idx) {
390                    let survivor = (**bottom).clone();
391                    *self = survivor;
392                    true
393                } else if matches!(**bottom, PaneNode::Leaf(idx) if idx == target_idx) {
394                    let survivor = (**top).clone();
395                    *self = survivor;
396                    true
397                } else {
398                    top.remove_leaf(target_idx) || bottom.remove_leaf(target_idx)
399                }
400            }
401            PaneNode::VerticalSplit { left, right, .. } => {
402                if matches!(**left, PaneNode::Leaf(idx) if idx == target_idx) {
403                    let survivor = (**right).clone();
404                    *self = survivor;
405                    true
406                } else if matches!(**right, PaneNode::Leaf(idx) if idx == target_idx) {
407                    let survivor = (**left).clone();
408                    *self = survivor;
409                    true
410                } else {
411                    left.remove_leaf(target_idx) || right.remove_leaf(target_idx)
412                }
413            }
414        }
415    }
416}
417
418/// Direction the user pressed after `<C-w>` to navigate or split.
419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub enum SplitOrientation {
421    /// `<C-w>s` -- new pane below the active one.
422    Horizontal,
423    /// `<C-w>v` -- new pane to the right of the active one.
424    Vertical,
425}
426
427/// Where a file-opening action should land the opened buffer.
428///
429/// `<CR>` uses `Default` (the host's preference, typically the active
430/// pane). `<C-s>` / `<C-v>` / `<C-t>` override to a horizontal split,
431/// vertical split or new tab respectively.
432///
433/// Owned in lattice-core (alongside the pane geometry) so both the
434/// picker's accept vocabulary (`lattice_picker::OpenTarget` re-exports
435/// this) and the grammar's `Effect::OpenInTarget` payload reference one
436/// canonical type without lattice-grammar depending on lattice-picker
437/// (wrong layering direction). Moved here from `lattice-picker` when
438/// `Effect::OpenInTarget` landed (LM.0); the picker re-exports it so
439/// every existing `lattice_picker::OpenTarget` call site is untouched.
440#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
441pub enum OpenTarget {
442    /// `<CR>` -- host preference (active pane by default).
443    #[default]
444    Default,
445    /// `<C-s>` -- open in a new horizontal split below.
446    Split,
447    /// `<C-v>` -- open in a new vertical split to the right.
448    VSplit,
449    /// `<C-t>` -- open in a brand-new tab.
450    Tab,
451}
452
453/// `<C-w>h/j/k/l` cardinal navigation. Geometry-aware: walks the
454/// tree to find the spatial neighbour of the active pane.
455///
456/// Owned in lattice-core (alongside the pane geometry) so any
457/// renderer + the grammar's `AppEffect::NavigatePane` payload can
458/// reference one canonical type. lattice-grammar re-exports it
459/// for ergonomic access from `AppEffect`.
460#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
461pub enum PaneDirection {
462    /// `<C-w>h` — the neighbour to the left.
463    Left,
464    /// `<C-w>j` — the neighbour below.
465    Down,
466    /// `<C-w>k` — the neighbour above.
467    Up,
468    /// `<C-w>l` — the neighbour to the right.
469    Right,
470}
471
472/// The pane tree owned by `App` (DESIGN.md §5.9, lives in
473/// `lattice-ui-tui`). v1 supports
474/// arbitrary recursive splits; the sole constraint is that the
475/// active pane must always exist (closing the last pane is a
476/// no-op so the App is never "paneless").
477///
478/// Leaves are addressed two ways: by **index** into [`Self::leaves`]
479/// (what [`PaneNode::Leaf`] and most methods use — not stable across
480/// [`Self::close_active`]) and by [`PaneId`] (stable; resolve with
481/// [`Self::index_of`]).
482///
483/// # Examples
484///
485/// ```
486/// use lattice_core::ui::pane::{PaneRect, PaneState, PaneTree, SplitOrientation};
487///
488/// let mut tree = PaneTree::single(PaneState::default());
489/// let right = tree.split_active(SplitOrientation::Vertical); // `<C-w>v`
490/// assert_eq!(tree.len(), 2);
491/// assert_eq!(tree.active_index(), 0); // focus stays on the original
492///
493/// // An even vertical split of an 80x24 area.
494/// let area = PaneRect { x: 0, y: 0, width: 80, height: 24 };
495/// let rects = tree.compute_rects(area);
496/// assert_eq!(rects[0], (0, PaneRect { x: 0, y: 0, width: 40, height: 24 }));
497/// assert_eq!(rects[1], (right, PaneRect { x: 40, y: 0, width: 40, height: 24 }));
498///
499/// // Zoom (`<C-w>z`) hands the active pane the whole area, non-destructively.
500/// assert!(tree.toggle_zoom());
501/// assert_eq!(tree.compute_rects(area), [(0, area)]);
502/// assert!(tree.toggle_zoom());
503/// assert_eq!(tree.compute_rects(area).len(), 2);
504///
505/// // Closing the last pane is refused.
506/// assert!(tree.close_active());
507/// assert!(!tree.close_active());
508/// ```
509#[derive(Debug, Clone)]
510pub struct PaneTree {
511    /// All leaves currently in the tree, indexed by position. Note:
512    /// indices are NOT stable across removals -- `remove_leaf`
513    /// shrinks the vec and the tree's `Leaf(idx)` references are
514    /// rewritten to match. Callers that need a stable handle should
515    /// use [`PaneState::id`].
516    leaves: Vec<PaneState>,
517    /// The root of the geometric layout. Always non-empty.
518    root: PaneNode,
519    /// Index into `leaves` of the currently active pane.
520    active: usize,
521    /// ZP.1: the zoomed pane, if any (`<C-w>z` — tmux's `prefix z`).
522    /// While set, [`Self::compute_rects`] hands that one pane the
523    /// whole area and every other leaf goes unpainted; the tree
524    /// itself is untouched, so the second toggle restores the layout
525    /// verbatim. That non-destructiveness is the whole point —
526    /// `<C-w>o` already exists for the destructive form.
527    ///
528    /// A [`PaneId`], not a leaf index, because `close_active`
529    /// renumbers every index above the removed one
530    /// (`rewrite_indices_after_remove`) — an index here would
531    /// silently re-target a different pane.
532    ///
533    /// **Invariant: when `Some`, this is the ACTIVE pane.** Every
534    /// mutation that could break it clears zoom instead (see
535    /// `set_active` / `split_active` / `close_active` /
536    /// `collapse_to_active`). The invariant is what lets the ~6
537    /// existing `compute_rects` consumers that look up the active
538    /// pane's rect keep working unchanged: under zoom the returned
539    /// list has exactly one entry and it is theirs. See
540    /// `docs/dev/architecture/pane-zoom.md` §4.
541    zoomed: Option<PaneId>,
542}
543
544/// `Default` builds a single-pane tree with a placeholder
545/// `PaneState`. Used by `Editor::default()` for headless /
546/// test scaffolding; production paths construct via
547/// [`PaneTree::single`] with a real pane.
548impl Default for PaneTree {
549    fn default() -> Self {
550        PaneTree::single(PaneState::default())
551    }
552}
553
554impl PaneTree {
555    /// Build a single-pane tree pointing at `state`.
556    pub fn single(state: PaneState) -> Self {
557        Self {
558            leaves: vec![state],
559            root: PaneNode::leaf(0),
560            active: 0,
561            zoomed: None,
562        }
563    }
564
565    /// The zoomed pane's id, or `None` when the full split
566    /// layout is showing.
567    ///
568    /// Slice: ZP.1.
569    pub fn zoomed(&self) -> Option<PaneId> {
570        self.zoomed
571    }
572
573    /// Whether a pane is currently zoomed. Read by the
574    /// modeline's `core.zoom` element and the tabline marker.
575    ///
576    /// Slice: ZP.1.
577    pub fn is_zoomed(&self) -> bool {
578        self.zoomed.is_some()
579    }
580
581    /// The zoomed pane's *leaf index*, resolved through
582    /// [`Self::index_of`]. `None` when nothing is zoomed, and also
583    /// when the recorded id no longer names a live leaf — a state
584    /// the enforcement below is meant to prevent, but resolving
585    /// rather than trusting means a stale id degrades to "not
586    /// zoomed" instead of to a panic on the render path.
587    ///
588    /// Slice: ZP.1.
589    pub fn zoomed_index(&self) -> Option<usize> {
590        self.zoomed.and_then(|id| self.index_of(id))
591    }
592
593    /// Toggle zoom on the active pane (`<C-w>z`). Returns
594    /// `true` if the zoom state changed.
595    ///
596    /// A single-leaf tree is a no-op: there is nothing to hide, and
597    /// marking it zoomed would light the indicator for a state the
598    /// user cannot see.
599    ///
600    /// Slice: ZP.1.
601    pub fn toggle_zoom(&mut self) -> bool {
602        if self.zoomed.is_some() {
603            self.zoomed = None;
604            return true;
605        }
606        if self.leaves.len() <= 1 {
607            return false;
608        }
609        self.zoomed = Some(self.leaves[self.active].id);
610        true
611    }
612
613    /// Drop zoom unconditionally. Returns `true` if it was
614    /// set. Called by every mutation that would otherwise break the
615    /// zoomed-is-active invariant.
616    ///
617    /// Slice: ZP.1.
618    pub fn clear_zoom(&mut self) -> bool {
619        self.zoomed.take().is_some()
620    }
621
622    /// The layout root, ignoring zoom. Renderers that recurse over the
623    /// tree should use [`Self::render_root`] instead.
624    pub fn root(&self) -> &PaneNode {
625        &self.root
626    }
627
628    /// Every pane, in leaf-index order (the indices [`PaneNode::Leaf`]
629    /// holds). Never empty.
630    pub fn leaves(&self) -> &[PaneState] {
631        &self.leaves
632    }
633
634    /// Mutable access to every pane's state (cursor, scroll, viewport
635    /// size…). The slice cannot grow or shrink, so the tree shape stays
636    /// consistent.
637    pub fn leaves_mut(&mut self) -> &mut [PaneState] {
638        &mut self.leaves
639    }
640
641    /// Number of panes. Always at least 1.
642    pub fn len(&self) -> usize {
643        self.leaves.len()
644    }
645
646    /// Always `false` — a tree is never paneless. Present for the
647    /// `len`/`is_empty` convention.
648    pub fn is_empty(&self) -> bool {
649        self.leaves.is_empty()
650    }
651
652    /// Leaf index of the focused pane.
653    pub fn active_index(&self) -> usize {
654        self.active
655    }
656
657    /// The focused pane's state.
658    pub fn active(&self) -> &PaneState {
659        &self.leaves[self.active]
660    }
661
662    /// The focused pane's state, mutably.
663    pub fn active_mut(&mut self) -> &mut PaneState {
664        &mut self.leaves[self.active]
665    }
666
667    /// Set the active pane by index. Out-of-bounds indices are
668    /// ignored. Returns `true` if the index changed.
669    pub fn set_active(&mut self, idx: usize) -> bool {
670        if idx >= self.leaves.len() || idx == self.active {
671            return false;
672        }
673        // ZP.1: focus leaves the zoomed pane, so the zoom goes with
674        // it. This is the enforcement point for the zoomed-is-active
675        // invariant on every focus path — `<C-w>hjkl`, `<C-w>w`, a
676        // mouse click, a picker landing in another pane. tmux's
677        // `select-pane` and Zed's toggle-zoom both unzoom here, and
678        // it makes the navigation keys the escape hatch out of zoom.
679        self.zoomed = None;
680        self.active = idx;
681        true
682    }
683
684    /// Locate a pane by its [`PaneId`]. Returns the index into
685    /// [`Self::leaves`] or `None` if the id is unknown.
686    pub fn index_of(&self, id: PaneId) -> Option<usize> {
687        self.leaves.iter().position(|p| p.id == id)
688    }
689
690    /// Split the active pane along `orientation`, inserting a new
691    /// leaf next to it. The new leaf inherits the active pane's
692    /// buffer + cursor + scroll (vim's `<C-w>s` / `<C-w>v` default).
693    /// Returns the new pane's index. The active pane stays the
694    /// original leaf -- the new sibling becomes inactive.
695    pub fn split_active(&mut self, orientation: SplitOrientation) -> usize {
696        // ZP.1: splitting a zoomed pane un-zooms first — the new
697        // sibling is created to be looked at, and leaving zoom on
698        // would hide it the instant it appeared. tmux does the same.
699        self.zoomed = None;
700        let active_idx = self.active;
701        let new_state = self.leaves[active_idx];
702        let new_state = PaneState {
703            id: PaneId::next(),
704            ..new_state
705        };
706        self.leaves.push(new_state);
707        let new_idx = self.leaves.len() - 1;
708        // Build the replacement subtree: the active leaf becomes
709        // one side of a new split; the new leaf becomes the other.
710        let split = match orientation {
711            SplitOrientation::Horizontal => PaneNode::HorizontalSplit {
712                top: Box::new(PaneNode::Leaf(active_idx)),
713                bottom: Box::new(PaneNode::Leaf(new_idx)),
714                ratio: DEFAULT_SPLIT_RATIO,
715            },
716            SplitOrientation::Vertical => PaneNode::VerticalSplit {
717                left: Box::new(PaneNode::Leaf(active_idx)),
718                right: Box::new(PaneNode::Leaf(new_idx)),
719                ratio: DEFAULT_SPLIT_RATIO,
720            },
721        };
722        let replaced = self.root.replace_leaf(active_idx, split);
723        debug_assert!(replaced, "active leaf must exist in root");
724        new_idx
725    }
726
727    /// Close the active pane. The parent split collapses to the
728    /// surviving sibling. If the tree has only one pane, the close
729    /// is a no-op (the App is never paneless). Returns `true` if a
730    /// pane was actually removed.
731    pub fn close_active(&mut self) -> bool {
732        if self.leaves.len() <= 1 {
733            return false;
734        }
735        // ZP.1: the zoomed pane IS the active pane (invariant), so
736        // closing it destroys the zoom target. Clear before the
737        // index rewrite below, which would otherwise leave `zoomed`
738        // naming a pane that has been renumbered out from under it.
739        self.zoomed = None;
740        let active_idx = self.active;
741        // Remove from the tree.
742        let removed = self.root.remove_leaf(active_idx);
743        debug_assert!(removed, "active leaf must exist in root");
744        // Remove from the leaves vec; rewrite remaining tree
745        // references (indices > active_idx) to fill the hole.
746        self.leaves.remove(active_idx);
747        rewrite_indices_after_remove(&mut self.root, active_idx);
748        // Pick a new active: the leaf with the lowest index that
749        // still exists (deterministic + stable in tests). Vim
750        // would pick the geometrically-adjacent pane; we'll add
751        // that polish in a follow-up.
752        self.active = 0;
753        true
754    }
755
756    /// `<C-w>o` / `:only` / emacs `C-x 1` -- close every pane except
757    /// the active one, collapsing the whole tree to a single leaf that
758    /// keeps the active pane's state. No-op (returns `false`) when only
759    /// one pane is open. Unlike repeated [`Self::close_active`], this
760    /// keeps the *active* pane and drops its siblings in one step.
761    pub fn collapse_to_active(&mut self) -> bool {
762        if self.leaves.len() <= 1 {
763            return false;
764        }
765        // ZP.1: `:only` makes the zoom permanent by actually
766        // dropping the siblings, so the temporary form retires.
767        // Leaving it set would zoom a one-leaf tree, which
768        // `toggle_zoom` refuses to create in the first place.
769        self.zoomed = None;
770        let survivor = self.leaves[self.active];
771        self.leaves = vec![survivor];
772        self.root = PaneNode::leaf(0);
773        self.active = 0;
774        true
775    }
776
777    /// Walk the tree and reset every
778    /// split's ratio to [`DEFAULT_SPLIT_RATIO`] (0.5). Vim's
779    /// `<C-w>=`. Returns `true` if any ratio actually changed,
780    /// so the renderer can skip the publish when there's
781    /// nothing to do.
782    ///
783    /// Slice: Issue #28 (2026-05-22).
784    pub fn equalize_ratios(&mut self) -> bool {
785        // ZP.1: ratios describe a layout that is not on screen while
786        // zoomed. Silently rewriting it would surprise the user on
787        // unzoom — they would get their layout back reshaped by a
788        // key they pressed against a full-screen pane. Refuse
789        // instead, matching tmux's resize-pane-while-zoomed.
790        if self.zoomed.is_some() {
791            return false;
792        }
793        equalize_recursive(&mut self.root)
794    }
795
796    /// Adjust the ratio of the nearest split-of-the-
797    /// requested-orientation containing the active pane. Vim's
798    /// `<C-w>+` / `<C-w>-` (HorizontalSplit) / `<C-w>>` /
799    /// `<C-w><` (VerticalSplit). `delta` is added to the
800    /// current ratio (positive = grow active side); clamped to
801    /// [MIN_SPLIT_RATIO, MAX_SPLIT_RATIO]. Returns `true` if a
802    /// ratio was found and changed.
803    ///
804    /// "Active side" semantics: if the active leaf is in the
805    /// `top` (or `left`) child, growing means increasing the
806    /// ratio (top/left gets bigger). If active is in `bottom`
807    /// (or `right`), growing means DECREASING the ratio.
808    ///
809    /// Slice: Issue #28.
810    pub fn resize_active_split(&mut self, orientation: SplitOrientation, delta: f32) -> bool {
811        // ZP.1: same reasoning as `equalize_ratios` — no silent
812        // reshaping of a layout the user cannot see.
813        if self.zoomed.is_some() {
814            return false;
815        }
816        let active = self.active;
817        resize_active_recursive(&mut self.root, active, orientation, delta).is_some()
818    }
819
820    /// Navigate cardinally from the active pane. Returns the new
821    /// active leaf index, or `None` if there's no neighbour in that
822    /// direction. Geometry comes from [`Self::compute_rects`] so
823    /// the navigation matches what the renderer drew.
824    /// A candidate must also OVERLAP the source on the perpendicular axis,
825    /// and among those that do, the source's cursor decides. Both halves are
826    /// load-bearing, and their absence was one bug:
827    ///
828    /// In a 2×2 grid every pane below the top row starts at the same `y`, so
829    /// ranking by travel distance alone left every candidate tied — and the
830    /// winner fell out of leaf iteration order, which is tree order, not
831    /// screen order. `<C-w>j` from the top-RIGHT pane landed in the bottom-
832    /// LEFT one, and so did `<C-w>j` from the top-left, which is how the bug
833    /// reads to a user: the direction keys ignore where you are.
834    ///
835    /// Overlap alone is not enough either. One wide pane above two narrow ones
836    /// overlaps both, so vim breaks that tie with the cursor's screen
837    /// position — you go down into the pane under your cursor — and that is
838    /// the behaviour muscle memory expects.
839    pub fn navigate(&self, direction: PaneDirection, area: PaneRect) -> Option<usize> {
840        // ZP.1: deliberately the unzoomed layout — see
841        // `compute_rects_layout`. The caller's `set_active` clears
842        // the zoom, so the user sees zoom drop and focus move one
843        // pane in the direction they pressed.
844        let rects = self.compute_rects_layout(area);
845        let from = rects.iter().find(|(idx, _)| *idx == self.active)?.1;
846        let vertical = matches!(direction, PaneDirection::Up | PaneDirection::Down);
847        // The perpendicular span of a rect: the horizontal one when travelling
848        // vertically, and vice versa.
849        let span = |r: &PaneRect| -> (u16, u16) {
850            if vertical {
851                (r.x, r.x + r.width)
852            } else {
853                (r.y, r.y + r.height)
854            }
855        };
856        let (from_lo, from_hi) = span(&from);
857        // Where the cursor sits along that span, APPROXIMATELY, and the two
858        // approximations are worth naming rather than hiding.
859        //
860        // The gutter is not modelled: `lattice-core` does not know its width,
861        // so a horizontal position is short by a few cells. And `Position`
862        // carries a byte offset, not a display column, so a line with
863        // multi-byte characters or tabs reads wider than it paints.
864        //
865        // Both only ever decide a TIE between candidates that already overlap
866        // the source, so the cost of being off is picking the neighbour next
867        // door when the cursor sits within a few cells of their shared edge.
868        // Approximately right beats tree order, which is not right at all.
869        let cursor_at = self.leaves.get(self.active).map(|s| {
870            let along = if vertical {
871                s.cursor.byte.saturating_sub(s.leftcol)
872            } else {
873                s.cursor.line.saturating_sub(s.scroll)
874            };
875            let along = u16::try_from(along).unwrap_or(u16::MAX);
876            from_lo.saturating_add(along).min(from_hi.saturating_sub(1))
877        });
878        // Ranked ascending, so a smaller key wins:
879        //   0. travel distance — the adjacent row/column first;
880        //   1. does the candidate hold the cursor (0 yes, 1 no);
881        //   2. how much of the source it covers, negated so more wins;
882        //   3. its start coordinate, purely so equals resolve the same way
883        //      every run rather than by hash or tree order.
884        let mut best: Option<(usize, (i32, u8, i32, u16))> = None;
885        for (idx, r) in rects.iter() {
886            if *idx == self.active {
887                continue;
888            }
889            let (qualifies, distance) = match direction {
890                PaneDirection::Left => (
891                    r.x + r.width <= from.x,
892                    (from.x as i32) - (r.x + r.width) as i32,
893                ),
894                PaneDirection::Right => (
895                    r.x >= from.x + from.width,
896                    (r.x as i32) - (from.x + from.width) as i32,
897                ),
898                PaneDirection::Up => (
899                    r.y + r.height <= from.y,
900                    (from.y as i32) - (r.y + r.height) as i32,
901                ),
902                PaneDirection::Down => (
903                    r.y >= from.y + from.height,
904                    (r.y as i32) - (from.y + from.height) as i32,
905                ),
906            };
907            if !qualifies {
908                continue;
909            }
910            let (lo, hi) = span(r);
911            let overlap = hi.min(from_hi).saturating_sub(lo.max(from_lo));
912            if overlap == 0 {
913                // Diagonal: it is in that direction, but not from HERE. Vim
914                // reports "no window in that direction" rather than jumping
915                // sideways, and so do we — landing somewhere the user was not
916                // pointing is worse than not moving.
917                continue;
918            }
919            let holds_cursor = cursor_at.is_some_and(|c| c >= lo && c < hi);
920            let key = (distance, u8::from(!holds_cursor), -(overlap as i32), lo);
921            match &best {
922                None => best = Some((*idx, key)),
923                Some((_, b)) if key < *b => best = Some((*idx, key)),
924                _ => {}
925            }
926        }
927        best.map(|(idx, _)| idx)
928    }
929
930    /// Cycle to the next pane (`<C-w>w`). Wraps around.
931    pub fn next_pane(&self) -> usize {
932        if self.leaves.is_empty() {
933            return 0;
934        }
935        (self.active + 1) % self.leaves.len()
936    }
937
938    /// Cycle to the previous pane (`<C-w>W`). Wraps around.
939    pub fn prev_pane(&self) -> usize {
940        if self.leaves.is_empty() {
941            return 0;
942        }
943        if self.active == 0 {
944            self.leaves.len() - 1
945        } else {
946            self.active - 1
947        }
948    }
949
950    /// Compute the rectangle each leaf occupies inside `area`. The
951    /// renderer + navigation use this to lay out / find spatial
952    /// neighbours. Each split divides its area by its `ratio`
953    /// (rounded to whole cells). Under zoom, only the zoomed pane is
954    /// returned, with the whole `area`. Entries are `(leaf index, rect)`
955    /// in depth-first, top-to-bottom / left-to-right order.
956    pub fn compute_rects(&self, area: PaneRect) -> Vec<(usize, PaneRect)> {
957        // ZP.1: zoom is one branch at the head of the single
958        // canonical layout function, so every consumer inherits it
959        // without knowing it exists — the TUI draw path, per-pane
960        // viewport sizing (which resizes terminal PTYs), mouse
961        // hit-testing and the pane-height motions all route here.
962        //
963        // It also makes zoom cheaper than not zooming: hidden panes
964        // get no rect, so no element fan-out and no per-pane content
965        // resolution happens for them at all (paramount goal #1).
966        if let Some(idx) = self.zoomed_index() {
967            return vec![(idx, area)];
968        }
969        self.compute_rects_layout(area)
970    }
971
972    /// The node a renderer should paint — the zoomed leaf when
973    /// zoomed, otherwise the real root.
974    ///
975    /// For renderers that recurse over [`PaneNode`] themselves rather
976    /// than calling [`Self::compute_rects`] (the GPUI peer's
977    /// `collect_pane_geometries` and `paint_pane_tree`). Expressing
978    /// zoom as "the tree is one leaf" means those walks stay exactly
979    /// as they were — no zoom branch inside the recursion, where it
980    /// would have to be re-checked at every level.
981    ///
982    /// Allocation-free in both arms: borrowed for the real root, and
983    /// the owned arm is a bare `Leaf(usize)` with no boxed children.
984    ///
985    /// Slice: ZP.3.
986    pub fn render_root(&self) -> std::borrow::Cow<'_, PaneNode> {
987        match self.zoomed_index() {
988            Some(idx) => std::borrow::Cow::Owned(PaneNode::Leaf(idx)),
989            None => std::borrow::Cow::Borrowed(&self.root),
990        }
991    }
992
993    /// The always-unzoomed peer of [`Self::compute_rects`] —
994    /// the full split layout, whatever the zoom state.
995    ///
996    /// One caller: [`Self::navigate`]. Cardinal navigation has to
997    /// ask where a pane sits in the REAL layout, because the answer
998    /// decides where focus lands after the zoom drops. Reading the
999    /// zoom-aware view instead would hand it a one-entry list, no
1000    /// neighbour would be found in any direction, and `<C-w>j` while
1001    /// zoomed would silently do nothing.
1002    ///
1003    /// Slice: ZP.1.
1004    pub fn compute_rects_layout(&self, area: PaneRect) -> Vec<(usize, PaneRect)> {
1005        let mut out = Vec::with_capacity(self.leaves.len());
1006        compute_rects_recursive(&self.root, area, &mut out);
1007        out
1008    }
1009}
1010
1011/// Walk the tree, dividing `area` evenly at each split. Leaves
1012/// receive the resulting rect.
1013fn compute_rects_recursive(node: &PaneNode, area: PaneRect, out: &mut Vec<(usize, PaneRect)>) {
1014    match node {
1015        PaneNode::Leaf(idx) => out.push((*idx, area)),
1016        PaneNode::HorizontalSplit { top, bottom, ratio } => {
1017            let top_h = ((area.height as f32) * *ratio).round() as u16;
1018            let top_rect = PaneRect {
1019                height: top_h,
1020                ..area
1021            };
1022            let bot_rect = PaneRect {
1023                y: area.y + top_h,
1024                height: area.height.saturating_sub(top_h),
1025                ..area
1026            };
1027            compute_rects_recursive(top, top_rect, out);
1028            compute_rects_recursive(bottom, bot_rect, out);
1029        }
1030        PaneNode::VerticalSplit { left, right, ratio } => {
1031            let left_w = ((area.width as f32) * *ratio).round() as u16;
1032            let left_rect = PaneRect {
1033                width: left_w,
1034                ..area
1035            };
1036            let right_rect = PaneRect {
1037                x: area.x + left_w,
1038                width: area.width.saturating_sub(left_w),
1039                ..area
1040            };
1041            compute_rects_recursive(left, left_rect, out);
1042            compute_rects_recursive(right, right_rect, out);
1043        }
1044    }
1045}
1046
1047/// Rewrite `Leaf(idx)` references in the tree to account for a
1048/// vector removal at `removed_idx`: every index `> removed_idx`
1049/// shifts down by one.
1050/// Issue #28: recursive helper for `PaneTree::equalize_ratios`.
1051/// Resets every split node's ratio to `DEFAULT_SPLIT_RATIO`.
1052/// Returns `true` if any ratio changed.
1053/// Number of leaf panes under `node`. Used to weight `<C-w>=` so EVERY pane
1054/// ends equal-area, not just balanced binary trees.
1055fn leaf_count(node: &PaneNode) -> usize {
1056    match node {
1057        PaneNode::Leaf(_) => 1,
1058        PaneNode::HorizontalSplit { top, bottom, .. } => leaf_count(top) + leaf_count(bottom),
1059        PaneNode::VerticalSplit { left, right, .. } => leaf_count(left) + leaf_count(right),
1060    }
1061}
1062
1063fn equalize_recursive(node: &mut PaneNode) -> bool {
1064    match node {
1065        PaneNode::Leaf(_) => false,
1066        PaneNode::HorizontalSplit { top, bottom, ratio }
1067        | PaneNode::VerticalSplit {
1068            left: top,
1069            right: bottom,
1070            ratio,
1071        } => {
1072            // Equal AREA, not equal ratio: a binary tree like
1073            // `V(A, V(B, C))` is only equal-thirds when the outer ratio is
1074            // 1/3 and the inner 1/2. Weight each split by the leaf counts of
1075            // its two subtrees so N panes at any nesting come out equal.
1076            let (lc, rc) = (leaf_count(top), leaf_count(bottom));
1077            let target = lc as f32 / (lc + rc) as f32;
1078            let changed = (*ratio - target).abs() > f32::EPSILON;
1079            *ratio = target;
1080            let l = equalize_recursive(top);
1081            let r = equalize_recursive(bottom);
1082            changed || l || r
1083        }
1084    }
1085}
1086
1087/// Issue #28: recursive helper for `PaneTree::resize_active_split`.
1088/// Returns `Some(())` if the active leaf was found AND a
1089/// matching-orientation ancestor was hit; `None` otherwise so
1090/// the caller can decide whether to no-op.
1091///
1092/// Walks top-down. At each split node, recurses into both
1093/// sides asking "did you contain the active leaf?". When a
1094/// child returns "yes, but no orientation-matching ancestor
1095/// upstream", this node — if its orientation matches —
1096/// applies the delta. The first matching ancestor on the path
1097/// up from the active leaf wins.
1098fn resize_active_recursive(
1099    node: &mut PaneNode,
1100    active: usize,
1101    orientation: SplitOrientation,
1102    delta: f32,
1103) -> Option<()> {
1104    match node {
1105        PaneNode::Leaf(idx) if *idx == active => Some(()),
1106        PaneNode::Leaf(_) => None,
1107        PaneNode::HorizontalSplit { top, bottom, ratio } => {
1108            // active was in `top` ⇒ grow = positive delta;
1109            // active was in `bottom` ⇒ grow = negate delta.
1110            if resize_active_recursive(top, active, orientation, delta).is_some() {
1111                if matches!(orientation, SplitOrientation::Horizontal) {
1112                    *ratio = (*ratio + delta).clamp(MIN_SPLIT_RATIO, MAX_SPLIT_RATIO);
1113                    return Some(());
1114                }
1115                return Some(());
1116            }
1117            if resize_active_recursive(bottom, active, orientation, delta).is_some() {
1118                if matches!(orientation, SplitOrientation::Horizontal) {
1119                    *ratio = (*ratio - delta).clamp(MIN_SPLIT_RATIO, MAX_SPLIT_RATIO);
1120                    return Some(());
1121                }
1122                return Some(());
1123            }
1124            None
1125        }
1126        PaneNode::VerticalSplit { left, right, ratio } => {
1127            if resize_active_recursive(left, active, orientation, delta).is_some() {
1128                if matches!(orientation, SplitOrientation::Vertical) {
1129                    *ratio = (*ratio + delta).clamp(MIN_SPLIT_RATIO, MAX_SPLIT_RATIO);
1130                    return Some(());
1131                }
1132                return Some(());
1133            }
1134            if resize_active_recursive(right, active, orientation, delta).is_some() {
1135                if matches!(orientation, SplitOrientation::Vertical) {
1136                    *ratio = (*ratio - delta).clamp(MIN_SPLIT_RATIO, MAX_SPLIT_RATIO);
1137                    return Some(());
1138                }
1139                return Some(());
1140            }
1141            None
1142        }
1143    }
1144}
1145
1146fn rewrite_indices_after_remove(node: &mut PaneNode, removed_idx: usize) {
1147    match node {
1148        PaneNode::Leaf(idx) => {
1149            if *idx > removed_idx {
1150                *idx -= 1;
1151            }
1152        }
1153        PaneNode::HorizontalSplit { top, bottom, .. } => {
1154            rewrite_indices_after_remove(top, removed_idx);
1155            rewrite_indices_after_remove(bottom, removed_idx);
1156        }
1157        PaneNode::VerticalSplit { left, right, .. } => {
1158            rewrite_indices_after_remove(left, removed_idx);
1159            rewrite_indices_after_remove(right, removed_idx);
1160        }
1161    }
1162}
1163
1164/// Geometry rectangle in screen coordinates. Mirrors ratatui's
1165/// `Rect` shape so the renderer can hand the result straight to
1166/// the layout routines without an extra conversion.
1167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1168pub struct PaneRect {
1169    /// Left edge, in cells from the screen's left.
1170    pub x: u16,
1171    /// Top edge, in cells from the screen's top.
1172    pub y: u16,
1173    /// Width in cells.
1174    pub width: u16,
1175    /// Height in cells.
1176    pub height: u16,
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181    #![allow(clippy::unwrap_used)]
1182    use super::*;
1183
1184    fn doc_state() -> PaneState {
1185        PaneState {
1186            id: PaneId::next(),
1187            buffer: BufferKind::Document,
1188            buffer_id: BufferId(1),
1189            cursor: Position::ZERO,
1190            scroll: 0,
1191            leftcol: 0,
1192            viewport_height: 0,
1193            viewport_width: 0,
1194            committed_buffer_id: None,
1195            scroll_lines: None,
1196        }
1197    }
1198
1199    #[test]
1200    fn single_pane_tree_has_one_leaf() {
1201        let t = PaneTree::single(doc_state());
1202        assert_eq!(t.len(), 1);
1203        assert!(t.root().is_single_leaf());
1204        assert_eq!(t.active_index(), 0);
1205    }
1206
1207    /// `<C-w>=` must make THREE vertical splits equal-width, not 50/25/25.
1208    /// A binary tree `V(A, V(B, C))` is equal-thirds only when the outer
1209    /// ratio is 1/3 and the inner 1/2 — leaf-weighted, not a flat 0.5.
1210    #[test]
1211    fn equalize_makes_three_splits_equal_area() {
1212        let mut t = PaneTree::single(doc_state());
1213        // A | (split right) → V(A, B); focus B; split again → V(A, V(B, C)).
1214        t.split_active(SplitOrientation::Vertical);
1215        let b = t.active_index(); // split_active keeps A active; B is the new leaf
1216        let b = if b == 0 { 1 } else { b };
1217        t.set_active(b);
1218        t.split_active(SplitOrientation::Vertical);
1219
1220        assert!(t.equalize_ratios(), "ratios changed from the 0.5 defaults");
1221
1222        // Outer split: left subtree = 1 leaf (A), right = 2 leaves (B,C) ⇒ 1/3.
1223        match t.root() {
1224            PaneNode::VerticalSplit { left, right, ratio } => {
1225                assert!(
1226                    (*ratio - 1.0 / 3.0).abs() < 1e-4,
1227                    "outer ratio = 1/3 (A gets a third), got {ratio}"
1228                );
1229                assert!(left.is_single_leaf(), "left is the lone A leaf");
1230                // Inner split: two leaves ⇒ even 0.5 (each then a third overall).
1231                match &**right {
1232                    PaneNode::VerticalSplit { ratio: inner, .. } => assert!(
1233                        (*inner - 0.5).abs() < 1e-4,
1234                        "inner ratio = 1/2, got {inner}"
1235                    ),
1236                    other => panic!("expected nested VerticalSplit, got {other:?}"),
1237                }
1238            }
1239            other => panic!("expected VerticalSplit root, got {other:?}"),
1240        }
1241    }
1242
1243    #[test]
1244    fn horizontal_split_creates_second_leaf_below() {
1245        let mut t = PaneTree::single(doc_state());
1246        let new_idx = t.split_active(SplitOrientation::Horizontal);
1247        assert_eq!(t.len(), 2);
1248        assert_eq!(new_idx, 1);
1249        // Active stays on original leaf.
1250        assert_eq!(t.active_index(), 0);
1251        // Compute rects with a 100x40 area: top + bottom should be
1252        // 20 each.
1253        let rects = t.compute_rects(PaneRect {
1254            x: 0,
1255            y: 0,
1256            width: 100,
1257            height: 40,
1258        });
1259        assert_eq!(rects.len(), 2);
1260        let by_idx: std::collections::HashMap<_, _> = rects.into_iter().collect();
1261        assert_eq!(by_idx[&0].height, 20);
1262        assert_eq!(by_idx[&1].height, 20);
1263        assert_eq!(by_idx[&0].y, 0);
1264        assert_eq!(by_idx[&1].y, 20);
1265    }
1266
1267    #[test]
1268    fn vertical_split_creates_second_leaf_right() {
1269        let mut t = PaneTree::single(doc_state());
1270        t.split_active(SplitOrientation::Vertical);
1271        let rects = t.compute_rects(PaneRect {
1272            x: 0,
1273            y: 0,
1274            width: 100,
1275            height: 40,
1276        });
1277        let by_idx: std::collections::HashMap<_, _> = rects.into_iter().collect();
1278        assert_eq!(by_idx[&0].width, 50);
1279        assert_eq!(by_idx[&1].width, 50);
1280        assert_eq!(by_idx[&0].x, 0);
1281        assert_eq!(by_idx[&1].x, 50);
1282    }
1283
1284    #[test]
1285    fn close_active_collapses_split_to_sibling() {
1286        let mut t = PaneTree::single(doc_state());
1287        t.split_active(SplitOrientation::Vertical);
1288        // Move active to the new (right) pane and close it.
1289        t.set_active(1);
1290        let removed = t.close_active();
1291        assert!(removed);
1292        assert_eq!(t.len(), 1);
1293        assert!(t.root().is_single_leaf());
1294    }
1295
1296    #[test]
1297    fn close_last_pane_is_a_noop() {
1298        let mut t = PaneTree::single(doc_state());
1299        let removed = t.close_active();
1300        assert!(!removed);
1301        assert_eq!(t.len(), 1);
1302    }
1303
1304    #[test]
1305    fn collapse_to_active_keeps_active_drops_siblings() {
1306        let mut t = PaneTree::single(doc_state());
1307        t.split_active(SplitOrientation::Vertical);
1308        t.split_active(SplitOrientation::Horizontal);
1309        // Make a non-zero pane active so we prove the SURVIVOR is the
1310        // active one, not just "leaf 0".
1311        t.set_active(2);
1312        let survivor_id = t.active().id;
1313        let collapsed = t.collapse_to_active();
1314        assert!(collapsed);
1315        assert_eq!(t.len(), 1);
1316        assert!(t.root().is_single_leaf());
1317        assert_eq!(t.active_index(), 0);
1318        assert_eq!(
1319            t.active().id,
1320            survivor_id,
1321            "`:only` must keep the active pane, dropping its siblings"
1322        );
1323    }
1324
1325    #[test]
1326    fn collapse_single_pane_is_a_noop() {
1327        let mut t = PaneTree::single(doc_state());
1328        let collapsed = t.collapse_to_active();
1329        assert!(!collapsed);
1330        assert_eq!(t.len(), 1);
1331    }
1332
1333    #[test]
1334    fn navigate_right_finds_vertical_neighbour() {
1335        let mut t = PaneTree::single(doc_state());
1336        t.split_active(SplitOrientation::Vertical);
1337        let target = t.navigate(
1338            PaneDirection::Right,
1339            PaneRect {
1340                x: 0,
1341                y: 0,
1342                width: 100,
1343                height: 40,
1344            },
1345        );
1346        assert_eq!(target, Some(1));
1347    }
1348
1349    #[test]
1350    fn navigate_left_finds_vertical_neighbour() {
1351        let mut t = PaneTree::single(doc_state());
1352        t.split_active(SplitOrientation::Vertical);
1353        t.set_active(1);
1354        let target = t.navigate(
1355            PaneDirection::Left,
1356            PaneRect {
1357                x: 0,
1358                y: 0,
1359                width: 100,
1360                height: 40,
1361            },
1362        );
1363        assert_eq!(target, Some(0));
1364    }
1365
1366    #[test]
1367    fn navigate_up_finds_horizontal_neighbour() {
1368        let mut t = PaneTree::single(doc_state());
1369        t.split_active(SplitOrientation::Horizontal);
1370        t.set_active(1); // bottom
1371        let target = t.navigate(
1372            PaneDirection::Up,
1373            PaneRect {
1374                x: 0,
1375                y: 0,
1376                width: 100,
1377                height: 40,
1378            },
1379        );
1380        assert_eq!(target, Some(0));
1381    }
1382
1383    /// Build the 2x2 grid: split vertically, then split each column
1384    /// horizontally. The resulting leaf indices are asserted in
1385    /// [`the_2x2_grid_is_laid_out_as_expected`] rather than assumed here — a
1386    /// split appends its new leaf, so the numbering is not the reading order.
1387    fn grid_2x2() -> PaneTree {
1388        let mut t = PaneTree::single(doc_state());
1389        t.split_active(SplitOrientation::Vertical); // 0 = left, 1 = right
1390        t.set_active(0);
1391        t.split_active(SplitOrientation::Horizontal); // left column -> 0 over 2
1392        t.set_active(1);
1393        t.split_active(SplitOrientation::Horizontal); // right column -> 1 over 3
1394        t
1395    }
1396
1397    /// The geometry every navigation assertion below depends on. Pinned
1398    /// separately so a layout change fails HERE, loudly, rather than making
1399    /// the navigation tests quietly vacuous.
1400    #[test]
1401    fn the_2x2_grid_is_laid_out_as_expected() {
1402        let rects = grid_2x2().compute_rects(area());
1403        let at = |i: usize| {
1404            let r = rects.iter().find(|(idx, _)| *idx == i).unwrap().1;
1405            (r.x, r.y)
1406        };
1407        assert_eq!(at(0), (0, 0), "leaf 0 is top-left");
1408        assert_eq!(at(2), (0, 20), "leaf 2 is bottom-left");
1409        assert_eq!(at(1), (50, 0), "leaf 1 is top-right");
1410        assert_eq!(at(3), (50, 20), "leaf 3 is bottom-right");
1411    }
1412
1413    fn area() -> PaneRect {
1414        PaneRect {
1415            x: 0,
1416            y: 0,
1417            width: 100,
1418            height: 40,
1419        }
1420    }
1421
1422    /// **`<C-w>j` from the TOP-RIGHT pane must land in the BOTTOM-RIGHT one.**
1423    ///
1424    /// Reported against a 2x2 grid: going down from EITHER top pane landed in
1425    /// the bottom-LEFT. Both bottom panes start at the same `y`, so both are
1426    /// equidistant, and the winner was decided by leaf iteration order rather
1427    /// than by which pane is actually below the one you are in.
1428    #[test]
1429    fn navigate_down_in_a_grid_stays_in_its_column() {
1430        let mut t = grid_2x2();
1431        t.set_active(1); // top-right
1432        assert_eq!(
1433            t.navigate(PaneDirection::Down, area()),
1434            Some(3),
1435            "down from the top-right pane is the bottom-RIGHT one"
1436        );
1437    }
1438
1439    /// The mirror: up from the bottom-right must not drift to the top-left.
1440    #[test]
1441    fn navigate_up_in_a_grid_stays_in_its_column() {
1442        let mut t = grid_2x2();
1443        t.set_active(3); // bottom-right
1444        assert_eq!(t.navigate(PaneDirection::Up, area()), Some(1));
1445    }
1446
1447    /// And the same on the other axis: right from the bottom-left must be the
1448    /// bottom-right, not the top-right.
1449    #[test]
1450    fn navigate_right_in_a_grid_stays_in_its_row() {
1451        let mut t = grid_2x2();
1452        t.set_active(2); // bottom-left
1453        assert_eq!(t.navigate(PaneDirection::Right, area()), Some(3));
1454    }
1455
1456    #[test]
1457    fn navigate_left_in_a_grid_stays_in_its_row() {
1458        let mut t = grid_2x2();
1459        t.set_active(3); // bottom-right
1460        assert_eq!(t.navigate(PaneDirection::Left, area()), Some(2));
1461    }
1462
1463    #[test]
1464    fn navigate_into_void_returns_none() {
1465        let t = PaneTree::single(doc_state());
1466        let target = t.navigate(
1467            PaneDirection::Right,
1468            PaneRect {
1469                x: 0,
1470                y: 0,
1471                width: 100,
1472                height: 40,
1473            },
1474        );
1475        assert_eq!(target, None);
1476    }
1477
1478    #[test]
1479    fn nested_splits_compute_rects_correctly() {
1480        let mut t = PaneTree::single(doc_state());
1481        t.split_active(SplitOrientation::Vertical);
1482        // Now: [0 | 1]. Move active to 1, split horizontally.
1483        t.set_active(1);
1484        t.split_active(SplitOrientation::Horizontal);
1485        // Now: [0 | [1 over 2]].
1486        assert_eq!(t.len(), 3);
1487        let rects = t.compute_rects(PaneRect {
1488            x: 0,
1489            y: 0,
1490            width: 100,
1491            height: 40,
1492        });
1493        let by_idx: std::collections::HashMap<_, _> = rects.into_iter().collect();
1494        // Pane 0 (left half).
1495        assert_eq!(by_idx[&0].x, 0);
1496        assert_eq!(by_idx[&0].width, 50);
1497        assert_eq!(by_idx[&0].height, 40);
1498        // Pane 1 (top right).
1499        assert_eq!(by_idx[&1].x, 50);
1500        assert_eq!(by_idx[&1].width, 50);
1501        assert_eq!(by_idx[&1].y, 0);
1502        assert_eq!(by_idx[&1].height, 20);
1503        // Pane 2 (bottom right).
1504        assert_eq!(by_idx[&2].x, 50);
1505        assert_eq!(by_idx[&2].y, 20);
1506        assert_eq!(by_idx[&2].height, 20);
1507    }
1508
1509    #[test]
1510    fn next_and_prev_pane_cycle() {
1511        let mut t = PaneTree::single(doc_state());
1512        t.split_active(SplitOrientation::Vertical);
1513        t.split_active(SplitOrientation::Horizontal);
1514        // 3 panes. From active=0: next=1, prev=2.
1515        assert_eq!(t.next_pane(), 1);
1516        assert_eq!(t.prev_pane(), 2);
1517        t.set_active(2);
1518        assert_eq!(t.next_pane(), 0);
1519        assert_eq!(t.prev_pane(), 1);
1520    }
1521
1522    #[test]
1523    fn close_after_nested_splits_keeps_other_leaves_addressable() {
1524        let mut t = PaneTree::single(doc_state());
1525        t.split_active(SplitOrientation::Vertical);
1526        t.set_active(1);
1527        t.split_active(SplitOrientation::Horizontal);
1528        // Tree: [0 | [1 over 2]]. Close active (2 -- bottom right).
1529        t.set_active(2);
1530        let removed = t.close_active();
1531        assert!(removed);
1532        assert_eq!(t.len(), 2);
1533        // Remaining leaves are at indices 0 and 1; both must
1534        // appear in the layout walk.
1535        let rects = t.compute_rects(PaneRect {
1536            x: 0,
1537            y: 0,
1538            width: 100,
1539            height: 40,
1540        });
1541        let indices: Vec<usize> = rects.iter().map(|(i, _)| *i).collect();
1542        assert!(indices.contains(&0));
1543        assert!(indices.contains(&1));
1544    }
1545
1546    #[test]
1547    fn pane_id_is_monotonic() {
1548        let a = PaneId::next();
1549        let b = PaneId::next();
1550        assert!(b.0 > a.0);
1551    }
1552
1553    #[test]
1554    fn split_assigns_new_pane_id_distinct_from_source() {
1555        let mut t = PaneTree::single(doc_state());
1556        let original_id = t.active().id;
1557        t.split_active(SplitOrientation::Vertical);
1558        let new_id = t.leaves()[1].id;
1559        assert_ne!(original_id, new_id);
1560    }
1561
1562    // ---- ZP.1: pane zoom -------------------------------------------
1563    // `docs/dev/architecture/pane-zoom.md`.
1564
1565    /// A 2-pane tree, split vertically, with the SECOND pane active —
1566    /// so "the zoomed pane" is not index 0 and an off-by-one in
1567    /// `zoomed_index` cannot pass by accident.
1568    fn two_pane_tree() -> PaneTree {
1569        let mut t = PaneTree::single(doc_state());
1570        let new_idx = t.split_active(SplitOrientation::Vertical);
1571        t.set_active(new_idx);
1572        t
1573    }
1574
1575    /// The core promise: zoom hands the active pane the whole area,
1576    /// and the toggle back restores the rects VERBATIM. Comparing the
1577    /// full rect list before and after is what makes this a test of
1578    /// non-destructiveness rather than of "something got restored".
1579    #[test]
1580    fn zoom_gives_the_active_pane_the_whole_area_and_restores_on_toggle() {
1581        let mut t = two_pane_tree();
1582        let before = t.compute_rects(area());
1583        assert_eq!(before.len(), 2, "unzoomed: both panes get a rect");
1584
1585        assert!(t.toggle_zoom());
1586        let zoomed = t.compute_rects(area());
1587        assert_eq!(
1588            zoomed,
1589            vec![(t.active_index(), area())],
1590            "zoomed: one entry, the active pane, the full area"
1591        );
1592
1593        assert!(t.toggle_zoom());
1594        assert_eq!(t.compute_rects(area()), before, "layout restored verbatim");
1595    }
1596
1597    /// Nothing to hide, and marking it zoomed would light the
1598    /// indicator for a state the user cannot see.
1599    #[test]
1600    fn zoom_is_a_no_op_on_a_single_pane_tree() {
1601        let mut t = PaneTree::single(doc_state());
1602        assert!(!t.toggle_zoom());
1603        assert!(!t.is_zoomed());
1604        assert_eq!(t.compute_rects(area()).len(), 1);
1605    }
1606
1607    /// The invariant every `compute_rects` consumer leans on: if a
1608    /// pane is zoomed, it is the active one. Enforced on each mutation
1609    /// that could break it, so a call site cannot forget.
1610    #[test]
1611    fn focus_change_clears_zoom() {
1612        let mut t = two_pane_tree();
1613        t.toggle_zoom();
1614        assert!(t.set_active(0), "moved focus to the other pane");
1615        assert!(!t.is_zoomed(), "focus left the zoomed pane, zoom went too");
1616    }
1617
1618    #[test]
1619    fn splitting_while_zoomed_clears_zoom() {
1620        let mut t = two_pane_tree();
1621        t.toggle_zoom();
1622        t.split_active(SplitOrientation::Horizontal);
1623        assert!(!t.is_zoomed(), "the new sibling must be visible");
1624        assert_eq!(t.compute_rects(area()).len(), 3);
1625    }
1626
1627    #[test]
1628    fn closing_the_zoomed_pane_clears_zoom() {
1629        let mut t = two_pane_tree();
1630        t.toggle_zoom();
1631        assert!(t.close_active());
1632        assert!(!t.is_zoomed());
1633        assert_eq!(t.compute_rects(area()).len(), 1);
1634    }
1635
1636    #[test]
1637    fn only_clears_zoom() {
1638        let mut t = two_pane_tree();
1639        t.toggle_zoom();
1640        assert!(t.collapse_to_active());
1641        assert!(
1642            !t.is_zoomed(),
1643            "`:only` made the zoom permanent; the temporary form retires"
1644        );
1645    }
1646
1647    /// Resize + equalize describe a layout that is not on screen.
1648    /// Refusing beats silently reshaping it, which would hand the user
1649    /// back a layout they never asked to change.
1650    #[test]
1651    fn resize_and_equalize_are_refused_while_zoomed() {
1652        let mut t = two_pane_tree();
1653        // Nudge one ratio off 0.5 first, so `equalize_ratios` has real
1654        // work to do and returning `false` cannot be a false pass.
1655        assert!(t.resize_active_split(SplitOrientation::Vertical, 0.1));
1656        let shape = t.compute_rects(area());
1657
1658        t.toggle_zoom();
1659        assert!(!t.equalize_ratios(), "equalize refused while zoomed");
1660        assert!(
1661            !t.resize_active_split(SplitOrientation::Vertical, 0.2),
1662            "resize refused while zoomed"
1663        );
1664        t.toggle_zoom();
1665
1666        assert_eq!(shape, t.compute_rects(area()), "layout untouched");
1667    }
1668
1669    /// `<C-w>j` while zoomed must find the pane that is spatially
1670    /// below in the REAL layout — the zoom-aware view has one entry
1671    /// and would report no neighbour in any direction, making the
1672    /// navigation keys silently dead.
1673    #[test]
1674    fn navigation_while_zoomed_reads_the_unzoomed_layout() {
1675        let mut t = PaneTree::single(doc_state());
1676        let below = t.split_active(SplitOrientation::Horizontal);
1677        t.toggle_zoom();
1678        assert!(t.is_zoomed());
1679
1680        let target = t.navigate(PaneDirection::Down, area());
1681        assert_eq!(target, Some(below), "found the real spatial neighbour");
1682
1683        t.set_active(target.unwrap());
1684        assert!(!t.is_zoomed(), "navigating out drops the zoom");
1685    }
1686
1687    /// Zoom rides on `PaneTree`, and `TabSlot` stashes a whole tree
1688    /// (`ui/tab.rs` swaps them on tab switch). So zoom is per-tab with
1689    /// no extra stash/restore step — this pins that.
1690    #[test]
1691    fn zoom_travels_with_the_tab_across_a_swap() {
1692        let mut live = two_pane_tree();
1693        live.toggle_zoom();
1694        let mut stashed = two_pane_tree();
1695
1696        std::mem::swap(&mut live, &mut stashed);
1697        assert!(!live.is_zoomed(), "switched to the unzoomed tab");
1698        assert!(stashed.is_zoomed(), "the zoomed tab kept its zoom");
1699
1700        std::mem::swap(&mut live, &mut stashed);
1701        assert!(live.is_zoomed(), "and gets it back on return");
1702    }
1703
1704    /// ZP.3: the cross-renderer contract in one assertion.
1705    ///
1706    /// The TUI reaches zoom through `compute_rects`; the GPUI peer
1707    /// recurses over `PaneNode` from `render_root`. Two code paths,
1708    /// and the thing that must not drift between them is *which
1709    /// leaves are visible*. Anything else — a renderer showing a pane
1710    /// the other hides — is the pixel-level divergence the
1711    /// lockstep-parity rule exists to prevent.
1712    #[test]
1713    fn render_root_and_compute_rects_agree_on_the_visible_leaves() {
1714        let mut t = PaneTree::single(doc_state());
1715        t.split_active(SplitOrientation::Vertical);
1716        let third = t.split_active(SplitOrientation::Horizontal);
1717        t.set_active(third);
1718
1719        for zoom in [false, true] {
1720            if zoom {
1721                assert!(t.toggle_zoom());
1722            }
1723
1724            let mut from_rects: Vec<usize> = t
1725                .compute_rects(area())
1726                .into_iter()
1727                .map(|(i, _)| i)
1728                .collect();
1729            let mut from_root = Vec::new();
1730            t.render_root().for_each_leaf(&mut |i| from_root.push(i));
1731
1732            from_rects.sort_unstable();
1733            from_root.sort_unstable();
1734            assert_eq!(
1735                from_rects, from_root,
1736                "TUI and GPUI must paint the same leaf set (zoomed = {zoom})"
1737            );
1738        }
1739    }
1740
1741    /// `close_active` renumbers every leaf index above the removed
1742    /// one. Keying zoom on `PaneId` is what stops that renumbering
1743    /// re-pointing the zoom at a different pane; this is the
1744    /// regression test for using an index instead.
1745    #[test]
1746    fn zoom_is_keyed_on_pane_id_not_leaf_index() {
1747        let mut t = PaneTree::single(doc_state());
1748        t.split_active(SplitOrientation::Vertical);
1749        let third = t.split_active(SplitOrientation::Vertical);
1750        t.set_active(third);
1751        t.toggle_zoom();
1752        let zoomed_id = t.zoomed().unwrap();
1753
1754        // Close a LOWER-indexed pane: every index above it shifts.
1755        t.set_active(0);
1756        t.close_active();
1757
1758        // Re-zoom the same pane by id and confirm the id still names it.
1759        let idx = t.index_of(zoomed_id).expect("pane survived the close");
1760        t.set_active(idx);
1761        t.toggle_zoom();
1762        assert_eq!(t.zoomed(), Some(zoomed_id));
1763        assert_eq!(t.zoomed_index(), Some(idx));
1764    }
1765}