// 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>` 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, } /// 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, /// 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, /// 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`); 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, /// 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, /// 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)>, /// Open context menu (Q#CM1), or `None` when closed. Shared /// `Arc` 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` 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, } 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(), } } /// 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, 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 { 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 { 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) { 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) { 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) { 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; } /// 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 { 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 { 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 = 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 { 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 { 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> { 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 { 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 { 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)> { 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, 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 { 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 { 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 = 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" ); } }