pmacs/src/window.rs

755 lines
26 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
}
/// 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,
}
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,
}
}
/// 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,
}
/// 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,
}
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. 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) -> HashMap<WindowId, Rect> {
let mut out = HashMap::new();
compute_node(&self.root, area, &mut out);
out
}
/// 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 {
let ids = self.iter_ids();
match ids.iter().position(|&id| id == current) {
Some(i) => ids[(i + 1) % ids.len()],
None => *ids.first().unwrap_or(&current),
}
}
/// Step focus to the previous window.
#[must_use]
pub fn focus_prev(&self, current: WindowId) -> WindowId {
let ids = self.iter_ids();
match ids.iter().position(|&id| id == current) {
Some(i) => ids[(i + ids.len() - 1) % ids.len()],
None => *ids.first().unwrap_or(&current),
}
}
}
fn compute_node(node: &LayoutNode, area: Rect, out: &mut HashMap<WindowId, Rect>) {
match node {
LayoutNode::Leaf(id) => {
out.insert(*id, area);
}
LayoutNode::Split {
orientation,
weights,
children,
} => {
let total: u32 = weights.iter().map(|w| (*w).max(1)).sum();
let primary = match orientation {
Orientation::Horizontal => area.size.rows,
Orientation::Vertical => area.size.cols,
};
let mut cursor: u32 = 0;
for (i, child) in children.iter().enumerate() {
let w = weights.get(i).copied().unwrap_or(1).max(1);
let extent = if i + 1 == children.len() {
primary - cursor
} else {
primary * w / total
};
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, out);
cursor += extent;
}
}
}
}
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());
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());
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());
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));
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));
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));
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());
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]);
}
}