1336 lines
49 KiB
Rust
1336 lines
49 KiB
Rust
// 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(¤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<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]);
|
||
}
|
||
}
|