3850 lines
144 KiB
Rust
3850 lines
144 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,
|
|
}
|
|
|
|
/// Live state of an in-progress incremental search (Q#SR5).
|
|
///
|
|
/// Present only while an isearch is running (`EditorCore::search`);
|
|
/// `None` otherwise. Holds the query as typed so far plus the cursor
|
|
/// origin to restore on cancel. The *matches* themselves live in
|
|
/// [`crate::search::SearchStore`] (shared with the decorations
|
|
/// producer and the TUI overlay); this struct is the per-session
|
|
/// input state that drives `find_all`.
|
|
#[derive(Clone, Debug)]
|
|
pub struct SearchSession {
|
|
/// The query as typed so far. Each edit re-runs `find_all`.
|
|
query: String,
|
|
/// Buffer + cursor position when the search began. `C-g` / `Esc`
|
|
/// restores this; `RET` keeps the current (match) cursor. The
|
|
/// buffer id also anchors `find_all` to the buffer the search
|
|
/// started in.
|
|
origin: (BufferId, Position),
|
|
/// Direction of the most recent step/begin. `true` = forward.
|
|
/// Drives the prompt label ("I-search" vs "I-search backward")
|
|
/// and the wrap direction of an empty-query repeat.
|
|
forward: bool,
|
|
/// Whether the query is a regex (Q#RX3). `false` = smart-case
|
|
/// substring (`find_all`); `true` = smart-case regex
|
|
/// (`find_all_regex`). Toggled live by `M-r`.
|
|
regex: bool,
|
|
/// `true` when the last recompute's regex pattern failed to compile
|
|
/// — the prompt shows `[invalid]` instead of a match count. Always
|
|
/// `false` in literal mode (substring never "fails to compile").
|
|
invalid: bool,
|
|
}
|
|
|
|
/// Live state of an in-progress query-replace (Arc 2, Q#QR1).
|
|
///
|
|
/// Present only while a query-replace's interactive phase is running
|
|
/// (`EditorCore::query_replace`); `None` otherwise. Unlike
|
|
/// [`SearchSession`], the buffer is usually already mutated by the
|
|
/// time this ends, so `origin` is used *only* for the nothing-matched
|
|
/// restore (Q#QR10); every other exit leaves point at the inspected
|
|
/// match. Matching runs forward from `next_from` on the *live* buffer
|
|
/// (Q#QR2), so offset shifts and never-re-matching-replacements fall
|
|
/// out for free.
|
|
#[derive(Clone, Debug)]
|
|
pub struct QueryReplaceSession {
|
|
/// The literal substring or regex source being replaced.
|
|
from: String,
|
|
/// The replacement text (may be empty — Q#QR3 deletion).
|
|
to: String,
|
|
/// Compiled regex engine when in regex mode (Q#QR9), cached for
|
|
/// the whole run so `!` stays linear; `None` = smart-case literal.
|
|
re: Option<regex::bytes::Regex>,
|
|
/// Buffer + cursor when the session began. Restored on cancel
|
|
/// *only* when nothing ever matched (Q#QR10).
|
|
origin: (BufferId, Position),
|
|
/// Byte offset the next forward search starts from — advanced past
|
|
/// each replacement so inserted text is never re-matched.
|
|
next_from: Position,
|
|
/// The match currently being prompted, or `None` before the first
|
|
/// advance / after finishing.
|
|
current: Option<crate::protocol::ByteRange>,
|
|
/// Number of replacements applied so far.
|
|
replaced: usize,
|
|
/// Whether any match was ever found (distinguishes "nothing
|
|
/// matched → restore origin" from "matched, then quit").
|
|
found_any: bool,
|
|
}
|
|
|
|
/// 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)>,
|
|
/// In-buffer incremental search store (Q#SR1). Per-buffer query +
|
|
/// matches + active index, written by the search session /
|
|
/// `search.*` commands and read by the decorations producer
|
|
/// ([`crate::semantic_render`]) and the TUI search overlay.
|
|
/// Cheaply cloneable (`Arc<Mutex>`); shared with both readers.
|
|
pub search_store: crate::search::SharedSearchStore,
|
|
/// Live incremental-search session (Q#SR5), or `None` when no
|
|
/// search is running. Frontend-agnostic: the TUI run loop and the
|
|
/// daemon's `FrontendEvent::Key` path both drive it through the
|
|
/// same `search_*` methods, so isearch behaves identically in the
|
|
/// terminal and GPU frontends. Only the *prompt surface* differs
|
|
/// (TUI bottom row vs GPU status band).
|
|
pub search: Option<SearchSession>,
|
|
/// In-core clipboard slot (Q#CM6) --- the bytes a paste inserts.
|
|
/// Written by copy/cut and by an inbound OS paste; read by paste.
|
|
/// The frontend-agnostic source of truth, so paste behaves
|
|
/// identically in the terminal and GPU frontends.
|
|
clipboard_slot: Vec<u8>,
|
|
/// One-shot outbound clipboard publish (Q#CM6). A copy/cut queues
|
|
/// `(originating frontend, bytes)`; the dispatcher drains it and
|
|
/// sends [`crate::protocol::InstanceSignal::Clipboard`] to that
|
|
/// frontend, which writes the OS clipboard (OSC 52 in the TUI,
|
|
/// `arboard` in the GPU). Drained per-tick like `pending_crdt_ops`.
|
|
pending_clipboard: Option<(FrontendId, Vec<u8>)>,
|
|
/// Open context menu (Q#CM1), or `None` when closed. Shared
|
|
/// `Arc<Mutex>` so the TUI [`crate::menu::MenuView`] overlay renders
|
|
/// from the same state the dispatch path mutates — the menu twin of
|
|
/// `search_store`.
|
|
pub menu: crate::menu::SharedMenu,
|
|
/// Open in-buffer completion popup (Arc 1a, Q#C2), or `None` when
|
|
/// closed. Shared `Arc<Mutex>` so the TUI
|
|
/// [`crate::completion::CompletionView`] overlay renders from the
|
|
/// same state the dispatch path navigates and the Lua driver
|
|
/// publishes into — the completion twin of `menu`.
|
|
pub completion_popup: crate::completion::SharedCompletionPopup,
|
|
/// Buffers whose input must round-trip (Arc 1b, Q#P6). While one
|
|
/// of these is the active buffer,
|
|
/// [`crate::editor::EditorState::dispatch_idle`] reports `false`,
|
|
/// so semantic frontends' optimistic-apply stays off: RET reaches
|
|
/// buffer-local bindings (a panel's visit) instead of locally
|
|
/// inserting `\n`, and plain typing dispatches into the edit path
|
|
/// where a read-only intercept can reject it — a CRDT-import
|
|
/// write would bypass the intercept chain entirely. Marked from
|
|
/// Lua via `pmacs.buffer.set_round_trip_input`; pruned on kill.
|
|
round_trip_buffers: std::collections::HashSet<BufferId>,
|
|
/// Live query-replace interactive session (Arc 2), or `None`. The
|
|
/// query-replace twin of `search`; drives the fifth dispatcher
|
|
/// shadow.
|
|
query_replace: Option<QueryReplaceSession>,
|
|
}
|
|
|
|
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(),
|
|
search_store: crate::search::make_shared_store(),
|
|
search: None,
|
|
clipboard_slot: Vec::new(),
|
|
pending_clipboard: None,
|
|
menu: crate::menu::make_shared_menu(),
|
|
completion_popup: crate::completion::make_shared_popup(),
|
|
round_trip_buffers: std::collections::HashSet::new(),
|
|
query_replace: None,
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
// ---- incremental search (Q#SR5) ----------------------------------------
|
|
//
|
|
// Frontend-agnostic isearch driven entirely through these methods.
|
|
// The TUI's `dispatch_search_key` and (later) the GPU's round-tripped
|
|
// keystrokes both call into here, so search behaves identically in
|
|
// both frontends. Matches live in `search_store` (shared with the
|
|
// decorations producer and the TUI overlay); `search` holds the
|
|
// live query + origin.
|
|
|
|
/// `true` iff an incremental search is in progress.
|
|
#[must_use]
|
|
pub fn search_active(&self) -> bool {
|
|
self.search.is_some()
|
|
}
|
|
|
|
/// The current isearch query (empty when no search is running).
|
|
#[must_use]
|
|
pub fn search_query(&self) -> &str {
|
|
self.search.as_ref().map_or("", |s| s.query.as_str())
|
|
}
|
|
|
|
/// Direction of the active search (`true` = forward). Defaults to
|
|
/// forward when no search is running — callers should gate on
|
|
/// [`Self::search_active`] first.
|
|
#[must_use]
|
|
pub fn search_forward(&self) -> bool {
|
|
self.search.as_ref().is_none_or(|s| s.forward)
|
|
}
|
|
|
|
/// `true` iff the active search is in regex mode (Q#RX3). `false`
|
|
/// for literal substring, or when no search is running.
|
|
#[must_use]
|
|
pub fn search_is_regex(&self) -> bool {
|
|
self.search.as_ref().is_some_and(|s| s.regex)
|
|
}
|
|
|
|
/// `true` iff the active regex search's pattern failed to compile —
|
|
/// the prompt shows `[invalid]` rather than a match count. Always
|
|
/// `false` in literal mode / when no search is running.
|
|
#[must_use]
|
|
pub fn search_is_invalid(&self) -> bool {
|
|
self.search.as_ref().is_some_and(|s| s.invalid)
|
|
}
|
|
|
|
/// `(active_index, total)` for the active buffer's matches, for the
|
|
/// prompt's "n/m" readout. `active_index` is 0-based and `None`
|
|
/// when there are no matches.
|
|
#[must_use]
|
|
pub fn search_match_summary(&self) -> (Option<usize>, usize) {
|
|
let bid = self.active_buffer_id();
|
|
let guard = self
|
|
.search_store
|
|
.lock()
|
|
.expect("search store mutex poisoned");
|
|
guard
|
|
.for_buffer(bid)
|
|
.map_or((None, 0), |s| (s.active_index(), s.len()))
|
|
}
|
|
|
|
/// Begin an incremental search anchored at the active buffer +
|
|
/// cursor. `forward` sets the initial step direction; `regex`
|
|
/// selects regex (`true`) vs literal substring (`false`) matching.
|
|
/// A no-op if a search is already running (the entry chord is
|
|
/// intercepted while active, so this is only reached from an
|
|
/// inactive state — the guard is belt-and-suspenders).
|
|
pub fn search_begin(&mut self, forward: bool, regex: bool) {
|
|
if self.search.is_some() {
|
|
return;
|
|
}
|
|
let origin = (self.active_buffer_id(), self.cursor());
|
|
// Attach the TUI match-wash overlay to the active window (once)
|
|
// so matches highlight live as the query grows. It
|
|
// self-suppresses when the store has no matches / is stale, so
|
|
// leaving it attached across searches is safe. The GPU gets the
|
|
// same matches via SearchMatch decorations and never reads this.
|
|
self.ensure_search_overlay();
|
|
self.search = Some(SearchSession {
|
|
query: String::new(),
|
|
origin,
|
|
forward,
|
|
regex,
|
|
invalid: false,
|
|
});
|
|
}
|
|
|
|
/// Toggle the active search between literal and regex matching
|
|
/// (Q#RX3, `M-r`), re-running the current query in the new mode. A
|
|
/// no-op when no search is running.
|
|
pub fn search_toggle_regex(&mut self) {
|
|
let Some(session) = self.search.as_mut() else {
|
|
return;
|
|
};
|
|
session.regex = !session.regex;
|
|
self.search_recompute();
|
|
}
|
|
|
|
/// Ensure the active window carries a [`crate::search::SearchView`]
|
|
/// overlay, attaching one if absent (deduped by overlay kind). The
|
|
/// view reads the per-buffer [`Self::search_store`] keyed on the
|
|
/// rendered buffer, so one instance suffices per window.
|
|
fn ensure_search_overlay(&mut self) {
|
|
let store = self.search_store.clone();
|
|
let win = self.active_window_mut();
|
|
if !win.overlay_kinds().contains(&"search") {
|
|
win.push_overlay(Box::new(crate::search::SearchView::new(store)));
|
|
}
|
|
}
|
|
|
|
/// Append a character to the query and re-search.
|
|
pub fn search_input_char(&mut self, ch: char) {
|
|
let Some(session) = self.search.as_mut() else {
|
|
return;
|
|
};
|
|
session.query.push(ch);
|
|
self.search_recompute();
|
|
}
|
|
|
|
/// Drop the last character of the query and re-search. With an
|
|
/// empty query this is a no-op (the search stays open, empty).
|
|
pub fn search_backspace(&mut self) {
|
|
let Some(session) = self.search.as_mut() else {
|
|
return;
|
|
};
|
|
session.query.pop();
|
|
self.search_recompute();
|
|
}
|
|
|
|
/// Re-run `find_all` for the current query against the origin
|
|
/// buffer, refresh the store, and move the cursor to the match
|
|
/// nearest the origin (first match at/after the origin cursor,
|
|
/// wrapping). An empty query or no match anchors the cursor back
|
|
/// at the origin so a failing search never drifts the view.
|
|
fn search_recompute(&mut self) {
|
|
let Some(session) = self.search.as_ref() else {
|
|
return;
|
|
};
|
|
let bid = session.origin.0;
|
|
let origin_byte = session.origin.1;
|
|
let query = session.query.clone();
|
|
let regex = session.regex;
|
|
let bytes = self.buffer_bytes(bid);
|
|
// Regex: `None` ⇒ the pattern won't compile (mark invalid, drop
|
|
// matches). Literal substring never fails. An invalid pattern
|
|
// clears the store (no stale matches paint) and shows
|
|
// `[invalid]` via the prompt.
|
|
let (matches, invalid) = if regex {
|
|
match crate::search::find_all_regex(&bytes, &query) {
|
|
Some(m) => (m, false),
|
|
None => (Vec::new(), true),
|
|
}
|
|
} else {
|
|
(crate::search::find_all(&bytes, &query), false)
|
|
};
|
|
if let Some(session) = self.search.as_mut() {
|
|
session.invalid = invalid;
|
|
}
|
|
let focus = {
|
|
let mut guard = self
|
|
.search_store
|
|
.lock()
|
|
.expect("search store mutex poisoned");
|
|
guard.set(bid, query, matches);
|
|
guard.focus_from(bid, origin_byte)
|
|
};
|
|
let target = focus.map_or(origin_byte, |range| range.start);
|
|
self.search_place_cursor(target);
|
|
}
|
|
|
|
/// Step the active buffer's match focus forward/backward (wrapping)
|
|
/// and move the cursor to it. Operates on [`Self::search_store`]
|
|
/// directly, so it works both during a live session (C-s / C-r)
|
|
/// and after accept (a `search.next` navigation command). A no-op
|
|
/// when the active buffer has no matches.
|
|
pub fn search_step(&mut self, forward: bool) {
|
|
if let Some(session) = self.search.as_mut() {
|
|
session.forward = forward;
|
|
}
|
|
let bid = self.active_buffer_id();
|
|
let stepped = {
|
|
let mut guard = self
|
|
.search_store
|
|
.lock()
|
|
.expect("search store mutex poisoned");
|
|
guard.step(bid, forward)
|
|
};
|
|
if let Some(range) = stepped {
|
|
self.search_place_cursor(range.start);
|
|
}
|
|
}
|
|
|
|
/// End the active search. `accept` keeps the cursor at the current
|
|
/// match and leaves the matches in the store (so they stay
|
|
/// highlighted, and `search.next` can resume, until the next edit
|
|
/// marks them stale). Cancel restores the origin cursor and clears
|
|
/// the matches. A no-op when no search is running.
|
|
pub fn search_finish(&mut self, accept: bool) {
|
|
let Some(session) = self.search.take() else {
|
|
return;
|
|
};
|
|
if accept {
|
|
return;
|
|
}
|
|
let (bid, origin_byte) = session.origin;
|
|
if self.active_buffer_id() == bid {
|
|
self.search_place_cursor(origin_byte);
|
|
}
|
|
self.search_store
|
|
.lock()
|
|
.expect("search store mutex poisoned")
|
|
.clear(bid);
|
|
}
|
|
|
|
/// Move the active window's cursor to a byte offset (clamped to the
|
|
/// buffer extent), resetting the goal column. Shared by the search
|
|
/// motions.
|
|
fn search_place_cursor(&mut self, byte: u64) {
|
|
let clamped = byte.min(self.active_buffer_len());
|
|
let aw = self.active_window_mut();
|
|
aw.cursor = clamped;
|
|
aw.goal_col = None;
|
|
}
|
|
|
|
// ---- query-replace (Arc 2, Q#QR1-10) -----------------------------------
|
|
|
|
/// True while a query-replace interactive session is running (the
|
|
/// fifth dispatcher-shadow predicate; also drives `dispatch_idle`
|
|
/// and the modal-close guard).
|
|
#[must_use]
|
|
pub fn query_replace_active(&self) -> bool {
|
|
self.query_replace.is_some()
|
|
}
|
|
|
|
/// The buffer a running query-replace is pinned to, or `None`. The
|
|
/// dispatcher reads this so the `buffer.after-edit` revision compare
|
|
/// targets the *edited* buffer, not whichever is active.
|
|
#[must_use]
|
|
pub fn query_replace_origin_buffer(&self) -> Option<BufferId> {
|
|
self.query_replace.as_ref().map(|s| s.origin.0)
|
|
}
|
|
|
|
/// Query-replace's wrong-buffer guard. Every edit and cursor move it
|
|
/// makes goes through the *active* window/buffer, but the session is
|
|
/// pinned to the buffer it started in — and focus can drift
|
|
/// mid-session (a click into another split, a key from another
|
|
/// frontend). Before touching the buffer, verify the active buffer
|
|
/// is still the origin buffer; if not, **abort without editing** so
|
|
/// a match found in the origin buffer can never be applied to an
|
|
/// unrelated one. Returns `true` when it is safe to proceed.
|
|
fn query_replace_on_origin(&mut self) -> bool {
|
|
let Some(origin_bid) = self.query_replace.as_ref().map(|s| s.origin.0) else {
|
|
return false;
|
|
};
|
|
if self.active_buffer_id() == origin_bid {
|
|
return true;
|
|
}
|
|
// Focus moved off the origin buffer — abort, don't corrupt.
|
|
if let Some(session) = self.query_replace.take() {
|
|
self.search_store
|
|
.lock()
|
|
.expect("search store mutex poisoned")
|
|
.clear(session.origin.0);
|
|
}
|
|
self.status = "query-replace aborted: active buffer changed".into();
|
|
false
|
|
}
|
|
|
|
/// Begin a query-replace from the cursor forward (Q#QR8). `regex`
|
|
/// selects `query-replace-regexp` (Q#QR9). An invalid regex refuses
|
|
/// to start (Q#QR2). Immediately advances to (and prompts on) the
|
|
/// first match, or finishes with "No matches" when there are none.
|
|
pub fn query_replace_begin(&mut self, from: String, to: String, regex: bool) {
|
|
if self.query_replace.is_some() || from.is_empty() {
|
|
return;
|
|
}
|
|
let re = if regex {
|
|
let Some(re) = crate::search::compile_search_regex(&from) else {
|
|
self.status = format!("Invalid regex: {from}");
|
|
return;
|
|
};
|
|
Some(re)
|
|
} else {
|
|
None
|
|
};
|
|
let origin = (self.active_buffer_id(), self.cursor());
|
|
// Reuse the isearch match-wash overlay to highlight the current
|
|
// match in the TUI; the GPU gets it via SearchMatch decorations.
|
|
self.ensure_search_overlay();
|
|
self.query_replace = Some(QueryReplaceSession {
|
|
from,
|
|
to,
|
|
re,
|
|
origin,
|
|
next_from: origin.1,
|
|
current: None,
|
|
replaced: 0,
|
|
found_any: false,
|
|
});
|
|
self.query_replace_advance();
|
|
}
|
|
|
|
/// Find the next match at/after `next_from` on the live buffer. On
|
|
/// a hit: highlight it, reveal it (cursor to its start, Q#QR2), and
|
|
/// prompt. On a miss: finish (natural end / nothing-matched).
|
|
fn query_replace_advance(&mut self) {
|
|
let Some(session) = self.query_replace.as_ref() else {
|
|
return;
|
|
};
|
|
let bid = session.origin.0;
|
|
let bytes = self.buffer_bytes(bid);
|
|
let start = (session.next_from as usize).min(bytes.len());
|
|
let found = match &session.re {
|
|
Some(re) => crate::search::find_first_regex_from(&bytes, re, start),
|
|
None => crate::search::find_first_from(&bytes, &session.from, start),
|
|
};
|
|
let Some(range) = found else {
|
|
self.query_replace_finish();
|
|
return;
|
|
};
|
|
let from = session.from.clone();
|
|
if let Some(session) = self.query_replace.as_mut() {
|
|
session.current = Some(range);
|
|
session.found_any = true;
|
|
}
|
|
// Highlight just this match: a single-element store set renders
|
|
// it as SearchMatchActive in both frontends (Q#QR5).
|
|
{
|
|
let mut guard = self
|
|
.search_store
|
|
.lock()
|
|
.expect("search store mutex poisoned");
|
|
guard.set(bid, from, vec![range]);
|
|
}
|
|
self.search_place_cursor(range.start);
|
|
self.query_replace_set_prompt();
|
|
}
|
|
|
|
/// Set `core.status` to the per-match prompt (Q#QR4) — shown in
|
|
/// both frontends via the v15 `StatusFacts.message` band.
|
|
fn query_replace_set_prompt(&mut self) {
|
|
if let Some(session) = self.query_replace.as_ref() {
|
|
self.status = format!(
|
|
"Query replacing '{}' with '{}' (y/n, ! all, . last, q quit)",
|
|
session.from, session.to
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Replace the current match with the to-string as a single edit
|
|
/// (Q#QR7), advancing `next_from` past the inserted text so it is
|
|
/// never re-matched (Q#QR2). Returns `true` when an edit was
|
|
/// applied. Does NOT advance to the next match — callers chain
|
|
/// `query_replace_advance` (or finish) as their flow needs.
|
|
fn query_replace_apply_current(&mut self) -> bool {
|
|
let Some(session) = self.query_replace.as_ref() else {
|
|
return false;
|
|
};
|
|
let Some(range) = session.current else {
|
|
return false;
|
|
};
|
|
let to = session.to.clone();
|
|
if let Err(e) = self.apply_active_edit(EditOp::Replace {
|
|
range: Range {
|
|
start: range.start,
|
|
end: range.end,
|
|
},
|
|
bytes: to.as_bytes(),
|
|
}) {
|
|
self.status = format!("query-replace: {e}");
|
|
return false;
|
|
}
|
|
let new_next = range.start + to.len() as u64;
|
|
if let Some(session) = self.query_replace.as_mut() {
|
|
session.next_from = new_next;
|
|
session.current = None;
|
|
session.replaced += 1;
|
|
}
|
|
self.search_place_cursor(new_next);
|
|
true
|
|
}
|
|
|
|
/// `y` / `SPC` — replace the current match, then advance to the next.
|
|
pub fn query_replace_replace(&mut self) {
|
|
if self.query_replace_on_origin() && self.query_replace_apply_current() {
|
|
self.query_replace_advance();
|
|
}
|
|
}
|
|
|
|
/// `n` / `DEL` — leave the current match, advance past it to the next.
|
|
pub fn query_replace_skip(&mut self) {
|
|
if !self.query_replace_on_origin() {
|
|
return;
|
|
}
|
|
if let Some(session) = self.query_replace.as_mut()
|
|
&& let Some(range) = session.current
|
|
{
|
|
session.next_from = range.end;
|
|
session.current = None;
|
|
}
|
|
self.query_replace_advance();
|
|
}
|
|
|
|
/// `!` — replace the current match and all remaining without
|
|
/// prompting, then finish (Q#QR6). One `after-edit` hook fires for
|
|
/// the batch (the dispatcher compares revision across the handler).
|
|
pub fn query_replace_all(&mut self) {
|
|
if !self.query_replace_on_origin() {
|
|
return;
|
|
}
|
|
while self.query_replace_apply_current() {
|
|
// Find the next match (mirrors advance's search, without the
|
|
// highlight/prompt work — we're not stopping to ask).
|
|
let Some(session) = self.query_replace.as_ref() else {
|
|
break;
|
|
};
|
|
let bid = session.origin.0;
|
|
let bytes = self.buffer_bytes(bid);
|
|
let start = (session.next_from as usize).min(bytes.len());
|
|
let found = match &session.re {
|
|
Some(re) => crate::search::find_first_regex_from(&bytes, re, start),
|
|
None => crate::search::find_first_from(&bytes, &session.from, start),
|
|
};
|
|
match found {
|
|
Some(range) => {
|
|
if let Some(session) = self.query_replace.as_mut() {
|
|
session.current = Some(range);
|
|
}
|
|
}
|
|
None => break,
|
|
}
|
|
}
|
|
self.query_replace_finish();
|
|
}
|
|
|
|
/// `.` — replace the current match, then finish (Q#QR6).
|
|
pub fn query_replace_replace_and_quit(&mut self) {
|
|
if !self.query_replace_on_origin() {
|
|
return;
|
|
}
|
|
self.query_replace_apply_current();
|
|
self.query_replace_finish();
|
|
}
|
|
|
|
/// End the session (Q#QR10): clear the highlight, restore the origin
|
|
/// cursor *only* if nothing ever matched, and set the count status.
|
|
/// Every other exit leaves point where the last step put it.
|
|
pub fn query_replace_finish(&mut self) {
|
|
let Some(session) = self.query_replace.take() else {
|
|
return;
|
|
};
|
|
let bid = session.origin.0;
|
|
self.search_store
|
|
.lock()
|
|
.expect("search store mutex poisoned")
|
|
.clear(bid);
|
|
if session.found_any {
|
|
let n = session.replaced;
|
|
self.status = format!("Replaced {n} occurrence{}", if n == 1 { "" } else { "s" });
|
|
} else {
|
|
if self.active_buffer_id() == bid {
|
|
self.search_place_cursor(session.origin.1);
|
|
}
|
|
self.status = format!("No matches for '{}'", session.from);
|
|
}
|
|
}
|
|
|
|
/// Snapshot a buffer's full byte content (empty if the id is
|
|
/// stale). O(1) rope snapshot + one copy; used to feed `find_all`.
|
|
fn buffer_bytes(&self, buffer_id: BufferId) -> Vec<u8> {
|
|
let reg = self.registry.borrow();
|
|
let Ok(buf) = reg.get(buffer_id) else {
|
|
return Vec::new();
|
|
};
|
|
let len = buf.len();
|
|
let mut out = vec![0u8; len as usize];
|
|
buf.snapshot_rope().slice(0, len, &mut out);
|
|
out
|
|
}
|
|
|
|
// ---- 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()));
|
|
}
|
|
// Search matches were computed against the pre-edit text, so
|
|
// their byte positions are now wrong. Mark the buffer's matches
|
|
// stale (M11.8): the producer / TUI overlay suppress them until
|
|
// a fresh search re-runs against the current content. No-op for
|
|
// a buffer with no search state. The headline isearch bet —
|
|
// "stale-after-edit linger" — is closed here.
|
|
self.search_store
|
|
.lock()
|
|
.expect("search store mutex poisoned")
|
|
.mark_stale(buffer_id);
|
|
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 in this
|
|
/// frontend). Returns false if the active frontend's layout has a
|
|
/// single window.
|
|
pub fn close_active(&mut self) -> bool {
|
|
// Per-frontend: gate on the *active frontend's* window count, not
|
|
// the global `self.windows` set. Every attached frontend keeps its
|
|
// own windows in `self.windows`, so a global `<= 1` check let a
|
|
// multi-frontend session close a frontend's last window and then
|
|
// panic picking a successor from the now-empty layout.
|
|
if self.active_layout().iter_ids().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, *within the active
|
|
/// frontend*.
|
|
pub fn close_others(&mut self) {
|
|
// Per-frontend: only prune the active frontend's own layout. The
|
|
// global `self.windows` set holds every frontend's windows, so a
|
|
// global `retain(|id| id == keep)` deleted OTHER frontends'
|
|
// windows — leaving their `view.active` dangling and panicking the
|
|
// next `active_window()` (the multi-frontend close-others crash).
|
|
let keep = self.active_window_id();
|
|
let doomed: Vec<WindowId> = self
|
|
.active_layout()
|
|
.iter_ids()
|
|
.into_iter()
|
|
.filter(|id| *id != keep)
|
|
.collect();
|
|
self.active_layout_mut().keep_only(keep);
|
|
for id in doomed {
|
|
self.windows.remove(&id);
|
|
}
|
|
}
|
|
|
|
// ---- 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)
|
|
}
|
|
|
|
// ---- clipboard (Q#CM6) -------------------------------------------------
|
|
|
|
/// The identifier under (or immediately left of) the cursor, or
|
|
/// `None` when the cursor isn't on a word (Q#CM3, the `symbol`
|
|
/// context). A word is a run of ASCII alphanumerics / `_`; since
|
|
/// those are all single-byte, the slice always lands on UTF-8
|
|
/// boundaries.
|
|
#[must_use]
|
|
pub fn word_at_cursor(&self) -> Option<String> {
|
|
let bytes = self.buffer_bytes(self.active_buffer_id());
|
|
let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
|
|
let cursor = (self.cursor() as usize).min(bytes.len());
|
|
let mut start = cursor;
|
|
while start > 0 && is_word(bytes[start - 1]) {
|
|
start -= 1;
|
|
}
|
|
let mut end = cursor;
|
|
while end < bytes.len() && is_word(bytes[end]) {
|
|
end += 1;
|
|
}
|
|
if start == end {
|
|
return None;
|
|
}
|
|
String::from_utf8(bytes[start..end].to_vec()).ok()
|
|
}
|
|
|
|
/// Bytes of the active region, or `None` when nothing is selected.
|
|
#[must_use]
|
|
pub fn region_bytes(&self) -> Option<Vec<u8>> {
|
|
let (lo, hi) = self.active_region()?;
|
|
let reg = self.registry.borrow();
|
|
let buf = reg.get(self.active_buffer_id()).ok()?;
|
|
let mut out = vec![0u8; (hi - lo) as usize];
|
|
buf.snapshot_rope().slice(lo, hi, &mut out);
|
|
Some(out)
|
|
}
|
|
|
|
/// Copy the active region into the clipboard slot and queue an
|
|
/// outbound OS-clipboard publish to the originating frontend.
|
|
/// Returns `false` (a no-op) when there is no region.
|
|
pub fn clipboard_copy(&mut self) -> bool {
|
|
let Some(bytes) = self.region_bytes() else {
|
|
return false;
|
|
};
|
|
self.clipboard_slot.clone_from(&bytes);
|
|
self.pending_clipboard = Some((self.active_frontend, bytes));
|
|
true
|
|
}
|
|
|
|
/// Cut: copy the region, then delete it. Returns `false` (a no-op)
|
|
/// when there is no region.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Propagates [`Self::delete_region`]'s error.
|
|
pub fn clipboard_cut(&mut self) -> Result<bool, String> {
|
|
if !self.clipboard_copy() {
|
|
return Ok(false);
|
|
}
|
|
self.delete_region()?;
|
|
Ok(true)
|
|
}
|
|
|
|
/// Paste the clipboard slot at the cursor, replacing the active
|
|
/// region if one exists (one undo step, like CUA type-over).
|
|
/// Returns `false` when the slot is empty.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Propagates the underlying edit error.
|
|
pub fn clipboard_paste(&mut self) -> Result<bool, String> {
|
|
if self.clipboard_slot.is_empty() {
|
|
return Ok(false);
|
|
}
|
|
let bytes = self.clipboard_slot.clone();
|
|
self.insert_bytes_over_region(&bytes)?;
|
|
Ok(true)
|
|
}
|
|
|
|
/// Insert externally-pasted bytes at the cursor (inbound OS paste:
|
|
/// terminal bracketed paste, or GPU Ctrl-V via `arboard`),
|
|
/// refreshing the slot so a later in-app paste repeats them.
|
|
/// Replaces the active region if one exists.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Propagates the underlying edit error.
|
|
pub fn paste_inbound(&mut self, data: &[u8]) -> Result<(), String> {
|
|
self.clipboard_slot = data.to_vec();
|
|
self.insert_bytes_over_region(data)
|
|
}
|
|
|
|
/// Shared insert/replace for paste: `Replace` over the active
|
|
/// region, else `Insert` at the cursor. The cursor lands just past
|
|
/// the inserted bytes and any selection is cleared. No-op insert for
|
|
/// empty `bytes`.
|
|
fn insert_bytes_over_region(&mut self, bytes: &[u8]) -> Result<(), String> {
|
|
self.active_window_mut().goal_col = None;
|
|
let start = if let Some((lo, hi)) = self.active_region() {
|
|
self.apply_active_edit(EditOp::Replace {
|
|
range: Range { start: lo, end: hi },
|
|
bytes,
|
|
})?;
|
|
lo
|
|
} else {
|
|
let pos = self.active_window().cursor;
|
|
self.apply_active_edit(EditOp::Insert { pos, bytes })?;
|
|
pos
|
|
};
|
|
let aw = self.active_window_mut();
|
|
aw.cursor = start + bytes.len() as u64;
|
|
aw.selection = None;
|
|
Ok(())
|
|
}
|
|
|
|
/// Select the whole active buffer (anchor at 0, cursor at the end).
|
|
pub fn select_all(&mut self) {
|
|
let len = self.active_buffer_len();
|
|
self.begin_selection(0);
|
|
let aw = self.active_window_mut();
|
|
aw.cursor = len;
|
|
aw.goal_col = None;
|
|
}
|
|
|
|
/// Drain the one-shot outbound clipboard publish, if any. Called by
|
|
/// the dispatcher each tick (alongside `pending_crdt_ops`).
|
|
pub fn take_pending_clipboard(&mut self) -> Option<(FrontendId, Vec<u8>)> {
|
|
self.pending_clipboard.take()
|
|
}
|
|
|
|
/// The current clipboard slot bytes (testing / introspection).
|
|
#[must_use]
|
|
pub fn clipboard_slot(&self) -> &[u8] {
|
|
&self.clipboard_slot
|
|
}
|
|
|
|
// ---- context menu (Q#CM1) ----------------------------------------------
|
|
|
|
/// True while a context menu is open.
|
|
#[must_use]
|
|
pub fn menu_is_open(&self) -> bool {
|
|
self.menu.lock().expect("menu mutex poisoned").is_some()
|
|
}
|
|
|
|
/// Open a menu of resolved `rows` anchored at the absolute `anchor`
|
|
/// cell. A no-op (stays closed) when no row is selectable. Attaches
|
|
/// the TUI overlay on first open (deduped by kind).
|
|
pub fn menu_open(&mut self, rows: Vec<crate::menu::MenuRow>, anchor: (u32, u32)) {
|
|
let state = crate::menu::MenuState::new(rows, anchor);
|
|
let opened = state.is_some();
|
|
*self.menu.lock().expect("menu mutex poisoned") = state;
|
|
if opened {
|
|
self.ensure_menu_overlay();
|
|
}
|
|
}
|
|
|
|
/// Close the menu (the overlay then self-suppresses).
|
|
pub fn menu_close(&mut self) {
|
|
*self.menu.lock().expect("menu mutex poisoned") = None;
|
|
}
|
|
|
|
/// Move the highlight by `delta` items (wrapping, skipping separators).
|
|
pub fn menu_step(&mut self, delta: isize) {
|
|
if let Some(m) = self.menu.lock().expect("menu mutex poisoned").as_mut() {
|
|
m.step(delta);
|
|
}
|
|
}
|
|
|
|
/// Set the highlight to `row` if it names a selectable item (mouse
|
|
/// hover / click).
|
|
pub fn menu_set_active_row(&mut self, row: usize) {
|
|
if let Some(m) = self.menu.lock().expect("menu mutex poisoned").as_mut()
|
|
&& matches!(m.rows.get(row), Some(crate::menu::MenuRow::Item { .. }))
|
|
{
|
|
m.active = row;
|
|
}
|
|
}
|
|
|
|
/// The active item's command name, if a menu is open.
|
|
#[must_use]
|
|
pub fn menu_active_command(&self) -> Option<String> {
|
|
self.menu
|
|
.lock()
|
|
.expect("menu mutex poisoned")
|
|
.as_ref()
|
|
.and_then(|m| m.active_command().map(str::to_owned))
|
|
}
|
|
|
|
/// Hit-test an absolute cell against the open popup, returning the
|
|
/// selectable row it covers (or `None`).
|
|
#[must_use]
|
|
pub fn menu_hit(&self, row: u32, col: u32) -> Option<usize> {
|
|
self.menu
|
|
.lock()
|
|
.expect("menu mutex poisoned")
|
|
.as_ref()
|
|
.and_then(|m| m.hit(row, col))
|
|
}
|
|
|
|
/// Ensure the active window carries a [`crate::menu::MenuView`]
|
|
/// overlay (deduped by kind). The view reads the shared `menu`, so
|
|
/// one instance suffices; it renders nothing while the menu is closed.
|
|
fn ensure_menu_overlay(&mut self) {
|
|
let menu = self.menu.clone();
|
|
let win = self.active_window_mut();
|
|
if !win.overlay_kinds().contains(&"context-menu") {
|
|
win.push_overlay(Box::new(crate::menu::MenuView::new(menu)));
|
|
}
|
|
}
|
|
|
|
// ---- in-buffer completion popup (Arc 1a, Q#C2/Q#C3) --------------------
|
|
|
|
/// True while the in-buffer completion popup is open.
|
|
#[must_use]
|
|
pub fn completion_popup_is_open(&self) -> bool {
|
|
self.completion_popup
|
|
.lock()
|
|
.expect("completion popup poisoned")
|
|
.is_some()
|
|
}
|
|
|
|
/// Open (or replace) the completion popup session. Attaches the
|
|
/// self-suppressing [`crate::completion::CompletionView`] overlay to
|
|
/// the active window on first use (deduped by kind, like the menu).
|
|
/// Emptiness is enforced upstream:
|
|
/// [`crate::completion::CompletionPopupState::new`] refuses to build
|
|
/// a candidate-less session.
|
|
pub fn completion_popup_open(&mut self, mut state: crate::completion::CompletionPopupState) {
|
|
// Stamp the owning window (Lua publishers don't know window
|
|
// identity): only that window's overlay paints the popup, and
|
|
// a focus change invalidates the session.
|
|
state.window_id = Some(self.active_window_id());
|
|
*self
|
|
.completion_popup
|
|
.lock()
|
|
.expect("completion popup poisoned") = Some(state);
|
|
self.ensure_completion_overlay();
|
|
}
|
|
|
|
/// Close the popup (the overlay then self-suppresses).
|
|
pub fn completion_popup_close(&mut self) {
|
|
*self
|
|
.completion_popup
|
|
.lock()
|
|
.expect("completion popup poisoned") = None;
|
|
}
|
|
|
|
/// Move the popup highlight by `delta` (wrapping).
|
|
pub fn completion_popup_step(&mut self, delta: isize) {
|
|
if let Some(p) = self
|
|
.completion_popup
|
|
.lock()
|
|
.expect("completion popup poisoned")
|
|
.as_mut()
|
|
{
|
|
p.step(delta);
|
|
}
|
|
}
|
|
|
|
/// Q#C3 session invariant: the popup only survives while the
|
|
/// active buffer still matches, the cursor sits at or after the
|
|
/// anchor, and every byte between them is a word byte (`[A-Za-z0-9_]`
|
|
/// --- the same ASCII word definition the Lua driver uses). A
|
|
/// trigger-character session (empty prefix, `cursor == anchor`)
|
|
/// holds trivially. Returns the `(anchor, cursor)` pair while the
|
|
/// invariant holds.
|
|
#[must_use]
|
|
fn completion_session_holds(&self) -> Option<(Position, Position)> {
|
|
/// Longest byte run still plausibly a completion prefix; past
|
|
/// this the session is stale, not a prefix.
|
|
const MAX_PREFIX_BYTES: u64 = 512;
|
|
|
|
let (buffer_id, window_id, anchor) = {
|
|
let guard = self
|
|
.completion_popup
|
|
.lock()
|
|
.expect("completion popup poisoned");
|
|
let p = guard.as_ref()?;
|
|
(p.buffer_id, p.window_id, p.anchor)
|
|
};
|
|
if window_id != Some(self.active_window_id()) {
|
|
return None; // focus moved to another window/split
|
|
}
|
|
if self.active_buffer_id() != buffer_id {
|
|
return None;
|
|
}
|
|
let cursor = self.active_window().cursor;
|
|
if cursor < anchor || cursor - anchor > MAX_PREFIX_BYTES {
|
|
return None;
|
|
}
|
|
let reg = self.registry.borrow();
|
|
let buffer = reg.get(buffer_id).ok()?;
|
|
if cursor > buffer.len() {
|
|
return None;
|
|
}
|
|
let mut bytes = vec![0u8; (cursor - anchor) as usize];
|
|
if !bytes.is_empty() {
|
|
buffer.snapshot_rope().slice(anchor, cursor, &mut bytes);
|
|
}
|
|
bytes
|
|
.iter()
|
|
.all(|b| b.is_ascii_alphanumeric() || *b == b'_')
|
|
.then_some((anchor, cursor))
|
|
}
|
|
|
|
/// Q#C3 post-dispatch validation: close the popup unless the
|
|
/// session invariant still holds. Called by the dispatcher after
|
|
/// every fallen-through key (motion, edits, buffer switches) and
|
|
/// cheap enough to call unconditionally --- a closed popup is a
|
|
/// single mutex peek.
|
|
pub fn completion_popup_validate(&mut self) {
|
|
if self.completion_popup_is_open() && self.completion_session_holds().is_none() {
|
|
self.completion_popup_close();
|
|
}
|
|
}
|
|
|
|
/// Q#C7 accept: re-validate the session at the moment of accept,
|
|
/// close the popup, and --- only when the invariant still holds ---
|
|
/// replace `[anchor .. cursor]` with the highlighted candidate's
|
|
/// insert text as a **single** edit (one undo step, mirroring
|
|
/// [`Self::insert_char_over_region`]). Returns `true` iff the
|
|
/// buffer was edited (the dispatcher fires `buffer.after-edit`
|
|
/// off that signal).
|
|
pub fn completion_popup_accept(&mut self) -> bool {
|
|
let holds = self.completion_session_holds();
|
|
let snap = {
|
|
let guard = self
|
|
.completion_popup
|
|
.lock()
|
|
.expect("completion popup poisoned");
|
|
guard
|
|
.as_ref()
|
|
.and_then(|p| p.selected_candidate().map(|c| c.insert_text.clone()))
|
|
};
|
|
self.completion_popup_close();
|
|
let (Some((anchor, cursor)), Some(text)) = (holds, snap) else {
|
|
return false;
|
|
};
|
|
self.active_window_mut().goal_col = None;
|
|
// An empty range degenerates to a plain insert (the
|
|
// trigger-character case, where nothing was typed yet).
|
|
let result = if cursor > anchor {
|
|
self.apply_active_edit(EditOp::Replace {
|
|
range: Range {
|
|
start: anchor,
|
|
end: cursor,
|
|
},
|
|
bytes: text.as_bytes(),
|
|
})
|
|
} else {
|
|
self.apply_active_edit(EditOp::Insert {
|
|
pos: anchor,
|
|
bytes: text.as_bytes(),
|
|
})
|
|
};
|
|
if let Err(e) = result {
|
|
self.status = format!("completion accept failed: {e}");
|
|
return false;
|
|
}
|
|
let aw = self.active_window_mut();
|
|
aw.cursor = anchor + text.len() as u64;
|
|
aw.selection = None;
|
|
true
|
|
}
|
|
|
|
// ---- round-trip input buffers (Arc 1b, Q#P6) ----------------------------
|
|
|
|
/// Mark (or unmark) `buffer_id` as requiring round-trip input.
|
|
/// See the field doc on `round_trip_buffers` for the semantics.
|
|
pub fn set_round_trip_input(&mut self, buffer_id: BufferId, on: bool) {
|
|
if on {
|
|
self.round_trip_buffers.insert(buffer_id);
|
|
} else {
|
|
self.round_trip_buffers.remove(&buffer_id);
|
|
}
|
|
}
|
|
|
|
/// True while the active buffer requires round-trip input (a
|
|
/// panel or other buffer-local-keymap surface is focused).
|
|
#[must_use]
|
|
pub fn active_buffer_round_trips(&self) -> bool {
|
|
self.round_trip_buffers.contains(&self.active_buffer_id())
|
|
}
|
|
|
|
/// Ensure the active window carries a
|
|
/// [`crate::completion::CompletionView`] overlay (deduped by kind).
|
|
/// The view reads the shared popup, so one instance suffices; it
|
|
/// renders nothing while the popup is closed.
|
|
fn ensure_completion_overlay(&mut self) {
|
|
let popup = self.completion_popup.clone();
|
|
let wid = self.active_window_id();
|
|
let win = self.active_window_mut();
|
|
if !win.overlay_kinds().contains(&"completion-popup") {
|
|
win.push_overlay(Box::new(crate::completion::CompletionView::new(popup, wid)));
|
|
}
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
}
|
|
self.round_trip_buffers.remove(&buffer_id);
|
|
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)
|
|
}
|
|
|
|
/// Attach a second frontend `fid` with its own single-window layout,
|
|
/// sharing the active buffer (mirrors `build_fresh_frontend_view`).
|
|
fn attach_frontend(s: &mut EditorCore, fid: FrontendId) -> WindowId {
|
|
let buffer_id = s.active_buffer_id();
|
|
let text_view = {
|
|
let reg = s.registry.borrow();
|
|
crate::text_view::TextView::new(reg.get(buffer_id).expect("buffer present"))
|
|
};
|
|
let win_id = WindowId::next();
|
|
s.windows
|
|
.insert(win_id, Window::new(win_id, buffer_id, text_view));
|
|
s.register_frontend_view(
|
|
fid,
|
|
FrontendView {
|
|
layout: Layout::single(win_id),
|
|
active: win_id,
|
|
},
|
|
);
|
|
win_id
|
|
}
|
|
|
|
#[test]
|
|
fn close_others_does_not_prune_other_frontends_windows() {
|
|
// Multi-frontend crash regression: closing others from one
|
|
// frontend must not delete another frontend's window (which would
|
|
// leave its `view.active` dangling → `active_window()` panic).
|
|
let mut s = from_bytes(b"hello\n");
|
|
let local_win = s.active_window_id();
|
|
let fid2 = FrontendId(42);
|
|
let win2 = attach_frontend(&mut s, fid2);
|
|
|
|
s.active_frontend = fid2;
|
|
s.close_others();
|
|
|
|
assert!(
|
|
s.windows.contains_key(&win2),
|
|
"close_others keeps the active frontend's own window"
|
|
);
|
|
assert!(
|
|
s.windows.contains_key(&local_win),
|
|
"close_others must not remove another frontend's window"
|
|
);
|
|
// LOCAL's active window is intact — no panic.
|
|
s.active_frontend = FrontendId::LOCAL;
|
|
assert_eq!(s.active_window_id(), local_win);
|
|
let _ = s.active_window();
|
|
}
|
|
|
|
#[test]
|
|
fn close_active_refuses_the_frontends_last_window_even_with_others_attached() {
|
|
// The "only one left" guard is per-frontend: two windows exist
|
|
// globally (LOCAL + fid2), but fid2 has just one, so close must
|
|
// refuse rather than empty fid2's layout and panic.
|
|
let mut s = from_bytes(b"hello\n");
|
|
let local_win = s.active_window_id();
|
|
let fid2 = FrontendId(42);
|
|
let win2 = attach_frontend(&mut s, fid2);
|
|
|
|
s.active_frontend = fid2;
|
|
assert!(
|
|
!s.close_active(),
|
|
"close_active refuses the active frontend's only window"
|
|
);
|
|
assert!(s.windows.contains_key(&win2));
|
|
assert!(s.windows.contains_key(&local_win));
|
|
}
|
|
|
|
#[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 copy_captures_region_and_queues_publish() {
|
|
let mut s = from_bytes(b"hello world");
|
|
s.begin_selection(0);
|
|
s.active_window_mut().cursor = 5; // region [0,5) = "hello"
|
|
assert!(s.clipboard_copy());
|
|
assert_eq!(s.clipboard_slot(), b"hello");
|
|
let (fid, bytes) = s.take_pending_clipboard().expect("publish queued");
|
|
assert_eq!(fid, s.active_frontend);
|
|
assert_eq!(bytes, b"hello");
|
|
// Drained: second take is None.
|
|
assert!(s.take_pending_clipboard().is_none());
|
|
// Copy does not mutate the buffer.
|
|
assert_eq!(s.active_buffer_len(), 11);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_without_region_is_a_noop() {
|
|
let mut s = from_bytes(b"abc");
|
|
assert!(!s.clipboard_copy());
|
|
assert!(s.clipboard_slot().is_empty());
|
|
assert!(s.take_pending_clipboard().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn cut_copies_then_deletes_region() {
|
|
let mut s = from_bytes(b"hello world");
|
|
s.begin_selection(6);
|
|
s.active_window_mut().cursor = 11; // region [6,11) = "world"
|
|
assert!(s.clipboard_cut().unwrap());
|
|
assert_eq!(s.clipboard_slot(), b"world");
|
|
assert_eq!(s.buffer_bytes(s.active_buffer_id()), b"hello ");
|
|
assert_eq!(s.cursor(), 6);
|
|
}
|
|
|
|
#[test]
|
|
fn paste_inserts_slot_at_cursor() {
|
|
let mut s = from_bytes(b"ac");
|
|
// Seed the slot via a copy.
|
|
s.begin_selection(0);
|
|
s.active_window_mut().cursor = 1; // "a"
|
|
s.clipboard_copy();
|
|
// Paste "a" between a and c.
|
|
s.clear_selection();
|
|
s.active_window_mut().cursor = 1;
|
|
assert!(s.clipboard_paste().unwrap());
|
|
assert_eq!(s.buffer_bytes(s.active_buffer_id()), b"aac");
|
|
assert_eq!(s.cursor(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn paste_replaces_active_region_as_one_step() {
|
|
let mut s = from_bytes(b"hello world");
|
|
// Copy "hello".
|
|
s.begin_selection(0);
|
|
s.active_window_mut().cursor = 5;
|
|
s.clipboard_copy();
|
|
// Select "world" and paste over it.
|
|
s.begin_selection(6);
|
|
s.active_window_mut().cursor = 11;
|
|
assert!(s.clipboard_paste().unwrap());
|
|
assert_eq!(s.buffer_bytes(s.active_buffer_id()), b"hello hello");
|
|
assert!(s.active_region().is_none()); // selection cleared
|
|
}
|
|
|
|
#[test]
|
|
fn paste_with_empty_slot_is_a_noop() {
|
|
let mut s = from_bytes(b"abc");
|
|
assert!(!s.clipboard_paste().unwrap());
|
|
assert_eq!(s.active_buffer_len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn paste_inbound_inserts_and_refreshes_slot() {
|
|
let mut s = from_bytes(b"ab");
|
|
s.active_window_mut().cursor = 1;
|
|
s.paste_inbound(b"XYZ").unwrap();
|
|
assert_eq!(s.buffer_bytes(s.active_buffer_id()), b"aXYZb");
|
|
assert_eq!(s.cursor(), 4);
|
|
// Slot refreshed, so an in-app paste repeats the external text.
|
|
assert_eq!(s.clipboard_slot(), b"XYZ");
|
|
// Inbound paste does NOT queue an outbound publish (no echo loop).
|
|
assert!(s.take_pending_clipboard().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn select_all_spans_the_buffer() {
|
|
let mut s = from_bytes(b"hello");
|
|
s.active_window_mut().cursor = 2;
|
|
s.select_all();
|
|
assert_eq!(s.active_region(), Some((0, 5)));
|
|
assert_eq!(s.region_bytes().unwrap(), b"hello");
|
|
}
|
|
|
|
#[test]
|
|
fn word_at_cursor_reads_the_identifier() {
|
|
let mut s = from_bytes(b"foo bar_baz qux");
|
|
s.active_window_mut().cursor = 6; // inside "bar_baz"
|
|
assert_eq!(s.word_at_cursor().as_deref(), Some("bar_baz"));
|
|
s.active_window_mut().cursor = 0; // start of "foo"
|
|
assert_eq!(s.word_at_cursor().as_deref(), Some("foo"));
|
|
s.active_window_mut().cursor = 3; // just past "foo" → scans left
|
|
assert_eq!(s.word_at_cursor().as_deref(), Some("foo"));
|
|
}
|
|
|
|
#[test]
|
|
fn word_at_cursor_is_none_in_whitespace() {
|
|
let mut s = from_bytes(b"a b");
|
|
s.active_window_mut().cursor = 2; // a run of spaces, none adjacent left
|
|
assert_eq!(s.word_at_cursor(), None);
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
|
|
// ---- incremental search (Q#SR5) ------------------------------------
|
|
|
|
fn type_query(s: &mut EditorCore, q: &str) {
|
|
for ch in q.chars() {
|
|
s.search_input_char(ch);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn search_begin_then_type_highlights_from_origin() {
|
|
let mut s = from_bytes(b"foo bar foo baz foo");
|
|
let bid = s.active_buffer_id();
|
|
s.active_window_mut().cursor = 0;
|
|
s.search_begin(true, false);
|
|
assert!(s.search_active());
|
|
type_query(&mut s, "foo");
|
|
// Three matches: 0..3, 8..11, 16..19; first (at/after origin 0)
|
|
// is active and the cursor sits on it.
|
|
assert_eq!(s.search_match_summary(), (Some(0), 3));
|
|
assert_eq!(s.cursor(), 0);
|
|
let guard = s.search_store.lock().expect("store");
|
|
assert!(!guard.is_stale(bid));
|
|
assert_eq!(guard.for_buffer(bid).expect("entry").len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn search_step_walks_matches_and_wraps() {
|
|
let mut s = from_bytes(b"foo bar foo baz foo");
|
|
s.active_window_mut().cursor = 0;
|
|
s.search_begin(true, false);
|
|
type_query(&mut s, "foo");
|
|
assert_eq!(s.cursor(), 0);
|
|
s.search_step(true);
|
|
assert_eq!((s.search_match_summary(), s.cursor()), ((Some(1), 3), 8));
|
|
s.search_step(true);
|
|
assert_eq!(s.cursor(), 16);
|
|
s.search_step(true); // wraps to the first match
|
|
assert_eq!(s.cursor(), 0);
|
|
s.search_step(false); // backward wraps to the last
|
|
assert_eq!(s.cursor(), 16);
|
|
}
|
|
|
|
#[test]
|
|
fn search_focuses_first_match_at_or_after_origin() {
|
|
let mut s = from_bytes(b"foo bar foo");
|
|
s.active_window_mut().cursor = 5; // inside "bar"
|
|
s.search_begin(true, false);
|
|
type_query(&mut s, "foo");
|
|
// First match with start >= 5 is the one at byte 8.
|
|
assert_eq!(s.cursor(), 8);
|
|
assert_eq!(s.search_match_summary(), (Some(1), 2));
|
|
}
|
|
|
|
#[test]
|
|
fn search_cancel_restores_origin_and_clears_store() {
|
|
let mut s = from_bytes(b"foo bar foo");
|
|
let bid = s.active_buffer_id();
|
|
s.active_window_mut().cursor = 5;
|
|
s.search_begin(true, false);
|
|
type_query(&mut s, "foo");
|
|
assert_eq!(s.cursor(), 8);
|
|
s.search_finish(false); // cancel
|
|
assert!(!s.search_active());
|
|
assert_eq!(s.cursor(), 5, "cancel restores the pre-search cursor");
|
|
assert!(
|
|
s.search_store
|
|
.lock()
|
|
.expect("store")
|
|
.for_buffer(bid)
|
|
.is_none(),
|
|
"cancel clears the matches"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn search_accept_keeps_cursor_and_matches() {
|
|
let mut s = from_bytes(b"foo bar foo");
|
|
let bid = s.active_buffer_id();
|
|
s.active_window_mut().cursor = 0;
|
|
s.search_begin(true, false);
|
|
type_query(&mut s, "foo");
|
|
s.search_step(true); // focus the match at byte 8
|
|
assert_eq!(s.cursor(), 8);
|
|
s.search_finish(true); // accept
|
|
assert!(!s.search_active());
|
|
assert_eq!(s.cursor(), 8, "accept keeps the cursor on the match");
|
|
assert!(
|
|
s.search_store
|
|
.lock()
|
|
.expect("store")
|
|
.for_buffer(bid)
|
|
.is_some(),
|
|
"accept keeps matches for highlight + navigation"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn search_backspace_widens_the_match_set() {
|
|
let mut s = from_bytes(b"fo foo food");
|
|
s.active_window_mut().cursor = 0;
|
|
s.search_begin(true, false);
|
|
type_query(&mut s, "foo"); // matches "foo" at 3..6, 7..10
|
|
assert_eq!(s.search_match_summary().1, 2);
|
|
s.search_backspace(); // query "fo"
|
|
assert_eq!(s.search_query(), "fo");
|
|
assert_eq!(s.search_match_summary().1, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn search_smart_case_is_case_sensitive_with_uppercase() {
|
|
let mut s = from_bytes(b"Foo foo FOO");
|
|
s.active_window_mut().cursor = 0;
|
|
s.search_begin(true, false);
|
|
type_query(&mut s, "Foo"); // uppercase => case-sensitive
|
|
assert_eq!(s.search_match_summary().1, 1);
|
|
s.search_backspace();
|
|
s.search_backspace();
|
|
s.search_backspace();
|
|
type_query(&mut s, "foo"); // lowercase => smart-case folds all
|
|
assert_eq!(s.search_match_summary().1, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn edit_marks_accepted_matches_stale() {
|
|
let mut s = from_bytes(b"foo foo");
|
|
let bid = s.active_buffer_id();
|
|
s.active_window_mut().cursor = 0;
|
|
s.search_begin(true, false);
|
|
type_query(&mut s, "foo");
|
|
s.search_finish(true); // matches persist after accept
|
|
assert!(!s.search_store.lock().expect("store").is_stale(bid));
|
|
s.insert_char('x'); // any edit invalidates the match offsets
|
|
assert!(
|
|
s.search_store.lock().expect("store").is_stale(bid),
|
|
"an edit marks the buffer's matches stale (linger fix)"
|
|
);
|
|
}
|
|
|
|
// ---- regex search (Q#RX3) ------------------------------------------
|
|
|
|
#[test]
|
|
fn regex_search_matches_pattern() {
|
|
let mut s = from_bytes(b"a1 b2 c3");
|
|
s.active_window_mut().cursor = 0;
|
|
s.search_begin(true, true);
|
|
assert!(s.search_is_regex());
|
|
type_query(&mut s, r"\d");
|
|
assert_eq!(s.search_match_summary().1, 3, "\\d matches 1, 2, 3");
|
|
assert!(!s.search_is_invalid());
|
|
}
|
|
|
|
#[test]
|
|
fn regex_invalid_pattern_flags_and_recovers() {
|
|
let mut s = from_bytes(b"foo");
|
|
s.active_window_mut().cursor = 0;
|
|
s.search_begin(true, true);
|
|
type_query(&mut s, "fo("); // unbalanced group mid-typing
|
|
assert!(s.search_is_invalid(), "incomplete group is invalid");
|
|
assert_eq!(s.search_match_summary().1, 0, "invalid ⇒ no matches");
|
|
type_query(&mut s, "o)"); // completes the group: regex fo(o) → "foo"
|
|
assert!(!s.search_is_invalid(), "valid pattern recovers");
|
|
assert_eq!(s.search_match_summary().1, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_regex_reinterprets_the_query() {
|
|
let mut s = from_bytes(b"a.b axb");
|
|
s.active_window_mut().cursor = 0;
|
|
s.search_begin(true, false); // literal
|
|
type_query(&mut s, "a.b");
|
|
assert!(!s.search_is_regex());
|
|
assert_eq!(
|
|
s.search_match_summary().1,
|
|
1,
|
|
"literal '.' matches only a.b"
|
|
);
|
|
s.search_toggle_regex(); // → regex
|
|
assert!(s.search_is_regex());
|
|
assert_eq!(s.search_match_summary().1, 2, "regex '.' also matches axb");
|
|
s.search_toggle_regex(); // back to literal
|
|
assert!(!s.search_is_regex());
|
|
assert_eq!(s.search_match_summary().1, 1);
|
|
}
|
|
|
|
// ---- in-buffer completion popup (Arc 1a) --------------------------------
|
|
|
|
fn text_of(s: &EditorCore) -> String {
|
|
let id = s.active_buffer_id();
|
|
let reg = s.registry.borrow();
|
|
let buf = reg.get(id).expect("active buffer present");
|
|
let mut bytes = vec![0u8; buf.len() as usize];
|
|
if !bytes.is_empty() {
|
|
buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
|
|
}
|
|
String::from_utf8(bytes).expect("test buffers are UTF-8")
|
|
}
|
|
|
|
fn open_popup(s: &mut EditorCore, anchor: u64, prefix: &str, insert_text: &str) {
|
|
let state = crate::completion::CompletionPopupState::new(
|
|
s.active_buffer_id(),
|
|
anchor,
|
|
prefix.to_owned(),
|
|
vec![crate::completion::PopupCandidate {
|
|
label: insert_text.to_owned(),
|
|
kind: crate::completion::CompletionItemKind::Text,
|
|
detail: None,
|
|
insert_text: insert_text.to_owned(),
|
|
}],
|
|
1,
|
|
)
|
|
.expect("non-empty candidate list");
|
|
s.completion_popup_open(state);
|
|
}
|
|
|
|
#[test]
|
|
fn completion_popup_open_attaches_self_suppressing_overlay() {
|
|
let mut s = from_bytes(b"he\n");
|
|
s.active_window_mut().cursor = 2;
|
|
open_popup(&mut s, 0, "he", "hello");
|
|
assert!(s.completion_popup_is_open());
|
|
assert!(
|
|
s.active_window()
|
|
.overlay_kinds()
|
|
.contains(&"completion-popup")
|
|
);
|
|
// Re-opening dedups the overlay by kind.
|
|
open_popup(&mut s, 0, "he", "hello");
|
|
let kinds = s.active_window().overlay_kinds();
|
|
assert_eq!(
|
|
kinds.iter().filter(|k| **k == "completion-popup").count(),
|
|
1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn completion_popup_validate_survives_word_growth_and_empty_prefix() {
|
|
let mut s = from_bytes(b"he world\n");
|
|
s.active_window_mut().cursor = 2;
|
|
open_popup(&mut s, 0, "he", "hello");
|
|
s.completion_popup_validate();
|
|
assert!(s.completion_popup_is_open(), "prefix `he` holds");
|
|
// Typing extends the word: still valid.
|
|
s.insert_char('l');
|
|
s.completion_popup_validate();
|
|
assert!(s.completion_popup_is_open(), "prefix `hel` holds");
|
|
// Trigger-char shape (cursor == anchor, empty prefix) holds too.
|
|
s.completion_popup_close();
|
|
s.active_window_mut().cursor = 2;
|
|
open_popup(&mut s, 2, "", "llo");
|
|
s.completion_popup_validate();
|
|
assert!(s.completion_popup_is_open(), "empty prefix at anchor holds");
|
|
}
|
|
|
|
#[test]
|
|
fn completion_popup_validate_closes_when_invariant_breaks() {
|
|
let mut s = from_bytes(b"he world\n");
|
|
s.active_window_mut().cursor = 2;
|
|
open_popup(&mut s, 0, "he", "hello");
|
|
// Cursor moved past the word: `[anchor..cursor]` spans a space.
|
|
s.active_window_mut().cursor = 4;
|
|
s.completion_popup_validate();
|
|
assert!(!s.completion_popup_is_open(), "non-word bytes close it");
|
|
|
|
// Cursor moved before the anchor.
|
|
s.active_window_mut().cursor = 2;
|
|
open_popup(&mut s, 2, "", "x");
|
|
s.active_window_mut().cursor = 1;
|
|
s.completion_popup_validate();
|
|
assert!(!s.completion_popup_is_open(), "cursor < anchor closes it");
|
|
|
|
// Session bound to a buffer that is not the active one.
|
|
let other = s.registry.borrow_mut().create("*other*");
|
|
let state = crate::completion::CompletionPopupState::new(
|
|
other,
|
|
0,
|
|
String::new(),
|
|
vec![crate::completion::PopupCandidate {
|
|
label: "x".into(),
|
|
kind: crate::completion::CompletionItemKind::Text,
|
|
detail: None,
|
|
insert_text: "x".into(),
|
|
}],
|
|
1,
|
|
)
|
|
.unwrap();
|
|
s.completion_popup_open(state);
|
|
s.completion_popup_validate();
|
|
assert!(!s.completion_popup_is_open(), "wrong buffer closes it");
|
|
}
|
|
|
|
#[test]
|
|
fn completion_popup_accept_replaces_prefix_as_one_undo_step() {
|
|
let mut s = from_bytes(b"he and more\n");
|
|
s.active_window_mut().cursor = 2;
|
|
open_popup(&mut s, 0, "he", "hello_world");
|
|
assert!(s.completion_popup_accept());
|
|
assert_eq!(text_of(&s), "hello_world and more\n");
|
|
assert_eq!(s.active_window().cursor, 11);
|
|
assert!(!s.completion_popup_is_open(), "accept closes the popup");
|
|
// Q#C7: the replace is a single edit — one undo restores the
|
|
// original text (not an intermediate delete-then-insert state).
|
|
s.undo();
|
|
assert_eq!(text_of(&s), "he and more\n");
|
|
}
|
|
|
|
#[test]
|
|
fn completion_popup_accept_empty_prefix_inserts_at_anchor() {
|
|
let mut s = from_bytes(b"x.\n");
|
|
s.active_window_mut().cursor = 2;
|
|
open_popup(&mut s, 2, "", "method");
|
|
assert!(s.completion_popup_accept());
|
|
assert_eq!(text_of(&s), "x.method\n");
|
|
assert_eq!(s.active_window().cursor, 8);
|
|
}
|
|
|
|
#[test]
|
|
fn completion_popup_accept_is_noop_when_session_stale() {
|
|
let mut s = from_bytes(b"he world\n");
|
|
s.active_window_mut().cursor = 2;
|
|
open_popup(&mut s, 0, "he", "hello");
|
|
// Simulate a race: the cursor left the word before accept ran.
|
|
s.active_window_mut().cursor = 5;
|
|
assert!(!s.completion_popup_accept());
|
|
assert_eq!(text_of(&s), "he world\n", "buffer untouched");
|
|
assert!(!s.completion_popup_is_open(), "stale accept still closes");
|
|
}
|
|
|
|
#[test]
|
|
fn completion_popup_validate_closes_on_window_focus_change() {
|
|
// Two splits on the SAME buffer: the session is window-scoped,
|
|
// so moving focus (buffer unchanged!) must invalidate it ---
|
|
// this is also what keeps the persistent overlay in the other
|
|
// split from painting a popup it doesn't own.
|
|
let mut s = from_bytes(b"he world\n");
|
|
s.split_active(Orientation::Horizontal, true);
|
|
s.active_window_mut().cursor = 2;
|
|
open_popup(&mut s, 0, "he", "hello");
|
|
s.completion_popup_validate();
|
|
assert!(s.completion_popup_is_open(), "session holds in its window");
|
|
s.focus_next();
|
|
s.completion_popup_validate();
|
|
assert!(
|
|
!s.completion_popup_is_open(),
|
|
"focus change closes the session even with the same buffer"
|
|
);
|
|
}
|
|
|
|
// ---- query-replace core (Arc 2) ----------------------------------------
|
|
|
|
#[test]
|
|
fn query_replace_all_replaces_and_counts() {
|
|
let mut s = from_bytes(b"foo foo foo\n");
|
|
s.query_replace_begin("foo".into(), "bar".into(), false);
|
|
assert!(s.query_replace_active(), "session opens on the first match");
|
|
s.query_replace_all();
|
|
assert_eq!(text_of(&s), "bar bar bar\n");
|
|
assert!(!s.query_replace_active(), "! finishes the session");
|
|
assert_eq!(s.status, "Replaced 3 occurrences");
|
|
}
|
|
|
|
#[test]
|
|
fn query_replace_growing_replacement_does_not_loop() {
|
|
// The a→aa shape: replacing must not re-match the inserted text.
|
|
let mut s = from_bytes(b"a a a\n");
|
|
s.query_replace_begin("a".into(), "aa".into(), false);
|
|
s.query_replace_all();
|
|
assert_eq!(text_of(&s), "aa aa aa\n", "each 'a' replaced exactly once");
|
|
assert_eq!(s.status, "Replaced 3 occurrences");
|
|
}
|
|
|
|
#[test]
|
|
fn query_replace_empty_to_deletes() {
|
|
let mut s = from_bytes(b"a-b-c\n");
|
|
s.query_replace_begin("-".into(), String::new(), false);
|
|
s.query_replace_all();
|
|
assert_eq!(text_of(&s), "abc\n", "empty replacement deletes matches");
|
|
}
|
|
|
|
#[test]
|
|
fn query_replace_skip_then_replace_is_selective() {
|
|
let mut s = from_bytes(b"x x x\n");
|
|
s.query_replace_begin("x".into(), "y".into(), false);
|
|
s.query_replace_skip(); // leave the first x
|
|
s.query_replace_replace(); // replace the second x, advance to third
|
|
s.query_replace_replace_and_quit(); // replace the third, quit
|
|
assert_eq!(text_of(&s), "x y y\n", "first skipped, rest replaced");
|
|
assert!(!s.query_replace_active());
|
|
}
|
|
|
|
#[test]
|
|
fn query_replace_nothing_matched_restores_origin() {
|
|
let mut s = from_bytes(b"hello world\n");
|
|
s.active_window_mut().cursor = 6; // on "world"
|
|
s.query_replace_begin("zzz".into(), "q".into(), false);
|
|
assert!(
|
|
!s.query_replace_active(),
|
|
"no match → session never stays open"
|
|
);
|
|
assert_eq!(text_of(&s), "hello world\n", "buffer untouched");
|
|
assert_eq!(s.active_window().cursor, 6, "origin cursor restored");
|
|
assert_eq!(s.status, "No matches for 'zzz'");
|
|
}
|
|
|
|
#[test]
|
|
fn query_replace_starts_from_cursor_forward() {
|
|
let mut s = from_bytes(b"k _ k\n");
|
|
s.active_window_mut().cursor = 2; // between the two k's
|
|
s.query_replace_begin("k".into(), "K".into(), false);
|
|
s.query_replace_all();
|
|
assert_eq!(text_of(&s), "k _ K\n", "only the match at/after point");
|
|
}
|
|
|
|
#[test]
|
|
fn query_replace_aborts_when_active_buffer_changes() {
|
|
// The wrong-buffer merge-blocker: a session started in buffer X
|
|
// must never apply its match to a buffer that became active
|
|
// mid-session. Focus drifts (a click / cross-frontend key), then
|
|
// the next replace key aborts safely instead of corrupting.
|
|
let mut s = from_bytes(b"foo foo\n");
|
|
let x = s.active_buffer_id();
|
|
s.query_replace_begin("foo".into(), "bar".into(), false);
|
|
assert!(s.query_replace_active());
|
|
|
|
// Switch the active buffer to an unrelated one (focus drift).
|
|
let y = s.registry.borrow_mut().create("*other*");
|
|
{
|
|
let reg = s.registry.borrow();
|
|
let buf = reg.get(y).unwrap();
|
|
let tv = crate::text_view::TextView::new(buf);
|
|
drop(reg);
|
|
let win = s.active_window_mut();
|
|
win.buffer_id = y;
|
|
win.text_view = tv;
|
|
win.cursor = 0;
|
|
}
|
|
assert_eq!(s.active_buffer_id(), y);
|
|
|
|
s.query_replace_replace(); // the y/replace key while drifted
|
|
assert!(!s.query_replace_active(), "drift aborts the session");
|
|
assert_eq!(s.status, "query-replace aborted: active buffer changed");
|
|
// Neither buffer was mutated by the aborted replace.
|
|
{
|
|
let reg = s.registry.borrow();
|
|
let bx = reg.get(x).unwrap();
|
|
let mut xb = vec![0u8; bx.len() as usize];
|
|
bx.snapshot_rope().slice(0, bx.len(), &mut xb);
|
|
assert_eq!(&xb, b"foo foo\n", "origin buffer X untouched");
|
|
let by = reg.get(y).unwrap();
|
|
assert_eq!(by.len(), 0, "unrelated buffer Y untouched");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn query_replace_regex_replaces_and_invalid_refuses() {
|
|
let mut s = from_bytes(b"a1 b2 c3\n");
|
|
s.query_replace_begin("[0-9]".into(), "#".into(), true);
|
|
s.query_replace_all();
|
|
assert_eq!(text_of(&s), "a# b# c#\n", "regex matches digits");
|
|
|
|
// Invalid regex refuses to start and leaves a status.
|
|
let mut s2 = from_bytes(b"abc\n");
|
|
s2.query_replace_begin("(unclosed".into(), "x".into(), true);
|
|
assert!(
|
|
!s2.query_replace_active(),
|
|
"invalid regex never opens a session"
|
|
);
|
|
assert!(s2.status.starts_with("Invalid regex"));
|
|
}
|
|
}
|