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}