pmacs/src/editor_core.rs

2173 lines
79 KiB
Rust

// editor_core.rs --- Mutable world state shared between Rust and Lua.
//! [`EditorCore`] is the editor's world state: a buffer registry, a
//! window tree, a focused window, file metadata, and the
//! minibuffer. Lives behind a `Rc<RefCell<...>>` so the Lua-bound
//! primitives in [`crate::lua_bindings`] (`pmacs.editor.*`,
//! `pmacs.window.*`) can mutate it from inside command bodies
//! invoked through [`crate::lua::LuaHost::invoke_command`].
//!
//! # Window model (T M2.8)
//!
//! Buffers live in [`BufferRegistry`]. Each [`Window`] points at one
//! by [`BufferId`] and owns its own cursor / view-top / goal-column /
//! [`TextView`]. The [`Layout`] tree maps the cell grid to per-window
//! viewport rectangles. A single [`WindowId`] is "active": every
//! `pmacs.editor.*` primitive operates on it; cursor and edits in
//! the run loop dispatch through it.
//!
//! When the active buffer mutates, [`EditorCore::apply_active_edit`]
//! notifies *every* window whose `buffer_id` matches the active
//! window's --- two windows on the same buffer keep their layout
//! caches synchronized.
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use crate::buffer::{Buffer, BufferId, EditOp};
use crate::file_io::{FileMeta, save_atomic};
use crate::lua_bindings::SharedRegistry;
use crate::minibuffer::Minibuffer;
use crate::protocol::FrontendId;
use crate::rope::Edit;
use crate::rope::{Position, Range};
use crate::text_view::TextView;
use crate::view::{DisplayCoord, View};
use crate::window::{FrontendView, Layout, Orientation, Window, WindowId};
/// T M10.10 post-audit-round-3 F16 — origin of a queued CRDT op.
///
/// Records **whether the originating frontend already applied the
/// op to its local mirror**, which determines whether the broadcast
/// sweep should exclude that frontend.
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum CrdtOpOrigin {
/// A replica frontend's `FrontendEvent::CrdtOp` path applied the
/// op to its local mirror before sending. Broadcast must exclude
/// that frontend (it would double-apply otherwise — see
/// `BufferMirror::apply_local_insert` /
/// `apply_local_delete` and `optimistic::apply_incoming_crdt_op`'s
/// echo-skip rule).
OptimisticReplica(FrontendId),
/// Daemon-side mutation (a `FrontendEvent::Key` round-trip, a
/// Lua-driven edit, a fallback path) generated the op. No
/// frontend has applied it locally; broadcast to every replica
/// frontend, including the one whose `Key` event drove the
/// daemon path (its mirror is otherwise stale).
DaemonKey,
}
/// The world state mutated by editor commands.
pub struct EditorCore {
/// Shared buffer registry. The registry is the canonical owner
/// of every buffer; windows reference buffers by [`BufferId`].
pub registry: SharedRegistry,
/// All windows, keyed by id for stable iteration. `WindowId`s
/// are globally unique across all frontends; each
/// [`FrontendView`] in `views` references a subset via its
/// `Layout`.
pub windows: BTreeMap<WindowId, Window>,
/// T M10.8 — per-frontend views. Each attached frontend has its
/// own `Layout` (split tree) + `active: WindowId`. Buffers are
/// shared via `registry`; cursors / `view_top`s live in the
/// per-frontend `Window` instances.
///
/// Invariant: `FrontendId::LOCAL` always has an entry. The
/// in-process editor uses this view; daemon-attached frontends
/// register additional entries on attach (M10.8 Day 3 wires
/// the per-attach registration via the dispatcher; Day 2 ships
/// a fallback-to-LOCAL accessor so single-frontend tests pass
/// before per-attach registration lands).
pub views: HashMap<FrontendId, FrontendView>,
/// One-line message shown in the status line.
pub status: String,
/// True iff the editor should exit at the next iteration.
pub quit: bool,
/// Universal minibuffer (T M2.7).
pub minibuffer: Minibuffer,
/// The frontend that produced the most recent input event
/// dispatched to this core (T M5.4). v0.1 has a single frontend
/// per instance, so this stays at [`FrontendId::LOCAL`] in
/// practice; the field is load-bearing for v0.3 multi-frontend
/// (multi-window, multi-user) where each input event must be
/// attributable to its source frontend.
pub active_frontend: FrontendId,
/// T M10.8 Day 4 — pending CRDT ops queue.
///
/// Each [`CrdtOpOrigin`] entry records both **what** to broadcast
/// and **who already applied it locally** (the sender-exclusion
/// signal). The dispatcher drains the queue per-tick and
/// broadcasts each op to multi-frontend sessions with
/// `crdt_replica` negotiated.
///
/// # M10.10 post-audit-round-3 F16: origin tagging
///
/// Sender exclusion depends on **whether the originating
/// frontend already applied the op to its local mirror**:
///
/// - [`CrdtOpOrigin::OptimisticReplica`] — a replica frontend's
/// `FrontendEvent::CrdtOp` path applied the op to its mirror
/// before sending. Broadcast must exclude that frontend so it
/// doesn't double-apply.
/// - [`CrdtOpOrigin::DaemonKey`] — daemon-side mutation (a
/// `FrontendEvent::Key` round-trip, a Lua-driven edit, etc.)
/// generated the op. No frontend's mirror has applied it
/// locally; broadcast must include every replica frontend
/// *including* the active one. Without this, the
/// active frontend's mirror would silently drift from daemon
/// state after every fallback / Key-path edit.
pub pending_crdt_ops: Vec<(CrdtOpOrigin, BufferId, crate::rope::CrdtOp)>,
/// T M4.5 L1 — bounded jump ring. Cross-file navigation
/// (`go-to-definition`, references, symbol jumps) pushes the
/// pre-jump `(BufferId, Position)` here before moving the cursor;
/// `M-,` (`jump_back`) pops the most recent entry and restores
/// it. Bounded at [`Self::JUMP_RING_CAP`]: the oldest entry is
/// evicted when full, so a long navigation session can't grow
/// this without limit. Entries naming a now-removed buffer are
/// skipped on pop (stale-handle safe, mirrors the registry's
/// `Missing` contract).
pub jump_ring: Vec<(BufferId, Position)>,
}
impl EditorCore {
/// A fresh core with one window on a `*scratch*` buffer.
#[must_use]
pub fn new(registry: SharedRegistry) -> Self {
let buffer_id = registry.borrow_mut().create("*scratch*");
let text_view = {
let r = registry.borrow();
let buf = r.get(buffer_id).expect("just-created scratch buffer");
TextView::new(buf)
};
let id = WindowId::next();
let window = Window::new(id, buffer_id, text_view);
let mut windows = BTreeMap::new();
windows.insert(id, window);
let mut views = HashMap::new();
views.insert(
FrontendId::LOCAL,
FrontendView {
layout: Layout::single(id),
active: id,
},
);
Self {
registry,
windows,
views,
status: String::new(),
quit: false,
minibuffer: Minibuffer::new(),
active_frontend: FrontendId::LOCAL,
pending_crdt_ops: Vec::new(),
jump_ring: Vec::new(),
}
}
/// Build a core from raw bytes under `name`. Used by tests.
/// Replaces the scratch buffer's content; the active window is
/// retained.
#[must_use]
pub fn from_bytes(registry: SharedRegistry, name: impl Into<String>, bytes: &[u8]) -> Self {
let mut core = Self::new(registry);
let id = core.active_window().buffer_id;
let new_id = {
let mut reg = core.registry.borrow_mut();
let new_id = reg.create_from_bytes(name, bytes);
// Replace the active window's buffer with the new one.
let _ = reg.remove(id);
new_id
};
let text_view = {
let reg = core.registry.borrow();
TextView::new(reg.get(new_id).unwrap())
};
let aw = core.active_window_mut();
aw.buffer_id = new_id;
aw.text_view = text_view;
aw.cursor = 0;
aw.view_top = 0;
aw.goal_col = None;
core
}
// ---- accessors ---------------------------------------------------------
/// T M10.8 — the active frontend's view (layout + active window).
///
/// **Day 2 transitional behavior**: if `active_frontend` has no
/// registered view (the daemon-attached frontend case before Day
/// 3's dispatcher refactor wires `register_frontend_view`), fall
/// back to `FrontendId::LOCAL`'s view. The invariant "LOCAL
/// always has a view" is enforced by the constructor.
#[must_use]
pub fn active_view(&self) -> &FrontendView {
self.views.get(&self.active_frontend).unwrap_or_else(|| {
self.views.get(&FrontendId::LOCAL).expect(
"invariant: FrontendId::LOCAL always has a registered FrontendView; \
populated by EditorCore::new and never removed",
)
})
}
/// Mutable view of the active frontend's [`FrontendView`].
///
/// Same fallback semantics as [`active_view`].
pub fn active_view_mut(&mut self) -> &mut FrontendView {
// Choose the key first to avoid borrowing `self.views`
// twice with overlapping lifetimes (the fallback path).
let key = if self.views.contains_key(&self.active_frontend) {
self.active_frontend
} else {
FrontendId::LOCAL
};
self.views.get_mut(&key).expect(
"invariant: FrontendId::LOCAL always has a registered FrontendView; \
populated by EditorCore::new and never removed",
)
}
/// The active frontend's window-split tree.
#[must_use]
pub fn active_layout(&self) -> &Layout {
&self.active_view().layout
}
/// Mutable access to the active frontend's window-split tree.
pub fn active_layout_mut(&mut self) -> &mut Layout {
&mut self.active_view_mut().layout
}
/// `WindowId` of the active frontend's focused window.
#[must_use]
pub fn active_window_id(&self) -> WindowId {
self.active_view().active
}
/// Set the active frontend's focused window.
pub fn set_active_window_id(&mut self, id: WindowId) {
self.active_view_mut().active = id;
}
/// Reference the active [`Window`] — the window currently
/// focused in the active frontend's view.
#[must_use]
pub fn active_window(&self) -> &Window {
let id = self.active_window_id();
self.windows
.get(&id)
.expect("active window present in core.windows")
}
/// Mutably reference the active [`Window`].
pub fn active_window_mut(&mut self) -> &mut Window {
let id = self.active_window_id();
self.windows
.get_mut(&id)
.expect("active window present in core.windows")
}
/// Reference a specific frontend's active [`Window`].
///
/// Returns `None` if `fid` has no registered view (no fallback —
/// callers explicitly asking about a specific frontend get a
/// truthful answer about whether that frontend has state).
#[must_use]
pub fn active_window_for(&self, fid: FrontendId) -> Option<&Window> {
let view = self.views.get(&fid)?;
self.windows.get(&view.active)
}
/// Mutably reference a specific frontend's active [`Window`].
pub fn active_window_mut_for(&mut self, fid: FrontendId) -> Option<&mut Window> {
let win_id = self.views.get(&fid)?.active;
self.windows.get_mut(&win_id)
}
/// T M10.8 — register a `FrontendView` for `fid`. Called by the
/// daemon on attach (Day 3 dispatcher work). Day 2's fallback
/// path makes this optional; Day 3 makes it required.
pub fn register_frontend_view(&mut self, fid: FrontendId, view: FrontendView) {
self.views.insert(fid, view);
}
/// T M10.8 — drop a frontend's view on detach. The frontend's
/// windows remain in `self.windows` until explicit cleanup (M10.x
/// may add per-detach window pruning); for M10.8 they're
/// orphaned but accessible by id (matches v0.1 behavior where
/// closing a window left others intact).
pub fn unregister_frontend_view(&mut self, fid: FrontendId) {
self.views.remove(&fid);
}
/// [`BufferId`] of the active window's buffer.
#[must_use]
pub fn active_buffer_id(&self) -> BufferId {
self.active_window().buffer_id
}
/// Path bound to the active window's buffer, if any. T M4.5 L1:
/// replaces the old `EditorCore.file_path` field — it now lives
/// per-buffer so cross-file navigation keeps each buffer's
/// identity straight.
#[must_use]
pub fn active_buffer_path(&self) -> Option<PathBuf> {
let id = self.active_buffer_id();
self.registry
.borrow()
.get(id)
.ok()
.and_then(|b| b.file_path().map(Path::to_path_buf))
}
/// Filesystem metadata recorded for the active window's buffer.
#[must_use]
pub fn active_file_meta(&self) -> Option<FileMeta> {
let id = self.active_buffer_id();
self.registry
.borrow()
.get(id)
.ok()
.and_then(|b| b.file_meta().cloned())
}
/// Bind a path (and clear metadata) on a specific buffer. Used by
/// file open / `pmacs.buffer.from_file`.
///
/// The path is normalized to an absolute, lexically-clean form
/// first ([`normalize_buffer_path`]). This is the single seam
/// every buffer identity flows through (CLI open, Lua find-file,
/// `WorkspaceEdit` rename ops), so doing it here keeps the invariant
/// "a buffer's `file_path` is always absolute" — which the LSP
/// layer relies on to build a resolvable `file:///…` URI (a
/// relative or `~`-prefixed path produced `file://ipc.cpp`, which
/// clangd rejected with `-32602 unresolvable URI`) and which
/// cross-file navigation relies on for buffer-identity matching.
pub fn set_buffer_path(&mut self, id: BufferId, path: Option<PathBuf>) {
let path = path.map(normalize_buffer_path);
if let Ok(b) = self.registry.borrow_mut().get_mut(id) {
b.set_file_path(path);
}
}
/// Record filesystem metadata on a specific buffer.
pub fn set_buffer_meta(&mut self, id: BufferId, meta: Option<FileMeta>) {
if let Ok(b) = self.registry.borrow_mut().get_mut(id) {
b.set_file_meta(meta);
}
}
/// Cursor of the active window (compatibility shim for callers
/// migrated from pre-M2.8 code).
#[must_use]
pub fn cursor(&self) -> Position {
self.active_window().cursor
}
/// `view_top` of the active window.
#[must_use]
pub fn view_top(&self) -> usize {
self.active_window().view_top
}
/// Active buffer's byte length.
#[must_use]
pub fn active_buffer_len(&self) -> u64 {
let id = self.active_buffer_id();
self.registry.borrow().get(id).map_or(0, Buffer::len)
}
/// Active buffer's name. Returns an owned String to release the
/// registry borrow promptly.
#[must_use]
pub fn active_buffer_name(&self) -> String {
let id = self.active_buffer_id();
self.registry
.borrow()
.get(id)
.map(|b| b.name().to_owned())
.unwrap_or_default()
}
/// Returns true iff the active buffer has unsaved modifications.
#[must_use]
pub fn active_buffer_is_modified(&self) -> bool {
let id = self.active_buffer_id();
self.registry
.borrow()
.get(id)
.is_ok_and(Buffer::is_modified)
}
/// 0-based line index containing the active window's cursor.
#[must_use]
pub fn cursor_line(&self) -> usize {
let aw = self.active_window();
aw.text_view.line_at_offset(aw.cursor)
}
/// Move the active window's cursor to the start of a 0-based line.
/// Out-of-range line numbers clamp to the last line.
pub fn move_to_line(&mut self, line: usize) {
let line_count = self.active_window().text_view.line_count().max(1);
let target_line = line.min(line_count - 1);
let target = self
.active_window()
.text_view
.line_offset(target_line)
.unwrap_or_else(|| self.active_buffer_len());
let aw = self.active_window_mut();
aw.cursor = target;
aw.goal_col = None;
}
// ---- jump ring (T M4.5 L1) ---------------------------------------------
/// Bound on [`Self::jump_ring`]. Large enough for a deep
/// cross-file dig (definition → definition → references …),
/// small enough that a stuck loop can't grow memory unbounded.
pub const JUMP_RING_CAP: usize = 64;
/// Record the active window's current `(buffer, cursor)` as a
/// jump origin. Call this *before* moving the cursor on a
/// navigation action (go-to-definition, references, symbol jump)
/// so `M-,` can return here.
///
/// When the ring is at [`Self::JUMP_RING_CAP`], the oldest
/// origin is evicted (front drop) — the user keeps the most
/// recent trail, which is the one they're likely to unwind.
pub fn push_jump(&mut self) {
let entry = (self.active_buffer_id(), self.cursor());
if self.jump_ring.len() >= Self::JUMP_RING_CAP {
self.jump_ring.remove(0);
}
self.jump_ring.push(entry);
}
/// Pop the most recent jump origin and move there. Returns
/// `true` if a jump was performed.
///
/// Stale entries — a recorded buffer that has since been removed
/// from the registry — are skipped (the loop keeps popping until
/// it finds a live target or the ring empties), so a jump-back
/// never lands on a missing buffer. The restored cursor is
/// clamped to the (possibly now shorter) buffer length.
pub fn jump_back(&mut self) -> bool {
while let Some((bid, pos)) = self.jump_ring.pop() {
if !self.registry.borrow().contains(bid) {
continue;
}
if self.active_buffer_id() != bid && self.switch_active_buffer(bid).is_err() {
continue;
}
let clamped = pos.min(self.active_buffer_len());
let aw = self.active_window_mut();
aw.cursor = clamped;
aw.goal_col = None;
return true;
}
false
}
// ---- editing primitives ------------------------------------------------
/// Apply `op` to the active buffer; notify every window
/// displaying that buffer. Returns the new buffer length.
///
/// # Errors
///
/// Returns a stringified error on buffer or view failure.
pub fn apply_active_edit(&mut self, op: EditOp<'_>) -> Result<u64, String> {
let buffer_id = self.active_buffer_id();
let mut reg = self.registry.borrow_mut();
let buffer = reg.get_mut(buffer_id).map_err(|e| e.to_string())?;
let edit = buffer.apply_edit(op).map_err(|e| e.to_string())?;
for win in self.windows.values_mut() {
if win.buffer_id == buffer_id {
let _ = win.text_view.on_edit(buffer, &edit);
for overlay in &mut win.overlays {
let _ = overlay.on_edit(buffer, &edit);
}
}
}
// T M10.8 Day 4 — capture CRDT op (if the buffer was in
// CRDT mode and produced one) for the dispatcher to
// broadcast on the next tick.
//
// M10.10 post-audit-round-3 F16: this is the **daemon-side**
// mutation path (e.g. `FrontendEvent::Key` round-trip,
// Lua-driven edit, fallback). The source frontend's mirror
// has NOT applied this op locally; the queued origin is
// [`CrdtOpOrigin::DaemonKey`] so the broadcast sweep includes
// every replica (no sender exclusion).
if let Some(crdt_op) = edit.crdt_op.as_ref() {
self.pending_crdt_ops
.push((CrdtOpOrigin::DaemonKey, buffer_id, (**crdt_op).clone()));
}
Ok(edit.new_rope.len())
}
/// Notify every window displaying `buffer_id` that the buffer was
/// just edited externally — used by code paths that mutate a buffer
/// without going through [`Self::apply_active_edit`] (the most
/// notable one being [`crate::lua::LuaHost::append_to_errors_buffer`],
/// which writes to `*errors*` from inside Lua callbacks).
///
/// Without this notification, any window currently displaying the
/// edited buffer would keep a stale [`crate::text_view::TextView`]
/// line cache, causing later cursor motions to land at offsets the
/// view cannot map back to display coordinates.
pub fn notify_buffer_edit(&mut self, buffer_id: BufferId, edit: &Edit) {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(buffer_id) else {
return;
};
for win in self.windows.values_mut() {
if win.buffer_id == buffer_id {
let _ = win.text_view.on_edit(buffer, edit);
for overlay in &mut win.overlays {
let _ = overlay.on_edit(buffer, edit);
}
}
}
}
/// Force every window currently showing `buffer_id` to rebuild
/// its [`TextView`] from scratch.
///
/// Used by code paths that rewrite a buffer end-to-end without
/// emitting a useful [`Edit`] (the help renderer issues a
/// delete-all + insert pair on `*help*`; `*buffer-list*` is
/// regenerated from scratch on every C-x C-b). Calling
/// [`Self::notify_buffer_edit`] for each step works but is more
/// fiddly; rebuild is simpler and still O(buffer length) which is
/// what an end-to-end rewrite cost anyway.
///
/// Cursor and `view_top` are clamped to the new buffer extent so
/// they don't dangle past the end after a shrinking rewrite.
pub fn rebuild_views_for(&mut self, buffer_id: BufferId) {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(buffer_id) else {
return;
};
let len = buffer.len();
for win in self.windows.values_mut() {
if win.buffer_id == buffer_id {
win.text_view = TextView::new(buffer);
if win.cursor > len {
win.cursor = len;
}
let max_top = win.text_view.line_count().saturating_sub(1);
if win.view_top > max_top {
win.view_top = max_top;
}
}
}
}
/// Save the active buffer to its backing file. Returns `true` on
/// successful write; `false` if no path is associated, the buffer
/// could not be read, or the atomic save failed. Callers (the
/// `buffer.save` Lua command) use the return value to gate
/// `buffer.after-save` firing.
pub fn save(&mut self) -> bool {
let id = self.active_buffer_id();
let Some(path) = self.active_buffer_path() else {
self.status = "no file (M1: open a file from argv)".into();
return false;
};
let len_and_bytes = {
let reg = self.registry.borrow();
let buffer = match reg.get(id) {
Ok(b) => b,
Err(e) => {
self.status = format!("save failed: {e}");
return false;
}
};
let len = buffer.len();
let mut content = vec![0u8; len as usize];
if len > 0 {
buffer.snapshot_rope().slice(0, len, &mut content);
}
(len, content)
};
let (_, content) = len_and_bytes;
match save_atomic(&path, &content) {
Ok(meta) => {
if let Ok(buf) = self.registry.borrow_mut().get_mut(id) {
buf.set_file_meta(Some(meta));
buf.mark_clean();
}
self.status = format!("saved {}", path.display());
true
}
Err(e) => {
self.status = format!("save failed: {e}");
false
}
}
}
/// Move the cursor by one codepoint to the left. No-op at start.
pub fn move_left(&mut self) {
let cursor = self.active_window().cursor;
if cursor == 0 {
self.active_window_mut().goal_col = None;
return;
}
let new = {
let id = self.active_buffer_id();
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
prev_codepoint(buffer, cursor)
};
let aw = self.active_window_mut();
aw.cursor = new;
aw.goal_col = None;
}
/// Move the cursor by one codepoint to the right. No-op at end.
pub fn move_right(&mut self) {
let cursor = self.active_window().cursor;
let new = {
let id = self.active_buffer_id();
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
if cursor >= buffer.len() {
cursor
} else {
next_codepoint(buffer, cursor)
}
};
let aw = self.active_window_mut();
aw.cursor = new;
aw.goal_col = None;
}
/// Move the cursor up one line, preserving display column.
pub fn move_up(&mut self) {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let goal_col = self.active_window().goal_col;
let result = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
let coord = self
.active_window()
.text_view
.pos_to_display(buffer, cursor)
.unwrap_or_default();
if coord.row == 0 {
return;
}
let goal = goal_col.unwrap_or(coord.col);
let target = DisplayCoord::new(coord.row - 1, goal);
let new_pos = self
.active_window()
.text_view
.display_to_pos(buffer, target);
(goal, new_pos)
};
let (goal, new_pos) = result;
let aw = self.active_window_mut();
aw.goal_col = Some(goal);
if let Some(p) = new_pos {
aw.cursor = p;
}
}
/// Move the cursor down one line, preserving display column.
pub fn move_down(&mut self) {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let goal_col = self.active_window().goal_col;
let result = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
let coord = self
.active_window()
.text_view
.pos_to_display(buffer, cursor)
.unwrap_or_default();
let next_row = coord.row + 1;
if (next_row as usize) >= self.active_window().text_view.line_count() {
return;
}
let goal = goal_col.unwrap_or(coord.col);
let target = DisplayCoord::new(next_row, goal);
let new_pos = self
.active_window()
.text_view
.display_to_pos(buffer, target);
(goal, new_pos)
};
let (goal, new_pos) = result;
let aw = self.active_window_mut();
aw.goal_col = Some(goal);
if let Some(p) = new_pos {
aw.cursor = p;
}
}
/// Move to the start of the current line.
pub fn move_line_start(&mut self) {
let cursor = self.active_window().cursor;
let new = {
let aw = self.active_window();
let line = aw.text_view.line_at_offset(cursor);
aw.text_view.line_offset(line).unwrap_or(cursor)
};
let aw = self.active_window_mut();
aw.cursor = new;
aw.goal_col = None;
}
/// Move the cursor forward by one word.
///
/// Skips runs of non-word characters, then a run of word
/// characters. Word characters are alphanumerics plus `_`, the
/// Emacs default. Multi-byte characters are handled correctly:
/// `is_word` runs after a full UTF-8 codepoint is decoded.
pub fn move_word_right(&mut self) {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let new = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
forward_word(buffer, cursor)
};
let aw = self.active_window_mut();
aw.cursor = new;
aw.goal_col = None;
}
/// Move the cursor backward by one word. Mirror of
/// [`Self::move_word_right`].
pub fn move_word_left(&mut self) {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let new = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
backward_word(buffer, cursor)
};
let aw = self.active_window_mut();
aw.cursor = new;
aw.goal_col = None;
}
/// Select the word at the active cursor. Returns `false` when the
/// cursor is not on a word character.
pub fn select_word_at_cursor(&mut self) -> bool {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let range = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else {
return false;
};
word_range_at(buffer, cursor)
};
let Some((start, end)) = range else {
return false;
};
let aw = self.active_window_mut();
aw.selection = Some(crate::window::Selection { anchor: start });
aw.cursor = end;
aw.goal_col = None;
true
}
/// Select the whole line at the active cursor, trailing newline
/// included — the convention that makes consecutive triple-click
/// lines abut (Q#M4). The cursor lands at the selection end (the
/// start of the next line). No-op when the buffer is gone.
pub fn select_line_at_cursor(&mut self) {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let (start, end) = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else {
return;
};
let view = &self.active_window().text_view;
let line = view.line_at_offset(cursor);
let start = view.line_offset(line).unwrap_or(0);
let end = view.line_offset(line + 1).unwrap_or_else(|| buffer.len());
(start, end)
};
let aw = self.active_window_mut();
aw.selection = Some(crate::window::Selection { anchor: start });
aw.cursor = end;
aw.goal_col = None;
}
/// Move the cursor forward to the next paragraph break.
///
/// A paragraph break is a blank line (empty or whitespace-only).
/// If the cursor is currently in a paragraph, the cursor lands at
/// the start of the first blank line after it. If the cursor is
/// already on a blank line, blanks are skipped first, then the
/// next blank line is found. Lands at the end of the buffer when
/// there are no further paragraph breaks. Mirrors GNU Emacs's
/// (and Doom's) `forward-paragraph` semantics.
pub fn move_paragraph_down(&mut self) {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let new = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
let aw = self.active_window();
forward_paragraph(buffer, &aw.text_view, cursor)
};
let aw = self.active_window_mut();
aw.cursor = new;
aw.goal_col = None;
}
/// Move the cursor backward to the previous paragraph break.
/// Mirror of [`Self::move_paragraph_down`].
pub fn move_paragraph_up(&mut self) {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let new = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
let aw = self.active_window();
backward_paragraph(buffer, &aw.text_view, cursor)
};
let aw = self.active_window_mut();
aw.cursor = new;
aw.goal_col = None;
}
/// Move the cursor down by approximately one screenful, scrolling
/// `view_top` to match. The step is the active window's last
/// rendered viewport height minus one (so the user keeps a line
/// of context); falls back to a sane default before the first
/// frame has rendered.
pub fn move_page_down(&mut self) {
let step = self.page_step();
let cursor = self.active_window().cursor;
let view_top = self.active_window().view_top;
let id = self.active_buffer_id();
let result = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
let aw = self.active_window();
let coord = aw
.text_view
.pos_to_display(buffer, cursor)
.unwrap_or_default();
let max_line = aw.text_view.line_count().saturating_sub(1) as u32;
let goal_col = aw.goal_col.unwrap_or(coord.col);
let target_row = (coord.row + step).min(max_line);
let target = DisplayCoord::new(target_row, goal_col);
let new_pos = aw.text_view.display_to_pos(buffer, target);
(goal_col, new_pos, view_top.saturating_add(step as usize))
};
let (goal, new_pos, new_top) = result;
let aw = self.active_window_mut();
aw.goal_col = Some(goal);
if let Some(p) = new_pos {
aw.cursor = p;
}
// Also nudge view_top; render's scroll-into-view will clamp
// and align further if needed.
let max_top = aw.text_view.line_count().saturating_sub(1);
aw.view_top = new_top.min(max_top);
}
/// Move the cursor up by approximately one screenful. Mirror of
/// [`Self::move_page_down`].
pub fn move_page_up(&mut self) {
let step = self.page_step();
let cursor = self.active_window().cursor;
let view_top = self.active_window().view_top;
let id = self.active_buffer_id();
let result = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
let aw = self.active_window();
let coord = aw
.text_view
.pos_to_display(buffer, cursor)
.unwrap_or_default();
let goal_col = aw.goal_col.unwrap_or(coord.col);
let target_row = coord.row.saturating_sub(step);
let target = DisplayCoord::new(target_row, goal_col);
let new_pos = aw.text_view.display_to_pos(buffer, target);
(goal_col, new_pos, view_top.saturating_sub(step as usize))
};
let (goal, new_pos, new_top) = result;
let aw = self.active_window_mut();
aw.goal_col = Some(goal);
if let Some(p) = new_pos {
aw.cursor = p;
}
aw.view_top = new_top;
}
/// Number of lines a "page" advances. Uses the active window's
/// last rendered viewport height minus one (one line of context
/// at the seam, like Emacs's `next-screen-context-lines`),
/// clamped to a sensible default for headless tests where no
/// frame has rendered.
fn page_step(&self) -> u32 {
const DEFAULT_PAGE: u32 = 20;
let rows = self.active_window().last_visible_rows;
if rows >= 2 { rows - 1 } else { DEFAULT_PAGE }
}
/// Move to the end of the current line (before any trailing newline).
pub fn move_line_end(&mut self) {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let new = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
let aw = self.active_window();
let line = aw.text_view.line_at_offset(cursor);
let Some(start) = aw.text_view.line_offset(line) else {
return;
};
let len = aw.text_view.line_len(buffer, line).unwrap_or(0);
start + len
};
let aw = self.active_window_mut();
aw.cursor = new;
aw.goal_col = None;
}
/// Insert a single character at the cursor.
pub fn insert_char(&mut self, ch: char) {
self.active_window_mut().goal_col = None;
let mut buf = [0u8; 4];
let s = ch.encode_utf8(&mut buf);
let bytes = s.as_bytes();
let pos = self.active_window().cursor;
if let Err(e) = self.apply_active_edit(EditOp::Insert { pos, bytes }) {
self.status = format!("insert failed: {e}");
return;
}
self.active_window_mut().cursor += bytes.len() as u64;
}
/// CUA type-over: insert `ch`, replacing the active region if one
/// exists. With a region this is a *single* `EditOp::Replace` — one
/// undo step — rather than the former `delete_region()` +
/// `insert_char()` pair, which recorded two. With no region it
/// delegates to [`Self::insert_char`] (a plain insert). The cursor
/// lands just past the inserted bytes and any selection is cleared.
pub fn insert_char_over_region(&mut self, ch: char) {
let Some((lo, hi)) = self.active_region() else {
self.insert_char(ch);
return;
};
self.active_window_mut().goal_col = None;
let mut buf = [0u8; 4];
let bytes = ch.encode_utf8(&mut buf).as_bytes();
if let Err(e) = self.apply_active_edit(EditOp::Replace {
range: Range { start: lo, end: hi },
bytes,
}) {
self.status = format!("replace failed: {e}");
return;
}
let aw = self.active_window_mut();
aw.cursor = lo + bytes.len() as u64;
aw.selection = None;
}
/// Delete the codepoint immediately before the cursor.
pub fn backspace(&mut self) {
self.active_window_mut().goal_col = None;
let cursor = self.active_window().cursor;
if cursor == 0 {
return;
}
let prev = {
let id = self.active_buffer_id();
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
prev_codepoint(buffer, cursor)
};
let range = Range::new(prev, cursor);
if let Err(e) = self.apply_active_edit(EditOp::Delete { range }) {
self.status = format!("delete failed: {e}");
return;
}
self.active_window_mut().cursor = prev;
}
/// Delete the codepoint at the cursor (forward delete).
pub fn delete_forward(&mut self) {
self.active_window_mut().goal_col = None;
let cursor = self.active_window().cursor;
let id = self.active_buffer_id();
let next = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
if cursor >= buffer.len() {
return;
}
next_codepoint(buffer, cursor)
};
let range = Range::new(cursor, next);
if let Err(e) = self.apply_active_edit(EditOp::Delete { range }) {
self.status = format!("delete failed: {e}");
}
}
/// Delete from the cursor backward to the start of the previous
/// word. The CUA-style `Ctrl+Backspace`. No-op at start-of-buffer.
/// Mirrors [`Self::backspace`] but the deleted range is the gap
/// between the cursor and where [`Self::move_word_left`] would
/// land.
pub fn delete_word_backward(&mut self) {
self.active_window_mut().goal_col = None;
let cursor = self.active_window().cursor;
if cursor == 0 {
return;
}
let new = {
let id = self.active_buffer_id();
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
backward_word(buffer, cursor)
};
if new == cursor {
return;
}
let range = Range::new(new, cursor);
if let Err(e) = self.apply_active_edit(EditOp::Delete { range }) {
self.status = format!("delete failed: {e}");
return;
}
self.active_window_mut().cursor = new;
}
/// Delete from the cursor forward to the end of the next word. The
/// CUA-style `Ctrl+Delete`. No-op at end-of-buffer. Mirrors
/// [`Self::delete_forward`] over the gap from the cursor to where
/// [`Self::move_word_right`] would land.
pub fn delete_word_forward(&mut self) {
self.active_window_mut().goal_col = None;
let cursor = self.active_window().cursor;
let id = self.active_buffer_id();
let new = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else { return };
if cursor >= buffer.len() {
return;
}
forward_word(buffer, cursor)
};
if new == cursor {
return;
}
let range = Range::new(cursor, new);
if let Err(e) = self.apply_active_edit(EditOp::Delete { range }) {
self.status = format!("delete failed: {e}");
}
}
/// Undo the most recent edit on the active buffer; clamp the
/// active window's cursor to the new length and notify all
/// windows on this buffer.
pub fn undo(&mut self) {
self.active_window_mut().goal_col = None;
let buffer_id = self.active_buffer_id();
let edit = {
let mut reg = self.registry.borrow_mut();
let Ok(buffer) = reg.get_mut(buffer_id) else {
return;
};
buffer.undo()
};
match edit {
Ok(edit) => {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(buffer_id) else {
return;
};
for win in self.windows.values_mut() {
if win.buffer_id == buffer_id {
let _ = win.text_view.on_edit(buffer, &edit);
let max = buffer.len();
if win.cursor > max {
win.cursor = max;
}
}
}
drop(reg);
// Post-audit-round-5 F27: undo on a CRDT-backed
// buffer produces a crdt_op that must broadcast to
// every replica frontend (including the one whose
// command triggered the undo — its BufferMirror has
// no other way to converge with the post-undo state).
self.queue_daemon_origin_crdt_op(buffer_id, &edit);
}
Err(_) => self.status = "nothing to undo".into(),
}
}
/// Redo the most recently undone edit on the active buffer.
pub fn redo(&mut self) {
self.active_window_mut().goal_col = None;
let buffer_id = self.active_buffer_id();
let edit = {
let mut reg = self.registry.borrow_mut();
let Ok(buffer) = reg.get_mut(buffer_id) else {
return;
};
buffer.redo()
};
match edit {
Ok(edit) => {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(buffer_id) else {
return;
};
for win in self.windows.values_mut() {
if win.buffer_id == buffer_id {
let _ = win.text_view.on_edit(buffer, &edit);
let max = buffer.len();
if win.cursor > max {
win.cursor = max;
}
}
}
drop(reg);
// Post-audit-round-5 F27 — same as undo above.
self.queue_daemon_origin_crdt_op(buffer_id, &edit);
}
Err(_) => self.status = "nothing to redo".into(),
}
}
/// T M10.10 post-audit-round-5 F27 + F28 — queue a CRDT op
/// produced by a daemon-origin edit (undo/redo via core, Lua
/// bindings, command pipeline) for broadcast.
///
/// Pushes into `pending_crdt_ops` with
/// [`CrdtOpOrigin::DaemonKey`] semantics: the broadcast sweep
/// includes every replica frontend (no sender exclusion). The
/// originating frontend's `BufferMirror` has not applied the op
/// locally — only the daemon's authoritative buffer has — so
/// the source's mirror needs the broadcast just like every
/// other replica.
///
/// No-op when the edit doesn't carry a `crdt_op` (the buffer
/// wasn't CRDT-backed at the time of the edit). Callers can
/// invoke this unconditionally after any daemon-origin
/// `apply_*` that returns an `Edit`; non-CRDT buffers pay no
/// cost beyond the early return.
pub fn queue_daemon_origin_crdt_op(&mut self, buffer_id: BufferId, edit: &Edit) {
if let Some(crdt_op) = edit.crdt_op.as_ref() {
self.pending_crdt_ops
.push((CrdtOpOrigin::DaemonKey, buffer_id, (**crdt_op).clone()));
}
}
// ---- window operations -------------------------------------------------
/// Split the active window. Returns the new window's id.
/// `same_buffer` controls whether the new window opens on the
/// active buffer (Emacs default) or a fresh `*scratch*` buffer.
pub fn split_active(&mut self, orientation: Orientation, same_buffer: bool) -> WindowId {
let active_buf = self.active_buffer_id();
let (buffer_id, text_view) = if same_buffer {
let reg = self.registry.borrow();
let buf = reg.get(active_buf).expect("active buffer present");
(active_buf, TextView::new(buf))
} else {
let mut reg = self.registry.borrow_mut();
let new_id = reg.create("*scratch*");
let buf = reg.get(new_id).unwrap();
(new_id, TextView::new(buf))
};
let new_id = WindowId::next();
let new_window = Window::new(new_id, buffer_id, text_view);
self.windows.insert(new_id, new_window);
let active = self.active_window_id();
self.active_layout_mut()
.split_window(active, orientation, new_id);
new_id
}
/// Move focus to the next window in iteration order.
pub fn focus_next(&mut self) {
let active = self.active_window_id();
let next = self.active_layout().focus_next(active);
self.set_active_window_id(next);
}
/// Move focus to the previous window in iteration order.
pub fn focus_prev(&mut self) {
let active = self.active_window_id();
let prev = self.active_layout().focus_prev(active);
self.set_active_window_id(prev);
}
/// Close the active window (unless it's the only one). Returns
/// false if there's only one window left.
pub fn close_active(&mut self) -> bool {
if self.windows.len() <= 1 {
return false;
}
let target = self.active_window_id();
self.active_layout_mut().close_window(target);
self.windows.remove(&target);
// Pick an adjacent window as the new focus.
let next = *self
.active_layout()
.iter_ids()
.first()
.expect("at least one window remains");
self.set_active_window_id(next);
true
}
/// Close every window except the active one.
pub fn close_others(&mut self) {
let keep = self.active_window_id();
self.active_layout_mut().keep_only(keep);
self.windows.retain(|id, _| *id == keep);
}
// ---- selection / region (T M2.12) --------------------------------------
/// Active region of the active window, as `(lo, hi)` byte
/// positions, or `None` if no region is set or it is empty.
#[must_use]
pub fn active_region(&self) -> Option<(Position, Position)> {
self.active_window().region()
}
/// Begin a selection at `anchor` on the active window.
pub fn begin_selection(&mut self, anchor: Position) {
self.active_window_mut().selection = Some(crate::window::Selection { anchor });
}
/// Drop any active selection on the active window.
pub fn clear_selection(&mut self) {
self.active_window_mut().selection = None;
}
/// Delete the active region (if any) from the active buffer and
/// move the cursor to the deletion's start. No-op if there is no
/// region. Returns the new buffer length.
///
/// # Errors
///
/// Returns the same stringified error shape as
/// [`Self::apply_active_edit`] if the underlying delete fails.
pub fn delete_region(&mut self) -> Result<u64, String> {
let Some((lo, hi)) = self.active_region() else {
return Ok(self.active_buffer_len());
};
let new_len = self.apply_active_edit(EditOp::Delete {
range: Range { start: lo, end: hi },
})?;
let aw = self.active_window_mut();
aw.cursor = lo;
aw.selection = None;
aw.goal_col = None;
Ok(new_len)
}
/// Safely remove `buffer_id` from the registry. Any window that
/// was displaying it is redirected to a fallback buffer (`*scratch*`,
/// created on demand) so window state never refers to a missing id.
///
/// # Errors
///
/// Returns an error string when `buffer_id` is the only buffer in
/// the registry (the registry must remain non-empty), or when the
/// id doesn't resolve.
pub fn kill_buffer(&mut self, buffer_id: BufferId) -> Result<(), String> {
{
let reg = self.registry.borrow();
if !reg.contains(buffer_id) {
return Err(format!("buffer {buffer_id:?} not found"));
}
if reg.len() <= 1 {
return Err("cannot kill the last remaining buffer".into());
}
}
let fallback = {
let mut reg = self.registry.borrow_mut();
match reg.find_by_name("*scratch*") {
Some(id) if id != buffer_id => id,
_ => {
let candidate = reg.ids().iter().copied().find(|id| *id != buffer_id);
match candidate {
Some(id) => id,
None => reg.create("*scratch*"),
}
}
}
};
{
let reg = self.registry.borrow();
let buf = reg.get(fallback).map_err(|e| e.to_string())?;
for win in self.windows.values_mut() {
if win.buffer_id == buffer_id {
win.buffer_id = fallback;
win.text_view = TextView::new(buf);
win.overlays.clear();
win.cursor = 0;
win.selection = None;
win.view_top = 0;
win.goal_col = None;
}
}
}
self.registry
.borrow_mut()
.remove(buffer_id)
.map(|_| ())
.map_err(|e| e.to_string())
}
/// Switch the active window to a different buffer, allocating a
/// fresh [`TextView`] for it.
pub fn switch_active_buffer(&mut self, buffer_id: BufferId) -> Result<(), String> {
let text_view = {
let reg = self.registry.borrow();
let buf = reg.get(buffer_id).map_err(|e| e.to_string())?;
TextView::new(buf)
};
let aw = self.active_window_mut();
aw.buffer_id = buffer_id;
aw.text_view = text_view;
// Overlays were keyed to the previous buffer's coordinates;
// dropping them is safer than carrying through coordinates
// that no longer mean anything. Callers that want to preserve
// an overlay across buffer switches re-register after.
aw.overlays.clear();
aw.cursor = 0;
aw.selection = None;
aw.view_top = 0;
aw.goal_col = None;
Ok(())
}
}
// ---------------------------------------------------------------------------
// Codepoint navigation
// ---------------------------------------------------------------------------
/// Return the byte position of the codepoint immediately before `pos`.
fn prev_codepoint(buf: &Buffer, pos: Position) -> Position {
if pos == 0 {
return 0;
}
let rope = buf.snapshot_rope();
let mut p = pos - 1;
while p > 0 {
let b = rope.byte_at(p).unwrap_or(0);
if (b & 0xC0) != 0x80 {
return p;
}
p -= 1;
}
0
}
/// Return the byte position of the codepoint immediately after `pos`.
fn next_codepoint(buf: &Buffer, pos: Position) -> Position {
let len = buf.len();
if pos >= len {
return len;
}
let rope = buf.snapshot_rope();
let lead = rope.byte_at(pos).unwrap_or(0);
let advance = utf8_codepoint_len(lead);
(pos + advance as u64).min(len)
}
fn utf8_codepoint_len(lead: u8) -> usize {
if lead < 0xC0 {
1
} else if lead < 0xE0 {
2
} else if lead < 0xF0 {
3
} else {
4
}
}
/// Decode the codepoint starting at `pos`. Returns `(char, advance)`
/// where `advance` is the number of bytes the codepoint consumed.
/// `None` if `pos` is past the buffer end or the bytes there are not
/// valid UTF-8.
fn char_at(buf: &Buffer, pos: Position) -> Option<(char, u64)> {
let rope = buf.snapshot_rope();
if pos >= rope.len() {
return None;
}
let lead = rope.byte_at(pos)?;
let len = utf8_codepoint_len(lead);
let mut bytes = [0u8; 4];
for (i, slot) in bytes.iter_mut().take(len).enumerate() {
*slot = rope.byte_at(pos + i as u64).unwrap_or(0);
}
let s = std::str::from_utf8(&bytes[..len]).ok()?;
let ch = s.chars().next()?;
Some((ch, len as u64))
}
/// Whether `c` counts as a word character. Matches the Emacs default:
/// alphanumerics plus underscore. Punctuation and whitespace are
/// separators.
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
/// Forward-word semantics: skip non-word characters, then skip word
/// characters, returning the resulting position.
fn forward_word(buf: &Buffer, mut pos: Position) -> Position {
let len = buf.len();
// Skip non-word.
while pos < len {
let Some((ch, advance)) = char_at(buf, pos) else {
break;
};
if is_word_char(ch) {
break;
}
pos += advance;
}
// Skip word.
while pos < len {
let Some((ch, advance)) = char_at(buf, pos) else {
break;
};
if !is_word_char(ch) {
break;
}
pos += advance;
}
pos
}
fn word_range_at(buf: &Buffer, pos: Position) -> Option<(Position, Position)> {
let (ch, ch_len) = char_at(buf, pos)?;
if !is_word_char(ch) {
return None;
}
// Walk back from just *past* the char under the cursor, not from
// `pos` itself: `backward_word(pos)` at a word's FIRST character
// sees the non-word char before it, skips it, and crosses into
// the previous word — double-clicking the 'w' of "llo world"
// would select "llo world". From `pos + ch_len` the char behind
// is this word's own first char, so the walk stops at its start.
let start = backward_word(buf, pos.saturating_add(ch_len));
let end = forward_word(buf, pos);
(start < end).then_some((start, end))
}
/// True iff `line` is empty or contains only ASCII whitespace.
/// Used by paragraph motion: a blank line is a paragraph break.
fn line_is_blank(buf: &Buffer, view: &TextView, line: usize) -> bool {
let Some(start) = view.line_offset(line) else {
return true;
};
let Some(len) = view.line_len(buf, line) else {
return true;
};
if len == 0 {
return true;
}
let rope = buf.snapshot_rope();
for chunk in rope.chunks(start, start + len) {
if chunk.iter().any(|b| !b.is_ascii_whitespace()) {
return false;
}
}
true
}
/// Forward-paragraph: skip blank lines if currently on one, then
/// scan forward until the first blank line; return the position at
/// the start of that line, or the buffer end.
fn forward_paragraph(buf: &Buffer, view: &TextView, pos: Position) -> Position {
let total = view.line_count();
if total == 0 {
return pos;
}
let cur_line = view.line_at_offset(pos);
let starting_blank = line_is_blank(buf, view, cur_line);
let mut line = cur_line.saturating_add(1);
if starting_blank {
while line < total && line_is_blank(buf, view, line) {
line += 1;
}
}
while line < total {
if line_is_blank(buf, view, line) {
return view.line_offset(line).unwrap_or(pos);
}
line += 1;
}
buf.len()
}
/// Backward-paragraph: mirror of [`forward_paragraph`].
fn backward_paragraph(buf: &Buffer, view: &TextView, pos: Position) -> Position {
if pos == 0 {
return 0;
}
let cur_line = view.line_at_offset(pos);
if cur_line == 0 {
return 0;
}
let starting_blank = line_is_blank(buf, view, cur_line);
let mut line = cur_line - 1;
if starting_blank {
loop {
if !line_is_blank(buf, view, line) {
break;
}
if line == 0 {
return view.line_offset(0).unwrap_or(0);
}
line -= 1;
}
}
loop {
if line_is_blank(buf, view, line) {
return view.line_offset(line).unwrap_or(0);
}
if line == 0 {
return 0;
}
line -= 1;
}
}
/// Backward-word semantics: step back over non-word characters, then
/// step back over word characters.
fn backward_word(buf: &Buffer, mut pos: Position) -> Position {
// Step back over non-word characters.
while pos > 0 {
let prev = prev_codepoint(buf, pos);
let Some((ch, _)) = char_at(buf, prev) else {
break;
};
if is_word_char(ch) {
break;
}
pos = prev;
}
// Step back over word characters.
while pos > 0 {
let prev = prev_codepoint(buf, pos);
let Some((ch, _)) = char_at(buf, prev) else {
break;
};
if !is_word_char(ch) {
break;
}
pos = prev;
}
pos
}
/// Normalize a buffer path to an absolute, lexically-clean form:
///
/// 1. expand a leading `~` / `~/…` against `$HOME`,
/// 2. join onto the process cwd if still relative,
/// 3. fold `.` / `..` purely lexically.
///
/// No filesystem access and no symlink resolution (unlike
/// [`std::fs::canonicalize`]): the result is correct for a
/// not-yet-created "[new file]" buffer and never silently rewrites a
/// path's on-disk identity. Every step is best-effort — if `$HOME`
/// or the cwd is unavailable the path is returned as far as it could
/// be resolved rather than panicking.
fn normalize_buffer_path(path: PathBuf) -> PathBuf {
let path = expand_tilde(path);
let abs = if path.is_absolute() {
path
} else if let Ok(cwd) = std::env::current_dir() {
cwd.join(path)
} else {
path
};
lexical_normalize(&abs)
}
/// Expand a leading `~` (whole component only) using `$HOME`. A bare
/// `~` becomes `$HOME`; `~/x` becomes `$HOME/x`. `~user` is left
/// untouched (no passwd lookup). Returns the input unchanged if it
/// has no leading `~`, isn't valid UTF-8, or `$HOME` is unset.
fn expand_tilde(path: PathBuf) -> PathBuf {
let Some(s) = path.to_str() else {
return path;
};
if s == "~" {
return std::env::var_os("HOME").map_or(path, PathBuf::from);
}
if let Some(rest) = s.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
return Path::new(&home).join(rest);
}
path
}
/// Fold `.` and `..` components without touching the filesystem.
/// `..` pops a preceding normal segment; against the root (or a
/// Windows prefix) it is dropped, since you cannot ascend past it.
fn lexical_normalize(path: &Path) -> PathBuf {
use std::path::Component;
let mut stack: Vec<Component> = Vec::new();
for comp in path.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => match stack.last() {
Some(Component::Normal(_)) => {
stack.pop();
}
Some(Component::RootDir | Component::Prefix(_)) => {}
_ => stack.push(Component::ParentDir),
},
c => stack.push(c),
}
}
let mut out = PathBuf::new();
for c in stack {
out.push(c.as_os_str());
}
if out.as_os_str().is_empty() {
PathBuf::from(".")
} else {
out
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::rc::Rc;
#[test]
fn lexical_normalize_folds_dot_and_dotdot() {
assert_eq!(
lexical_normalize(Path::new("/a/./b/../c")),
PathBuf::from("/a/c")
);
// `..` cannot ascend past the root.
assert_eq!(
lexical_normalize(Path::new("/../../x")),
PathBuf::from("/x")
);
// Already clean ⇒ unchanged (keeps tempdir paths stable so
// the LSP acceptance tests' exact-path asserts still hold).
assert_eq!(
lexical_normalize(Path::new("/tmp/quickshell/ipc.cpp")),
PathBuf::from("/tmp/quickshell/ipc.cpp")
);
}
#[test]
fn expand_tilde_only_at_leading_component() {
// `~user` (no passwd lookup) and a non-leading `~` are left
// exactly as-is, independent of `$HOME`.
assert_eq!(
expand_tilde(PathBuf::from("~bob/x")),
PathBuf::from("~bob/x")
);
assert_eq!(expand_tilde(PathBuf::from("a/~/b")), PathBuf::from("a/~/b"));
// With `$HOME` set (the case in any normal test environment)
// a leading `~` / `~/…` expands against its real value.
if let Some(home) = std::env::var_os("HOME") {
assert_eq!(expand_tilde(PathBuf::from("~")), PathBuf::from(&home));
assert_eq!(
expand_tilde(PathBuf::from("~/src/ipc.cpp")),
Path::new(&home).join("src/ipc.cpp")
);
}
}
#[test]
fn normalize_buffer_path_yields_absolute() {
// A relative path becomes absolute (joined onto cwd) — this
// is exactly what made clangd reject `file://ipc.cpp`.
let p = normalize_buffer_path(PathBuf::from("ipc.cpp"));
assert!(p.is_absolute(), "expected absolute, got {p:?}");
assert!(p.ends_with("ipc.cpp"));
}
fn fresh() -> EditorCore {
let reg: SharedRegistry =
Rc::new(RefCell::new(crate::buffer_registry::BufferRegistry::new()));
EditorCore::new(reg)
}
fn from_bytes(bytes: &[u8]) -> EditorCore {
let reg: SharedRegistry =
Rc::new(RefCell::new(crate::buffer_registry::BufferRegistry::new()));
EditorCore::from_bytes(reg, "test", bytes)
}
#[test]
fn insert_advances_cursor() {
let mut s = from_bytes(b"");
s.insert_char('h');
s.insert_char('i');
assert_eq!(s.cursor(), 2);
assert_eq!(s.active_buffer_len(), 2);
}
#[test]
fn backspace_undoes_insertion() {
let mut s = from_bytes(b"abc");
s.active_window_mut().cursor = 3;
s.backspace();
assert_eq!(s.cursor(), 2);
assert_eq!(s.active_buffer_len(), 2);
}
#[test]
fn cursor_navigation_left_right() {
let mut s = from_bytes(b"abc");
s.active_window_mut().cursor = 0;
s.move_right();
assert_eq!(s.cursor(), 1);
s.move_right();
s.move_right();
s.move_right();
assert_eq!(s.cursor(), 3);
s.move_left();
s.move_left();
s.move_left();
s.move_left();
assert_eq!(s.cursor(), 0);
}
#[test]
fn cursor_navigation_up_down_preserves_column() {
let mut s = from_bytes(b"abcdef\nghi\njklmno");
s.active_window_mut().cursor = 4;
s.move_down();
assert_eq!(s.cursor(), 10);
s.move_down();
assert_eq!(s.cursor(), 15);
s.move_up();
s.move_up();
assert_eq!(s.cursor(), 4);
}
#[test]
fn line_start_and_end() {
let mut s = from_bytes(b"hello\nworld");
s.active_window_mut().cursor = 8;
s.move_line_start();
assert_eq!(s.cursor(), 6);
s.move_line_end();
assert_eq!(s.cursor(), 11);
}
#[test]
fn undo_clamps_cursor_to_buffer_len() {
let mut s = from_bytes(b"");
s.insert_char('a');
s.insert_char('b');
assert_eq!(s.cursor(), 2);
s.undo();
assert_eq!(s.cursor(), 1);
s.undo();
assert_eq!(s.cursor(), 0);
}
#[test]
fn delete_forward_at_end_is_noop() {
let mut s = from_bytes(b"abc");
s.active_window_mut().cursor = 3;
s.delete_forward();
assert_eq!(s.active_buffer_len(), 3);
}
#[test]
fn delete_word_backward_removes_previous_word_to_cursor() {
// Cursor sits at end-of-buffer; deletes back through "world".
let mut s = from_bytes(b"hello world");
s.active_window_mut().cursor = 11;
s.delete_word_backward();
// `backward_word` lands at the start of the word ("world"
// begins at byte 6), so we delete bytes 6..11.
assert_eq!(s.cursor(), 6);
assert_eq!(s.active_buffer_len(), 6);
}
#[test]
fn delete_word_backward_at_start_of_buffer_is_noop() {
let mut s = from_bytes(b"hello");
s.active_window_mut().cursor = 0;
s.delete_word_backward();
assert_eq!(s.cursor(), 0);
assert_eq!(s.active_buffer_len(), 5);
}
#[test]
fn delete_word_forward_removes_next_word_from_cursor() {
let mut s = from_bytes(b"hello world");
s.active_window_mut().cursor = 0;
s.delete_word_forward();
// `forward_word` lands at the end of the first word (byte 5);
// delete bytes 0..5. Cursor stays where it was.
assert_eq!(s.cursor(), 0);
assert_eq!(s.active_buffer_len(), 6);
}
#[test]
fn delete_word_forward_at_end_of_buffer_is_noop() {
let mut s = from_bytes(b"hello");
s.active_window_mut().cursor = 5;
s.delete_word_forward();
assert_eq!(s.cursor(), 5);
assert_eq!(s.active_buffer_len(), 5);
}
#[test]
fn multibyte_navigation() {
let mut s = from_bytes("héllo".as_bytes());
s.active_window_mut().cursor = 0;
s.move_right();
assert_eq!(s.cursor(), 1);
s.move_right();
assert_eq!(s.cursor(), 3);
s.move_right();
assert_eq!(s.cursor(), 4);
s.move_left();
s.move_left();
assert_eq!(s.cursor(), 1);
}
#[test]
fn save_with_no_path_announces_via_status() {
let mut s = fresh();
s.save();
assert!(s.status.contains("no file"));
}
/// T M10.8 — pins the Day 2 transitional fallback behavior in
/// [`EditorCore::active_view`].
///
/// **Day 2 → Day 3 transition contract**: while the dispatcher
/// thread is being wired (Day 3 work), the daemon may set
/// `active_frontend` to a daemon-attached `FrontendId` whose
/// `FrontendView` hasn't been registered yet. The fallback to
/// `FrontendId::LOCAL`'s view keeps single-frontend behavior
/// observable.
///
/// **Day 3 cleanup**: once
/// [`EditorCore::register_frontend_view`] is invariantly called
/// before any event dispatch, this test flips to assert "every
/// `active_frontend` has its own registered view, no fallback
/// ever activates." Until then, the fallback is the bridge.
#[test]
fn active_view_falls_back_to_local_when_active_frontend_unregistered() {
let mut s = fresh();
// Default active_frontend is LOCAL → no fallback yet.
assert_eq!(s.active_frontend, FrontendId::LOCAL);
let local_active_window = s.active_view().active;
// Simulate the Day 2 transitional state: a daemon-attached
// frontend's id is set as active, but no FrontendView is
// registered for it (Day 3 work).
s.active_frontend = FrontendId(42);
assert!(!s.views.contains_key(&FrontendId(42)));
// Fallback activates: active_view() returns LOCAL's view.
let fallback_view = s.active_view();
assert_eq!(
fallback_view.active, local_active_window,
"Day 2 fallback: active_view() returns LOCAL's view when active_frontend has no entry"
);
// Same for active_window().
let win = s.active_window();
assert_eq!(win.id, local_active_window);
}
#[test]
fn active_view_for_explicit_fid_returns_none_when_unregistered() {
// T M10.8 — explicit-fid lookups don't fall back. Callers
// explicitly asking about a specific frontend get a truthful
// None when that frontend has no state, distinguishing
// "active by default" from "actually has its own view."
let s = fresh();
assert!(s.active_window_for(FrontendId(42)).is_none());
assert!(s.active_window_for(FrontendId::LOCAL).is_some());
}
#[test]
fn register_and_unregister_frontend_view() {
// T M10.8 — the lifecycle API the dispatcher uses on attach
// and detach. Wiring lives in `daemon.rs`; this test pins
// the EditorCore-side semantics.
let mut s = fresh();
let fid = FrontendId(7);
assert!(s.active_window_for(fid).is_none());
// Build a view referencing the existing scratch window so
// we don't need a fresh window allocation in this test.
let local_view = s.views[&FrontendId::LOCAL].clone();
s.register_frontend_view(fid, local_view);
assert!(s.active_window_for(fid).is_some());
// Unregister drops the entry; explicit lookup returns None.
s.unregister_frontend_view(fid);
assert!(s.active_window_for(fid).is_none());
// LOCAL invariant survives unrelated register/unregister.
assert!(s.views.contains_key(&FrontendId::LOCAL));
}
#[test]
fn split_active_creates_a_second_window_on_same_buffer() {
let mut s = fresh();
let original = s.active_window_id();
let new_id = s.split_active(Orientation::Vertical, true);
assert_ne!(new_id, original);
assert_eq!(s.windows.len(), 2);
// Same buffer.
assert_eq!(s.windows[&original].buffer_id, s.windows[&new_id].buffer_id);
}
#[test]
fn edit_in_one_window_propagates_through_buffer_to_the_other() {
let mut s = from_bytes(b"abc");
let _new = s.split_active(Orientation::Vertical, true);
// Insert via the active window.
s.active_window_mut().cursor = 3;
s.insert_char('X');
// Buffer length is now 4; the *other* window shares the
// same buffer, so its text view sees the same length.
assert_eq!(s.active_buffer_len(), 4);
// The other window's text_view has the same line count,
// confirming on_edit fired.
let active = s.active_window_id();
let other = s.windows.keys().find(|id| **id != active).copied().unwrap();
assert_eq!(s.windows[&other].text_view.line_count(), 1);
}
#[test]
fn close_active_falls_back_to_remaining_window() {
let mut s = fresh();
s.split_active(Orientation::Horizontal, true);
assert_eq!(s.windows.len(), 2);
assert!(s.close_active());
assert_eq!(s.windows.len(), 1);
}
#[test]
fn close_active_refuses_when_only_one_window() {
let mut s = fresh();
assert!(!s.close_active());
assert_eq!(s.windows.len(), 1);
}
#[test]
fn focus_next_round_robins() {
let mut s = fresh();
let a = s.active_window_id();
let _b = s.split_active(Orientation::Vertical, true);
let _c = s.split_active(Orientation::Horizontal, true);
// Splits don't move focus; `a` is still active.
assert_eq!(s.active_window_id(), a);
let order = s.active_layout().iter_ids();
assert_eq!(order.len(), 3);
// Walking N times wraps back to the original.
for _ in 0..3 {
s.focus_next();
}
assert_eq!(s.active_window_id(), a);
}
// ------------------------------------------------------------------
// F27 / F28 (post-audit-round-5) — daemon-origin CRDT ops are
// queued on `pending_crdt_ops` so they reach all replicas.
// ------------------------------------------------------------------
/// Helper: upgrade the active buffer to CRDT-backed under the
/// LOCAL peer id (mirrors what the daemon does at attach time
/// for replica sessions).
#[cfg(feature = "crdt")]
fn upgrade_active_to_crdt(s: &mut EditorCore) {
let buffer_id = s.active_buffer_id();
let mut reg = s.registry.borrow_mut();
let buf = reg.get_mut(buffer_id).expect("active buffer present");
buf.upgrade_to_crdt(crate::crdt::peer_id_from_frontend(
crate::protocol::FrontendId::LOCAL,
))
.expect("upgrade");
}
/// F27 — undo on a CRDT-backed buffer queues the resulting
/// CRDT op for broadcast.
#[cfg(feature = "crdt")]
#[test]
fn undo_on_crdt_buffer_queues_crdt_op_for_broadcast_f27() {
let mut s = from_bytes(b"abc");
upgrade_active_to_crdt(&mut s);
// Apply an edit so there's something to undo. apply_active_edit
// also pushes a DaemonKey-origin op.
s.apply_active_edit(crate::buffer::EditOp::Insert {
pos: 3,
bytes: b"X",
})
.expect("edit");
let queued_after_edit = s.pending_crdt_ops.len();
assert!(queued_after_edit >= 1, "edit must queue a CRDT op");
// Drain to isolate the undo's queueing.
s.pending_crdt_ops.clear();
s.undo();
assert!(
!s.pending_crdt_ops.is_empty(),
"F27: undo on a CRDT-backed buffer must queue a CRDT op for broadcast"
);
// Origin must be DaemonKey (broadcast-to-all-replicas).
let (origin, _, _) = &s.pending_crdt_ops[0];
assert!(
matches!(origin, CrdtOpOrigin::DaemonKey),
"F27: undo's CRDT op must be queued with DaemonKey origin (broadcast to all replicas including active frontend)"
);
}
/// F27 — redo on a CRDT-backed buffer queues the resulting
/// CRDT op for broadcast.
#[cfg(feature = "crdt")]
#[test]
fn redo_on_crdt_buffer_queues_crdt_op_for_broadcast_f27() {
let mut s = from_bytes(b"abc");
upgrade_active_to_crdt(&mut s);
s.apply_active_edit(crate::buffer::EditOp::Insert {
pos: 3,
bytes: b"X",
})
.expect("edit");
s.undo();
s.pending_crdt_ops.clear();
s.redo();
assert!(
!s.pending_crdt_ops.is_empty(),
"F27: redo on a CRDT-backed buffer must queue a CRDT op for broadcast"
);
let (origin, _, _) = &s.pending_crdt_ops[0];
assert!(matches!(origin, CrdtOpOrigin::DaemonKey));
}
/// F27 — undo on a non-CRDT buffer is a no-op for the broadcast
/// queue (the buffer produced no `crdt_op` on the Edit).
#[test]
fn undo_on_non_crdt_buffer_does_not_queue_crdt_op_f27() {
let mut s = from_bytes(b"abc");
s.apply_active_edit(crate::buffer::EditOp::Insert {
pos: 3,
bytes: b"X",
})
.expect("edit");
// Non-CRDT — apply_active_edit's pending push is a no-op
// (Edit::crdt_op is None). Confirm precondition then undo.
assert!(s.pending_crdt_ops.is_empty());
s.undo();
assert!(
s.pending_crdt_ops.is_empty(),
"F27: undo on a non-CRDT buffer must not produce a phantom queue entry"
);
}
// ---- jump ring (T M4.5 L1) -----------------------------------------
#[test]
fn jump_back_returns_false_on_empty_ring() {
let mut s = from_bytes(b"abc");
s.active_window_mut().cursor = 2;
assert!(!s.jump_back(), "empty ring must not move the cursor");
assert_eq!(s.cursor(), 2);
}
#[test]
fn push_then_jump_back_restores_cursor() {
let mut s = from_bytes(b"line one\nline two\nline three");
s.active_window_mut().cursor = 3;
s.push_jump();
s.active_window_mut().cursor = 20;
assert!(s.jump_back());
assert_eq!(s.cursor(), 3);
// Ring is now empty; a second pop is a no-op.
assert!(!s.jump_back());
}
#[test]
fn jump_back_clamps_to_shortened_buffer() {
let mut s = from_bytes(b"abcdefghij");
s.active_window_mut().cursor = 9;
s.push_jump();
// Truncate the buffer so the recorded position is past EOF.
s.apply_active_edit(crate::buffer::EditOp::Delete {
range: Range::new(2, 10),
})
.expect("delete");
assert!(s.jump_back());
assert_eq!(
s.cursor(),
s.active_buffer_len(),
"stale position must clamp to the current buffer length"
);
}
#[test]
fn jump_ring_is_bounded_and_evicts_oldest() {
let mut s = from_bytes(b"0123456789");
for i in 0..(EditorCore::JUMP_RING_CAP + 10) {
s.active_window_mut().cursor = (i % 10) as u64;
s.push_jump();
}
assert_eq!(
s.jump_ring.len(),
EditorCore::JUMP_RING_CAP,
"ring must stay bounded at JUMP_RING_CAP"
);
}
#[test]
fn jump_back_skips_removed_buffer() {
let mut s = from_bytes(b"original");
// Record a jump on a second buffer, then remove that buffer.
let doomed = s.registry.borrow_mut().create_from_bytes("doomed", b"x");
s.switch_active_buffer(doomed).expect("switch");
s.active_window_mut().cursor = 1;
s.push_jump();
// Switch back and record a live origin too.
let original = *s.registry.borrow().ids().first().expect("original id");
s.switch_active_buffer(original).expect("switch back");
s.active_window_mut().cursor = 4;
s.push_jump();
s.active_window_mut().cursor = 0;
// Drop the doomed buffer: its ring entry is now stale.
s.registry.borrow_mut().remove(doomed).expect("remove");
// First pop lands on the live `original` origin.
assert!(s.jump_back());
assert_eq!(s.active_buffer_id(), original);
assert_eq!(s.cursor(), 4);
// Next pop would be the stale `doomed` entry — skipped, ring empties.
assert!(!s.jump_back());
}
}