// window.rs --- Window tree, splits, and per-window state (T M2.8). //! A *window* displays a buffer in a region of the cell grid. The //! editor maintains a tree of windows: leaves render a single //! buffer; splits divide their parent's area horizontally //! (children stack vertically) or vertically (children sit //! side-by-side). The active window is identified by its //! [`WindowId`]; key events route to it. //! //! # Per-window state //! //! [`Window`] owns the cursor, scroll position, sticky goal column, //! and a [`TextView`] specific to its buffer. Two windows on the //! same buffer have independent cursors but share buffer content; //! when the buffer mutates, both windows' text views are notified //! by [`crate::editor_core::EditorCore`]. //! //! # Layout //! //! [`Layout::compute`] walks the tree given the available [`Rect`] //! and produces a per-window viewport rectangle. Splits are //! proportional with integer weights, so a SIGWINCH-driven resize //! is automatic: the new terminal area is just fed back through //! `compute` --- ratios are intrinsic to the tree, not derived from //! the previous absolute sizes. //! //! # Threading //! //! Single-threaded, like the rest of the editor core. Lives inside //! [`crate::editor_core::EditorCore`]. use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use crate::buffer::BufferId; use crate::cell::{CellCoord, CellSize}; use crate::rope::Position; use crate::text_view::TextView; use crate::view::View; // --------------------------------------------------------------------------- // WindowId // --------------------------------------------------------------------------- /// Stable identifier for a window. Allocated in monotonic order; /// reusing a freed id is not currently supported (window-close just /// drops the id permanently). #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct WindowId(u64); impl WindowId { /// Mint a new id. Allocates from a process-wide counter; ids are /// unique across the lifetime of the process. #[must_use] pub fn next() -> Self { static COUNTER: AtomicU64 = AtomicU64::new(1); Self(COUNTER.fetch_add(1, Ordering::Relaxed)) } /// The raw id, useful for debug formatting and tests. #[must_use] pub fn raw(self) -> u64 { self.0 } } // --------------------------------------------------------------------------- // Rect // --------------------------------------------------------------------------- /// Rectangular region of the cell grid (rows × cols at a given /// origin). Used for window viewports. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct Rect { /// Top-left corner. pub origin: CellCoord, /// Width and height. pub size: CellSize, } impl Rect { /// New rect at `(row, col)` of `(rows, cols)` size. #[must_use] pub fn new(row: u32, col: u32, rows: u32, cols: u32) -> Self { Self { origin: CellCoord::new(row, col), size: CellSize::new(rows, cols), } } /// True iff the rect has positive area. #[must_use] pub fn is_empty(&self) -> bool { self.size.rows == 0 || self.size.cols == 0 } } // --------------------------------------------------------------------------- // Orientation // --------------------------------------------------------------------------- /// Which axis a split divides. /// /// Naming follows Emacs's convention, which can be confusing: a /// **horizontal** split produces children stacked top-to-bottom (the /// dividing line is horizontal). A **vertical** split produces /// children side-by-side (the dividing line is vertical). #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum Orientation { /// Children stack top-to-bottom; rows are divided. Horizontal, /// Children sit side-by-side; columns are divided. Vertical, } // --------------------------------------------------------------------------- // Window // --------------------------------------------------------------------------- /// An active selection in a window. /// /// The region runs from `anchor` to the window's `cursor`. Either /// endpoint can be the lower bound; [`Selection::range`] returns them /// in canonical (lo, hi) order. A `Selection` with `anchor == cursor` /// is *active but empty*: useful for "shift-click extends" semantics. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct Selection { /// Where the selection began (mouse-down position, typically). pub anchor: Position, } /// Line-number display mode for a window's left gutter (UX gutter arc). /// /// Defined in `pmacs-protocol` so the wire, the daemon, and both frontends /// share one enum and one number rule ([`LineNumberMode::number_for`], /// Q#UX7); re-exported here so `crate::window::LineNumberMode` stays the /// in-crate path. pub use pmacs_protocol::LineNumberMode; /// Cells of horizontal padding the line-number gutter adds around the /// digit field: a leading and a trailing blank, so `gutter_w = digits + /// PAD` (Q#UX3). Kept as a named constant so both frontends can share the /// convention (Q#UX7). `u32` to match the cell-grid column type. pub const LINE_NUMBER_GUTTER_PAD: u32 = 2; /// Number of decimal digits in `n` (for `n >= 1`). Allocation-free. #[must_use] pub fn decimal_digits(mut n: usize) -> u32 { let mut d = 1u32; while n >= 10 { n /= 10; d += 1; } d } // --------------------------------------------------------------------------- // Window parameters (bottom-panel arc, Q#BP2) // --------------------------------------------------------------------------- /// Which edge of the frame a *side window* is pinned to. /// /// Stage 1 of the bottom-panel arc ships exactly one side. Left / right / /// top are named deferrals, so the enum stays closed rather than /// accepting a value no allocator honors: a Lua caller asking for an /// unsupported side gets a pointed error at the boundary instead of a /// silently ordinary window. #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub enum Side { /// Pinned to the bottom of the frame (the panel slot). Bottom, } impl Side { /// Parse the Lua-facing spelling. `None` for every unsupported value. #[must_use] pub fn from_name(name: &str) -> Option { match name { "bottom" => Some(Self::Bottom), _ => None, } } /// The Lua-facing spelling. #[must_use] pub fn name(self) -> &'static str { match self { Self::Bottom => "bottom", } } } /// Structural floor for a window's **outer** row extent: one text row /// plus its mode line (`content = outer - 1`). /// /// Every programmatic source of `fixed_rows` clamps a nonzero request up /// to this floor; a request of `0` is rejected rather than being an /// invisible "open" (Q#BP2). This is *not* a promise that the layout can /// never produce a smaller rect — [`Layout::compute`] has always been /// allowed to hand out zero extents on an intrinsically tiny frame. The /// bounded promise is narrower: the panel allocator never makes an /// otherwise satisfiable document tree unsatisfiable. pub const MIN_WINDOW_OUTER_ROWS: u32 = 2; /// Default `window.panel-height`: outer rows a freshly created panel /// takes when the caller supplies no explicit `height` (Q#BP11). pub const DEFAULT_PANEL_ROWS: u32 = 12; /// How far back [`QuitAction::Restore`] chains may be retained before the /// oldest retained presentation is truncated to [`QuitAction::Delete`] /// (Q#BP2c, R4-B6). Repeated panel replacement would otherwise grow the /// recursive history without bound. pub const MAX_PANEL_QUIT_DEPTH: usize = 64; /// What `window.quit` does to a side window (Q#BP2c). /// /// Present only on a side window; ordinary windows and every capability /// fallback carry `None`. Replacing a side presentation captures the /// outgoing one in `Restore` so `C → B → A → delete` restores the actual /// presentations rather than forgetting `A` or leaking `C`'s height and /// dedication into it. #[derive(Clone, Debug, PartialEq, Eq)] pub enum QuitAction { /// Close the side window and collapse its wrapper. Delete, /// Reinstate a previously displayed presentation, then fall back to /// `then` on the next quit. Restore { /// Buffer that was displayed. Revalidated at quit time: a killed /// buffer degrades the whole entry to [`QuitAction::Delete`]. buffer_id: BufferId, /// Requested outer rows of that presentation. fixed_rows: u32, /// Whether that presentation was dedicated. dedicated: bool, /// Saved cursor, clamped against the buffer's current contents. cursor: Position, /// Saved first visible line. view_top: usize, /// Saved sticky goal column. goal_col: Option, /// Saved region, if one was active. selection: Option, /// The action that was in force *before* this presentation /// replaced its predecessor. then: Box, }, } impl QuitAction { /// Number of retained presentations in this chain, counted /// iteratively so a long history can never blow the stack. #[must_use] pub fn depth(&self) -> usize { let mut depth = 0usize; let mut cursor = self; while let Self::Restore { then, .. } = cursor { depth += 1; cursor = then; } depth } /// Truncate the oldest retained `Restore` to [`QuitAction::Delete`] /// so the chain holds at most `cap` presentations. Iterative, like /// [`Self::depth`]. pub fn truncate_to(&mut self, cap: usize) { if cap == 0 { *self = Self::Delete; return; } let mut kept = 0usize; let mut cursor = self; loop { match cursor { Self::Delete => return, Self::Restore { then, .. } => { kept += 1; if kept >= cap { **then = Self::Delete; return; } cursor = then; } } } } } /// Per-window display-policy parameters (Q#BP2). /// /// `side` is immutable after placement; `quit_action` and /// `origin_document` are implementation-owned bookkeeping that the Lua /// `set_params` surface refuses to write (Q#BP2c), so Lua cannot forge a /// window id, a buffer restore chain, or stale cursor state. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct WindowParams { /// Side this window is pinned to, or `None` for an ordinary /// document window. Immutable after placement (Q#BP2a). pub side: Option, /// Requested **outer** rows (including the mode line) when this is a /// side window. Inert on any other window — the fixed map is built /// from side windows only. pub fixed_rows: Option, /// Whether `display_buffer` may replace this window's buffer. /// /// Binds the **policy layer only**: raw `pmacs.window.switch_buffer` /// and `switch_active_buffer_for` deliberately ignore it, because /// they are the low-level escape hatch and every existing caller /// predates this arc (Q#BP2c). pub dedicated: bool, /// See [`WindowParams::quit_action`]. quit_action: Option, /// See [`WindowParams::origin_document`]. origin_document: Option, } impl WindowParams { /// What `window.quit` does here, if anything. #[must_use] pub fn quit_action(&self) -> Option<&QuitAction> { self.quit_action.as_ref() } /// Install (or clear) the quit action. Rust-internal: no Lua path /// reaches this. pub fn set_quit_action(&mut self, action: Option) { self.quit_action = action; } /// The remembered document window this side window was entered /// from (Q#BP2c). Recorded at panel creation, refreshed on every /// focus transition from a non-side window into the panel, and /// revalidated on every use. #[must_use] pub fn origin_document(&self) -> Option { self.origin_document } /// Record (or clear) the remembered document window. Rust-internal. pub fn set_origin_document(&mut self, origin: Option) { self.origin_document = origin; } /// True iff this window is pinned to a side. #[must_use] pub fn is_side(&self) -> bool { self.side.is_some() } } /// One leaf of the window tree: a buffer plus per-window state. pub struct Window { /// Unique identifier. pub id: WindowId, /// Buffer displayed in this window. pub buffer_id: BufferId, /// Plain-text view of the buffer for this window. Each window /// owns its own; line offsets are independent. pub text_view: TextView, /// Composition stack: views that render after `text_view` into the /// same cell grid (T M2.9). See [`crate::view::View`] for the /// composition contract. Stored as trait objects so user-defined /// view kinds can join the stack via Lua in later milestones. pub overlays: Vec>, /// Byte position of this window's cursor. pub cursor: Position, /// Active region, if any (T M2.12). Mouse drag sets the anchor /// at mouse-down and updates the cursor as the mouse moves; the /// selection lives until cleared (mouse-up with no movement, a /// keystroke that cancels, or a region-aware command consumes it). pub selection: Option, /// First buffer line shown at the top of this window's viewport. pub view_top: usize, /// Sticky display column for vertical motion. pub goal_col: Option, /// Number of text rows that fit in this window's viewport at last /// render. Updated by the renderer; consumed by `cursor.page-down` /// / `cursor.page-up`. `0` until the first render lands. pub last_visible_rows: u32, /// Line-number gutter mode for this window (UX gutter arc). `Off` by /// default → no gutter, no coordinate change. pub line_numbers: LineNumberMode, /// Display-policy parameters (bottom-panel arc, Q#BP2). Default for /// every ordinary window: no side, no fixed extent, undedicated. pub params: WindowParams, } impl Window { /// New window for `buffer_id`, with an attached `text_view` and /// cursor at the start. #[must_use] pub fn new(id: WindowId, buffer_id: BufferId, text_view: TextView) -> Self { Self { id, buffer_id, text_view, overlays: Vec::new(), cursor: 0, selection: None, view_top: 0, goal_col: None, last_visible_rows: 0, line_numbers: LineNumberMode::Off, params: WindowParams::default(), } } /// True iff this window is pinned to a side (bottom-panel arc). #[must_use] pub fn is_side(&self) -> bool { self.params.is_side() } /// Width in cells this window's line-number gutter occupies, or `0` /// when disabled (UX gutter arc, Q#UX3). `digits(line_count) + PAD`; /// the renderer caps this against the window width and applies it as a /// left offset to the text area. Every gutter coordinate-math site /// reads this one function so the width stays consistent. #[must_use] pub fn gutter_width(&self) -> u32 { // Every on-mode reserves the same width — sized for the largest // number any mode could show (the absolute line count, which // bounds relative distances and hybrid's cursor-line number). A // fixed width keeps the text from jittering as the cursor moves in // relative/hybrid modes. if self.line_numbers.is_on() { decimal_digits(self.text_view.line_count().max(1)) + LINE_NUMBER_GUTTER_PAD } else { 0 } } /// Push an overlay onto the composition stack. Overlays render /// after `text_view`, in the order they were pushed. pub fn push_overlay(&mut self, view: Box) { self.overlays.push(view); } /// Push `view` unless an overlay with the same /// [`View::overlay_identity`] is already attached — attachment /// of store-backed overlays must be idempotent per window /// (PR #113 round-6 finding 1: repeated switches into a buffer /// stacked duplicate render views on passive panes, each cloning /// every span and rescanning the buffer per frame). Views /// without an identity always push. pub fn ensure_overlay(&mut self, view: Box) { if let Some(id) = view.overlay_identity() && self .overlays .iter() .any(|v| v.overlay_identity() == Some(id)) { return; } self.overlays.push(view); } /// Stable kind identifiers of every overlay on this window, in /// push order. Test seam used by `pmacs.window._overlay_kinds()` /// to verify that a specific overlay type actually attached /// (e.g. a code-format prompt result buffer expects a /// `"syntax-highlight"` overlay after the wire-up step). pub fn overlay_kinds(&self) -> Vec<&'static str> { self.overlays.iter().map(|v| v.kind()).collect() } /// Active region as `(lo, hi)` byte positions, if any. Returns /// `None` when no selection is active or when the selection is /// empty (anchor == cursor). #[must_use] pub fn region(&self) -> Option<(Position, Position)> { let sel = self.selection?; match sel.anchor.cmp(&self.cursor) { std::cmp::Ordering::Less => Some((sel.anchor, self.cursor)), std::cmp::Ordering::Greater => Some((self.cursor, sel.anchor)), std::cmp::Ordering::Equal => None, } } } // --------------------------------------------------------------------------- // LayoutNode + Layout // --------------------------------------------------------------------------- /// One node in the window tree. #[derive(Clone, Debug)] pub enum LayoutNode { /// A single window occupying its parent's area. Leaf(WindowId), /// A split with proportional integer weights. The weights vector /// always has the same length as `children`; weights of `0` are /// treated as `1` (defensive against empty weight specs from /// Lua). Split { /// Direction of the dividing line. orientation: Orientation, /// Per-child weights. Sum of weights determines proportional /// allocation across the parent's primary axis. weights: Vec, /// Children in display order (left→right or top→bottom). children: Vec, }, } /// Window tree + active focus. #[derive(Clone, Debug)] pub struct Layout { /// Root of the tree. pub root: LayoutNode, } /// A frontend's last authoritative cell-equivalent frame capacity /// (Q#BP2b / Q#BP15a). /// /// `geometry_epoch` is a monotonically increasing declaration id owned by /// the frontend. Grid / `LOCAL` views cache their real attach and resize /// sizes here with an internal epoch; a semantic view stays `None` — /// **unknown**, never `24×80` — until Stage 2's authenticated /// `FrontendCellGeometry` fills it. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct DeclaredFrameGeometry { /// Monotonic declaration id. A lower or repeated epoch carrying /// different data is stale. pub geometry_epoch: u64, /// Whole-frame capacity in cells, including the one global status row. pub total: CellSize, } /// T M10.8 — one attached frontend's view of the editor. /// /// Per-frontend state for multi-frontend operation: the split tree /// the frontend sees and which window within it is focused. /// `WindowId`s are globally unique across all frontends — the /// `EditorCore::windows` flat map holds every window, and each /// frontend's `FrontendView` references a subset via its `Layout`. /// /// The buffers themselves remain shared in `EditorCore::registry` — /// two frontends with windows onto the same `BufferId` see the same /// content but each window owns its own cursor / `view_top` / `goal_col`. #[derive(Clone, Debug)] pub struct FrontendView { /// Window tree visible to this frontend. pub layout: Layout, /// Focused window within `layout`. Always a `WindowId` that /// `layout` references (invariant: `layout.iter_ids()` contains /// `active`). pub active: WindowId, /// Whether this frontend's display *projects* folds — i.e. whether /// it collapses hidden lines away (Arc 6 Stage 2, Q#FD21). /// /// Motion, paging, wheel scrolling, click inverses, and the /// auto-scroll clamp all live in shared /// [`EditorCore`](crate::editor_core::EditorCore) code, but Stage 2 /// collapses only the **grid** renderer — a `semantic_render` (GPU) /// session still displays every source line until Stage 3, and both /// kinds may attach to one buffer at once over the same shared fold /// store. Reckoning in visible lines unconditionally would make that /// GPU session's cursor skip lines it is still showing, so every /// command/event-time visible-line reckoning is gated on the /// **acting** frontend's flag. /// /// **Render-time clamps used to need no gate, on the premise that a /// semantic session never enters `paint_frame`. The bottom-panel /// band breaks that premise** (Q#BP17): the daemon projects a /// semantic frontend's side window through the same per-window /// painter. So the extracted painters /// (`prepare_window_cursor_visible`, `paint_window_content`) take the /// visible-line map as a **parameter**, and the panel path passes /// `None` when the *owning* frontend's `fold_projection` is false. /// That path must not call `EditorCore::fold_map_for_window`, which /// gates on the **active** frontend — correct for command-time /// reckoning, wrong for painting another frontend's panel. /// /// Set at attach from the negotiated selected-render bit (grid ⇒ /// `true`, semantic ⇒ `false`), cleared with the view at detach, and /// `true` for [`FrontendId::LOCAL`](crate::protocol::FrontendId). /// Deliberately has no `Default`: every construction site chooses /// explicitly, so the projection is never inferred from a /// `FrontendId` (**Bet B8**). pub fold_projection: bool, /// Whether this frontend can *render* a side window (bottom-panel /// arc, Q#BP13). /// /// `true` for [`FrontendId::LOCAL`](crate::protocol::FrontendId) and /// every grid session. Stage 1 sets `false` for every semantic /// session — the GPU band is Stage 2 — so a `display` carrying a /// `side` falls back to the non-side target and **discards every /// side-specific parameter** rather than pinning a document window it /// could not show. Like `fold_projection`, deliberately has no /// `Default`: every construction site chooses explicitly. pub panel_capable: bool, /// This frontend's last authoritative frame capacity, or `None` while /// it is **unknown** (Q#BP2b). /// /// The panel allocator is the only consumer, and it must never guess: /// a panel requested before a real declaration stays non-presentable /// rather than being sized against the GPU attach request's permanent /// `24×80` placeholder. pub frame_geometry: Option, /// Cached derived layout state: the side window exists but cannot be /// satisfied on the current frame (Q#BP2b). /// /// Recomputed from authoritative geometry by /// `EditorState::reconcile_panel_layout`; never persisted, never set /// from Lua, and never `true` while no side window exists. pub panel_hidden: bool, } impl Layout { /// A trivial single-window layout. #[must_use] pub fn single(window: WindowId) -> Self { Self { root: LayoutNode::Leaf(window), } } /// Walk the tree and assign each leaf a viewport rectangle. /// /// Splits divide proportionally according to their weights, except /// that a leaf listed in `fixed` takes exactly that many **rows** out /// of a horizontal split before the remainder is divided (Q#BP2). /// The map is the *effective* allocation, not the stored request: a /// hidden panel is passed as `0`, which gives it an empty rect and /// hands every reclaimed row back to the document subtree. /// /// `fixed` is interpreted only on leaves of a **horizontal** split — /// a vertical split divides columns, where a row count means nothing /// — and the last flexible child still takes the remainder, so a tree /// with no fixed leaves computes byte-identically to before this arc. /// If a child's allocated extent is `0` (terminal too small for the /// split), that child receives an empty rect, and renderers must /// skip it. #[must_use] pub fn compute(&self, area: Rect, fixed: &HashMap) -> HashMap { let mut out = HashMap::new(); compute_node(&self.root, area, fixed, &mut out); out } /// The single side leaf among `sides`, if this layout holds one. /// /// `sides` answers "is this window pinned to a side"; the caller owns /// the `Window` table, so the predicate is injected rather than /// duplicated here. At most one bottom side leaf exists per /// `FrontendView` (Q#BP2a). #[must_use] pub fn side_leaf(&self, sides: impl Fn(WindowId) -> bool) -> Option { self.iter_ids().into_iter().find(|id| sides(*id)) } /// The document subtree beneath the root-level panel wrapper. /// /// A side window is installed as the final child of a horizontal /// split wrapping the entire prior root (Q#BP2a), so the document /// subtree is that wrapper's first child. Returns `None` when the /// tree does not have that exact shape. #[must_use] pub fn document_subtree(&self, side: WindowId) -> Option<&LayoutNode> { match &self.root { LayoutNode::Split { orientation: Orientation::Horizontal, children, .. } if children.len() == 2 && matches!(children[1], LayoutNode::Leaf(id) if id == side) => { Some(&children[0]) } _ => None, } } /// Wrap the entire current root in a horizontal split whose final /// child is `side` (Q#BP2a). /// /// `fixed_rows` makes the panel's weight inert, so the prior root /// keeps the flexible remainder and its **structure** — nodes, /// weights, order, ids — is untouched (Bet B6). pub fn install_side_leaf(&mut self, side: WindowId) { let prior = std::mem::replace(&mut self.root, LayoutNode::Leaf(side)); self.root = LayoutNode::Split { orientation: Orientation::Horizontal, weights: vec![1, 1], children: vec![prior, LayoutNode::Leaf(side)], }; } /// All [`WindowId`]s in left→right / top→bottom order. #[must_use] pub fn iter_ids(&self) -> Vec { let mut out = Vec::new(); collect_ids(&self.root, &mut out); out } /// Replace the leaf currently displaying `target` with a split. /// Returns `true` if the leaf was found and replaced. pub fn split_window( &mut self, target: WindowId, orientation: Orientation, new_window: WindowId, ) -> bool { split_node(&mut self.root, target, orientation, new_window) } /// Remove the leaf for `target`. Returns `true` if removed. /// Collapses single-child splits in the cleanup pass. pub fn close_window(&mut self, target: WindowId) -> bool { let removed = remove_leaf(&mut self.root, target).is_some(); if removed { collapse_single_child_splits(&mut self.root); } removed } /// Collapse the layout to just `keep`. Returns `false` if `keep` /// is not a leaf in the tree. pub fn keep_only(&mut self, keep: WindowId) -> bool { if !self.iter_ids().contains(&keep) { return false; } self.root = LayoutNode::Leaf(keep); true } /// Step focus from `current` to the next window in iteration /// order, wrapping around. Returns the new focus, or `current` /// if the layout has only one window. #[must_use] pub fn focus_next(&self, current: WindowId) -> WindowId { self.focus_step(current, true, &|_| true) } /// Step focus to the previous window. #[must_use] pub fn focus_prev(&self, current: WindowId) -> WindowId { self.focus_step(current, false, &|_| true) } /// [`Self::focus_next`] / [`Self::focus_prev`] restricted to windows /// `eligible` accepts (Q#BP6: a hidden panel is never a focus /// destination, though it becomes one again as soon as it reappears). /// /// A currently focused ineligible window can always leave, so the /// caller can never strand focus: `current` itself is not filtered. #[must_use] pub fn focus_step( &self, current: WindowId, forward: bool, eligible: &impl Fn(WindowId) -> bool, ) -> WindowId { let ids = self.iter_ids(); if ids.is_empty() { return current; } let Some(start) = ids.iter().position(|&id| id == current) else { return ids .iter() .copied() .find(|id| eligible(*id)) .unwrap_or_else(|| *ids.first().unwrap_or(¤t)); }; let n = ids.len(); for step in 1..=n { let i = if forward { (start + step) % n } else { (start + n - (step % n)) % n }; if eligible(ids[i]) { return ids[i]; } } current } /// Index path from the root to `target`'s leaf, or `None` when the /// layout does not hold it. #[must_use] pub fn path_to(&self, target: WindowId) -> Option> { let mut path = Vec::new(); path_to_node(&self.root, target, &mut path).then_some(path) } /// The node at `path`, or `None` when the path does not resolve. #[must_use] pub fn node_at(&self, path: &[usize]) -> Option<&LayoutNode> { let mut node = &self.root; for &i in path { match node { LayoutNode::Split { children, .. } => node = children.get(i)?, LayoutNode::Leaf(_) => return None, } } Some(node) } /// Mutable [`Self::node_at`]. pub fn node_at_mut(&mut self, path: &[usize]) -> Option<&mut LayoutNode> { let mut node = &mut self.root; for &i in path { match node { LayoutNode::Split { children, .. } => node = children.get_mut(i)?, LayoutNode::Leaf(_) => return None, } } Some(node) } /// The horizontal boundary immediately **below** `target` (Q#BP5b /// rule 2), or `None` when there is none. /// /// Walk up from the leaf to the nearest horizontal-split ancestor at /// which the path child has a **following sibling**. "Nearest /// horizontal ancestor" alone is wrong: when the subtree is that /// ancestor's *final* child there is no boundary below it there, and /// the real one is further up. This is also the boundary a drag on /// `target`'s bottom mode-line row moves, so keyboard resize and drag /// are the same operation (acceptance 31). #[must_use] pub fn boundary_below(&self, target: WindowId) -> Option { let path = self.path_to(target)?; for depth in (0..path.len()).rev() { let parent_path = &path[..depth]; let child_index = path[depth]; let LayoutNode::Split { orientation: Orientation::Horizontal, children, .. } = self.node_at(parent_path)? else { continue; }; if child_index + 1 < children.len() { return Some(SplitBoundary { path: parent_path.to_vec(), upper: child_index, }); } } None } } /// One horizontal split boundary: the split node plus the index of the /// child immediately **above** the dividing line (Q#BP5). #[derive(Clone, Debug, PartialEq, Eq)] pub struct SplitBoundary { /// Index path from the root to the horizontal split node. pub path: Vec, /// Index of the child above the boundary; `upper + 1` is below it. pub upper: usize, } fn path_to_node(node: &LayoutNode, target: WindowId, path: &mut Vec) -> bool { match node { LayoutNode::Leaf(id) => *id == target, LayoutNode::Split { children, .. } => { for (i, child) in children.iter().enumerate() { path.push(i); if path_to_node(child, target, path) { return true; } path.pop(); } false } } } /// Minimum **outer** rows a subtree needs for every one of its leaves to /// clear [`MIN_WINDOW_OUTER_ROWS`] (Q#BP2). /// /// The recursion is the point: "leave the document tree two rows" is /// wrong, because two rows at the root does not give each nested leaf two /// rows. Horizontal splits stack rows, so minima add; vertical splits /// share rows, so the tallest child governs. #[must_use] pub fn subtree_min_rows(node: &LayoutNode) -> u32 { match node { LayoutNode::Leaf(_) => MIN_WINDOW_OUTER_ROWS, LayoutNode::Split { orientation: Orientation::Horizontal, children, .. } => children.iter().map(subtree_min_rows).sum(), LayoutNode::Split { orientation: Orientation::Vertical, children, .. } => children.iter().map(subtree_min_rows).max().unwrap_or(0), } } /// The same sum/max recursion over the user's `window.min-height` /// *preference* (Q#BP2). /// /// `per_leaf` resolves the setting against that window's own buffer /// (buffer-local override → global → default) and is snapshotted once per /// gesture, before any geometry changes. Only **interactive** resize — /// drag, keyboard, and the Stage 2 `PanelResizeRows` — consults this; the /// ordinary layout pass and frame-resize reconciliation use /// [`subtree_min_rows`] alone, so changing a preference can never /// invalidate an existing layout. #[must_use] pub fn interactive_min_rows(node: &LayoutNode, per_leaf: &impl Fn(WindowId) -> u32) -> u32 { match node { LayoutNode::Leaf(id) => per_leaf(*id), LayoutNode::Split { orientation: Orientation::Horizontal, children, .. } => children .iter() .map(|child| interactive_min_rows(child, per_leaf)) .sum(), LayoutNode::Split { orientation: Orientation::Vertical, children, .. } => children .iter() .map(|child| interactive_min_rows(child, per_leaf)) .max() .unwrap_or(0), } } fn compute_node( node: &LayoutNode, area: Rect, fixed: &HashMap, out: &mut HashMap, ) { match node { LayoutNode::Leaf(id) => { out.insert(*id, area); } LayoutNode::Split { orientation, weights, children, } => { let primary = match orientation { Orientation::Horizontal => area.size.rows, Orientation::Vertical => area.size.cols, }; // Pass 1 — subtract the fixed children. Only a horizontal // split divides rows, so `fixed` is inert anywhere else. let mut extents: Vec> = vec![None; children.len()]; let mut fixed_total: u32 = 0; if matches!(orientation, Orientation::Horizontal) { for (i, child) in children.iter().enumerate() { if let LayoutNode::Leaf(id) = child && let Some(rows) = fixed.get(id).copied() { // Saturating: a request larger than the frame // takes what is left rather than wrapping. The // caller has already clamped against the document // minimum; this is the last-resort floor. let take = rows.min(primary.saturating_sub(fixed_total)); extents[i] = Some(take); fixed_total += take; } } } // Pass 2 — divide the remainder by weight among the flexible // children, preserving last-flexible-takes-the-remainder. let remainder = primary.saturating_sub(fixed_total); let total: u32 = children .iter() .enumerate() .filter(|(i, _)| extents[*i].is_none()) .map(|(i, _)| weights.get(i).copied().unwrap_or(1).max(1)) .sum(); let last_flexible = children .iter() .enumerate() .rev() .find(|(i, _)| extents[*i].is_none()) .map(|(i, _)| i); let mut flexible_used: u32 = 0; let mut cursor: u32 = 0; for (i, child) in children.iter().enumerate() { let extent = if let Some(rows) = extents[i] { rows } else { let w = weights.get(i).copied().unwrap_or(1).max(1); // u64 intermediates: `remainder * w` is the only // place this arithmetic could overflow a u32, and a // saturating fallback there would hand a non-last // child the whole remainder and underflow the last // one. Widening deletes the case outright. let e = if Some(i) == last_flexible { remainder - flexible_used } else if total == 0 { 0 } else { u32::try_from(u64::from(remainder) * u64::from(w) / u64::from(total)) .unwrap_or(remainder) }; flexible_used += e; e }; let child_area = match orientation { Orientation::Horizontal => Rect { origin: CellCoord::new(area.origin.row + cursor, area.origin.col), size: CellSize::new(extent, area.size.cols), }, Orientation::Vertical => Rect { origin: CellCoord::new(area.origin.row, area.origin.col + cursor), size: CellSize::new(area.size.rows, extent), }, }; compute_node(child, child_area, fixed, out); cursor += extent; } } } } /// Every [`WindowId`] beneath `node`, in layout order. #[must_use] pub fn node_ids(node: &LayoutNode) -> Vec { let mut out = Vec::new(); collect_ids(node, &mut out); out } fn collect_ids(node: &LayoutNode, out: &mut Vec) { match node { LayoutNode::Leaf(id) => out.push(*id), LayoutNode::Split { children, .. } => { for c in children { collect_ids(c, out); } } } } fn split_node( node: &mut LayoutNode, target: WindowId, orientation: Orientation, new_window: WindowId, ) -> bool { match node { LayoutNode::Leaf(id) if *id == target => { let original = *id; *node = LayoutNode::Split { orientation, weights: vec![1, 1], children: vec![LayoutNode::Leaf(original), LayoutNode::Leaf(new_window)], }; true } LayoutNode::Leaf(_) => false, LayoutNode::Split { children, .. } => children .iter_mut() .any(|c| split_node(c, target, orientation, new_window)), } } fn remove_leaf(node: &mut LayoutNode, target: WindowId) -> Option<()> { match node { LayoutNode::Leaf(_) => None, LayoutNode::Split { children, weights, .. } => { // Direct child match? if let Some(idx) = children .iter() .position(|c| matches!(c, LayoutNode::Leaf(id) if *id == target)) { children.remove(idx); if idx < weights.len() { weights.remove(idx); } return Some(()); } // Recurse into split children. for c in children.iter_mut() { if remove_leaf(c, target).is_some() { return Some(()); } } None } } } fn collapse_single_child_splits(node: &mut LayoutNode) { if let LayoutNode::Split { children, .. } = node { for c in children.iter_mut() { collapse_single_child_splits(c); } if children.len() == 1 { let only = children.remove(0); *node = only; } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn line_number_mode_number_for_covers_all_modes() { use LineNumberMode::{Absolute, Hybrid, Off, Relative}; // Cursor on buffer line 5 (0-based). Lines 3 and 7 are 2 away. assert_eq!(Off.number_for(3, 5), None); // Absolute ignores the cursor line: 1-based. assert_eq!(Absolute.number_for(3, 5), Some(4)); assert_eq!(Absolute.number_for(5, 5), Some(6)); // Relative: distance from the cursor line; cursor line is 0. assert_eq!(Relative.number_for(3, 5), Some(2)); assert_eq!(Relative.number_for(7, 5), Some(2)); assert_eq!(Relative.number_for(5, 5), Some(0)); // Hybrid: absolute on the cursor line, relative elsewhere. assert_eq!(Hybrid.number_for(5, 5), Some(6)); assert_eq!(Hybrid.number_for(3, 5), Some(2)); // Every on-mode reserves a gutter; Off does not. assert!(!Off.is_on()); assert!(Absolute.is_on() && Relative.is_on() && Hybrid.is_on()); } #[test] fn decimal_digits_counts_correctly() { assert_eq!(decimal_digits(1), 1); assert_eq!(decimal_digits(9), 1); assert_eq!(decimal_digits(10), 2); assert_eq!(decimal_digits(99), 2); assert_eq!(decimal_digits(100), 3); assert_eq!(decimal_digits(1000), 4); // A 6-digit file → 6 digits + PAD gutter. assert_eq!(decimal_digits(123_456), 6); } fn id() -> WindowId { WindowId::next() } fn rect_24x80() -> Rect { Rect::new(0, 0, 24, 80) } #[test] fn single_window_takes_full_area() { let w = id(); let layout = Layout::single(w); let placements = layout.compute(rect_24x80(), &HashMap::new()); assert_eq!(placements.get(&w), Some(&rect_24x80())); } #[test] fn vertical_split_divides_columns() { let a = id(); let b = id(); let mut layout = Layout::single(a); assert!(layout.split_window(a, Orientation::Vertical, b)); let placements = layout.compute(rect_24x80(), &HashMap::new()); let ra = placements[&a]; let rb = placements[&b]; assert_eq!(ra.size.rows, 24); assert_eq!(rb.size.rows, 24); assert_eq!(ra.size.cols + rb.size.cols, 80); assert_eq!(ra.origin.col, 0); assert_eq!(rb.origin.col, ra.size.cols); } #[test] fn horizontal_split_divides_rows() { let a = id(); let b = id(); let mut layout = Layout::single(a); assert!(layout.split_window(a, Orientation::Horizontal, b)); let placements = layout.compute(rect_24x80(), &HashMap::new()); let ra = placements[&a]; let rb = placements[&b]; assert_eq!(ra.size.cols, 80); assert_eq!(rb.size.cols, 80); assert_eq!(ra.size.rows + rb.size.rows, 24); } #[test] fn ratios_are_preserved_under_resize() { // 2:1 horizontal split. Resizing should preserve ratio. let a = id(); let b = id(); let mut layout = Layout::single(a); layout.split_window(a, Orientation::Vertical, b); if let LayoutNode::Split { weights, .. } = &mut layout.root { *weights = vec![2, 1]; } else { panic!("expected split"); } let p1 = layout.compute(Rect::new(0, 0, 24, 90), &HashMap::new()); assert_eq!(p1[&a].size.cols, 60); assert_eq!(p1[&b].size.cols, 30); // Resize down by 1/3. let p2 = layout.compute(Rect::new(0, 0, 24, 60), &HashMap::new()); assert_eq!(p2[&a].size.cols, 40); assert_eq!(p2[&b].size.cols, 20); // Resize wide. let p3 = layout.compute(Rect::new(0, 0, 24, 300), &HashMap::new()); assert_eq!(p3[&a].size.cols, 200); assert_eq!(p3[&b].size.cols, 100); } #[test] fn eight_splits_render_in_distinct_rects() { // Build an 8-way layout: vertical-of-4 over horizontal-of-2, // achieved by 3 vertical splits then 1 horizontal split per // column. Verify all 8 leaves get unique non-empty rects. let initial = id(); let mut layout = Layout::single(initial); let mut leaves = vec![initial]; // Split each existing leaf vertically until we have 4. for _ in 0..3 { let pivot = *leaves.last().unwrap(); let new = id(); assert!(layout.split_window(pivot, Orientation::Vertical, new)); leaves.push(new); } // Now horizontally split each leaf. let mut more = Vec::new(); for &l in &leaves { let new = id(); assert!(layout.split_window(l, Orientation::Horizontal, new)); more.push(new); } leaves.extend(more); assert_eq!(leaves.len(), 8); let placements = layout.compute(rect_24x80(), &HashMap::new()); assert_eq!(placements.len(), 8); // Every rect must be non-empty (terminal large enough). for id in &leaves { let r = placements[id]; assert!(!r.is_empty(), "rect for {id:?} was empty"); } // No two rects overlap (compare pairwise). let rects: Vec<_> = leaves.iter().map(|id| placements[id]).collect(); for i in 0..rects.len() { for j in (i + 1)..rects.len() { assert!(!rects_overlap(&rects[i], &rects[j])); } } } fn rects_overlap(a: &Rect, b: &Rect) -> bool { let a_r0 = a.origin.row; let a_r1 = a.origin.row + a.size.rows; let a_c0 = a.origin.col; let a_c1 = a.origin.col + a.size.cols; let b_r0 = b.origin.row; let b_r1 = b.origin.row + b.size.rows; let b_c0 = b.origin.col; let b_c1 = b.origin.col + b.size.cols; a_r0 < b_r1 && b_r0 < a_r1 && a_c0 < b_c1 && b_c0 < a_c1 } #[test] fn focus_next_walks_in_iteration_order() { let a = id(); let b = id(); let c = id(); let mut layout = Layout::single(a); layout.split_window(a, Orientation::Vertical, b); layout.split_window(b, Orientation::Horizontal, c); let order = layout.iter_ids(); assert_eq!(order.len(), 3); let mut cur = order[0]; for expected in &[order[1], order[2], order[0], order[1]] { cur = layout.focus_next(cur); assert_eq!(&cur, expected); } } #[test] fn focus_prev_is_inverse_of_focus_next() { let a = id(); let b = id(); let c = id(); let mut layout = Layout::single(a); layout.split_window(a, Orientation::Vertical, b); layout.split_window(b, Orientation::Horizontal, c); let order = layout.iter_ids(); let mut cur = order[0]; cur = layout.focus_next(cur); cur = layout.focus_prev(cur); assert_eq!(cur, order[0]); } #[test] fn close_window_collapses_single_child_split() { let a = id(); let b = id(); let mut layout = Layout::single(a); layout.split_window(a, Orientation::Vertical, b); assert_eq!(layout.iter_ids().len(), 2); assert!(layout.close_window(b)); assert_eq!(layout.iter_ids(), vec![a]); assert!(matches!(layout.root, LayoutNode::Leaf(_))); } #[test] fn keep_only_collapses_to_target() { let a = id(); let b = id(); let c = id(); let mut layout = Layout::single(a); layout.split_window(a, Orientation::Vertical, b); layout.split_window(a, Orientation::Horizontal, c); assert!(layout.keep_only(c)); assert_eq!(layout.iter_ids(), vec![c]); } #[test] fn keep_only_returns_false_for_unknown_id() { let a = id(); let bogus = id(); let mut layout = Layout::single(a); assert!(!layout.keep_only(bogus)); assert_eq!(layout.iter_ids(), vec![a]); } }