pmacs/src/window.rs

1336 lines
49 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<Self> {
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<u32>,
/// Saved region, if one was active.
selection: Option<Selection>,
/// The action that was in force *before* this presentation
/// replaced its predecessor.
then: Box<QuitAction>,
},
}
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<Side>,
/// 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<u32>,
/// 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<QuitAction>,
/// See [`WindowParams::origin_document`].
origin_document: Option<WindowId>,
}
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<QuitAction>) {
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<WindowId> {
self.origin_document
}
/// Record (or clear) the remembered document window. Rust-internal.
pub fn set_origin_document(&mut self, origin: Option<WindowId>) {
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<Box<dyn View>>,
/// 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<Selection>,
/// 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<u32>,
/// 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<dyn View>) {
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<dyn View>) {
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<u32>,
/// Children in display order (left→right or top→bottom).
children: Vec<LayoutNode>,
},
}
/// 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<DeclaredFrameGeometry>,
/// 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<WindowId, u32>) -> HashMap<WindowId, Rect> {
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<WindowId> {
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<WindowId> {
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(&current));
};
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<Vec<usize>> {
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<SplitBoundary> {
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<usize>,
/// 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<usize>) -> 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<WindowId, u32>,
out: &mut HashMap<WindowId, Rect>,
) {
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<Option<u32>> = 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<WindowId> {
let mut out = Vec::new();
collect_ids(node, &mut out);
out
}
fn collect_ids(node: &LayoutNode, out: &mut Vec<WindowId>) {
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]);
}
}