// 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, LayoutNode, MAX_PANEL_QUIT_DEPTH, MIN_WINDOW_OUTER_ROWS, Orientation, QuitAction, Side, Window, WindowId, subtree_min_rows, }; /// 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, } /// One recorded jump origin (bottom-panel arc, Q#BP11c). /// /// `window_id` and `side_origin` are what make `M-,` correct once a panel /// can be a separate window: restoring into the recorded window keeps the /// document window untouched, and a *side* origin that no longer /// revalidates is **skipped** rather than degrading to an active-window /// switch — that degradation is exactly the duplicate-panel corruption /// this design removes. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct JumpEntry { /// Window the origin was recorded in. pub window_id: WindowId, /// Buffer displayed there at the time. pub buffer_id: BufferId, /// Cursor position to restore. pub position: Position, /// Whether `window_id` was a side window when recorded. pub side_origin: bool, } /// Which lifecycle hook Phase 2 of the display transaction must fire /// **with the target window active** (Q#BP4 / Q#BP11b). #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum HookKind { /// `buffer.after-switch` — a reuse, including a same-buffer no-op. AfterSwitch, /// `buffer.after-load` — a fresh load. saveplace, recentf, syntax /// and LSP all require the document target to be active for this. AfterLoad, /// Nothing to fire (a newly created path-backed buffer for a /// `NotFound` path, matching initial-target / local-startup). None, } /// A `display_buffer` request (Q#BP3). /// /// `height` and `dedicated` are deliberately option-valued at the policy /// boundary: omission is **not** silently equivalent to an explicit /// zero/false, which is what lets a user-resized panel keep its height as /// compile and listview replace one another. #[derive(Clone, Debug)] pub struct DisplayRequest { /// Buffer to display. pub buffer_id: BufferId, /// Exact target window. Mutually exclusive with `side`. pub window: Option, /// Requested side. Mutually exclusive with `window`. pub side: Option, /// Explicit requested outer rows for a side placement. pub height: Option, /// Explicit dedication for the installed presentation. pub dedicated: Option, /// Explicit final-focus request. Omission defaults to `false` for an /// actual side target and `true` for an ordinary one; an explicit /// value survives fallback unchanged. pub select: Option, /// The caller's resolved `window.panel-height`, used only when a side /// slot is **created** with no explicit `height`. pub default_panel_rows: u32, } impl DisplayRequest { /// A bare ordinary-placement request for `buffer_id`. #[must_use] pub fn new(buffer_id: BufferId) -> Self { Self { buffer_id, window: None, side: None, height: None, dedicated: None, select: None, default_panel_rows: crate::window::DEFAULT_PANEL_ROWS, } } } /// What Phase 1 of the display transaction decided (Q#BP4). #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct DisplayOutcome { /// Window the buffer was installed in. pub target: WindowId, /// The frontend's focused window before Phase 1 ran. pub saved_active: WindowId, /// Resolved final-focus request. pub select: bool, /// Whether this call created the side window — the adopter rollback /// hook (a terminal whose session fails to start must remove the /// wrapper it just created). pub created_side: bool, } /// What [`EditorCore::reconcile_panel_layout_core`] resolved (Q#BP2b). #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub struct PanelReconciliation { /// The panel's effective visibility after the transaction. pub hidden: bool, /// Whether `hidden` changed in this transaction — Stage 2 keys its /// authoritative `PanelFrame::Absent` / fresh `Present` on this. pub changed: bool, /// A side window whose terminal controller the caller must release, /// because focus just left an invisible panel. pub released_terminal: Option, } /// Row extent of an arbitrary subtree, derived from its leaves' computed /// rects: leaves tile their parent, so the union's height is the node's. fn node_row_extent(node: &LayoutNode, placements: &HashMap) -> u32 { let ids = crate::window::node_ids(node); let mut lo = u32::MAX; let mut hi = 0u32; for id in ids { let Some(rect) = placements.get(&id) else { continue; }; lo = lo.min(rect.origin.row); hi = hi.max(rect.origin.row + rect.size.rows); } if lo == u32::MAX { 0 } else { hi - lo } } /// What Phase 1 of `window.quit` did (Q#BP2c). #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum QuitOutcome { /// The side window was closed and its wrapper collapsed. Deleted { /// Where focus landed, when the frontend still has a view. focus: Option, }, /// A saved presentation was reinstalled; Phase 2 must fire the /// ordinary switch hook so overlays reattach. Restored { /// The window that was restored. target: WindowId, /// The buffer now displayed there. buffer_id: BufferId, }, } #[derive(Copy, Clone, Debug)] struct Placement { target: WindowId, kind: PlacementKind, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] enum PlacementKind { Ordinary, Side { created: bool, replacing: bool }, } /// Live state of an in-progress incremental search (Q#SR5). /// /// Present only while an isearch is running (`EditorCore::search`); /// `None` otherwise. Holds the query as typed so far plus the cursor /// origin to restore on cancel. The *matches* themselves live in /// [`crate::search::SearchStore`] (shared with the decorations /// producer and the TUI overlay); this struct is the per-session /// input state that drives `find_all`. #[derive(Clone, Debug)] pub struct SearchSession { /// The query as typed so far. Each edit re-runs `find_all`. query: String, /// Buffer + cursor position when the search began. `C-g` / `Esc` /// restores this; `RET` keeps the current (match) cursor. The /// buffer id also anchors `find_all` to the buffer the search /// started in. origin: (BufferId, Position), /// Direction of the most recent step/begin. `true` = forward. /// Drives the prompt label ("I-search" vs "I-search backward") /// and the wrap direction of an empty-query repeat. forward: bool, /// Whether the query is a regex (Q#RX3). `false` = smart-case /// substring (`find_all`); `true` = smart-case regex /// (`find_all_regex`). Toggled live by `M-r`. regex: bool, /// `true` when the last recompute's regex pattern failed to compile /// — the prompt shows `[invalid]` instead of a match count. Always /// `false` in literal mode (substring never "fails to compile"). invalid: bool, } /// Live state of an in-progress query-replace (Arc 2, Q#QR1). /// /// Present only while a query-replace's interactive phase is running /// (`EditorCore::query_replace`); `None` otherwise. Unlike /// [`SearchSession`], the buffer is usually already mutated by the /// time this ends, so `origin` is used *only* for the nothing-matched /// restore (Q#QR10); every other exit leaves point at the inspected /// match. Matching runs forward from `next_from` on the *live* buffer /// (Q#QR2), so offset shifts and never-re-matching-replacements fall /// out for free. #[derive(Clone, Debug)] pub struct QueryReplaceSession { /// The literal substring or regex source being replaced. from: String, /// The replacement text (may be empty — Q#QR3 deletion). to: String, /// Compiled regex engine when in regex mode (Q#QR9), cached for /// the whole run so `!` stays linear; `None` = smart-case literal. re: Option, /// Buffer + cursor when the session began. Restored on cancel /// *only* when nothing ever matched (Q#QR10). origin: (BufferId, Position), /// Byte offset the next forward search starts from — advanced past /// each replacement so inserted text is never re-matched. next_from: Position, /// The match currently being prompted, or `None` before the first /// advance / after finishing. current: Option, /// Number of replacements applied so far. replaced: usize, /// Whether any match was ever found (distinguishes "nothing /// matched → restore origin" from "matched, then quit"). found_any: bool, } /// One frontend's command boundary — Emacs's `this-command` / /// `last-command` pair (kill ring, Q#KR2). /// /// `this` is the command currently (or most recently) executing for the /// frontend; `last` is the one before it. A chain-sensitive command /// (kill append, `M-y`) reads `last` *while it runs* — its own rotation /// already moved its predecessor there. `this = None` is a broken /// chain: some non-command input (an optimistic CRDT edit, a pointer /// gesture, a paste, an unbound key) intervened, so the next rotation /// makes `last = None` and every chain check fails. #[derive(Debug, Clone, Default)] pub struct CommandBoundary { /// The command executing now / most recently, or `None` after a /// non-command input. pub this: Option, /// The command before `this`. pub last: Option, } /// Exact provenance of one typed self-insert (auto-pairing Q#AP9). /// /// `this_command() == "buffer.self-insert"` proves only the *input /// class*; it cannot say which character was typed, where the edit /// actually landed after intercepts, or whether the command that ran /// under that name performed the insert at all. This record carries /// the exact facts for the one consumer contract that needs them (the /// pairing hook): the decoded codepoint, the requested and effective /// ranges, and the post-edit cursor, plus a `clean` verdict (effective /// triple equals the request). It is ephemeral — armed by the two /// self-insert producers (dispatch fallback, optimistic CRDT arm) for /// exactly one `buffer.after-edit` fan-out, consumable once via /// `pmacs.editor.take_typed_edit()`, and cleared when the fan-out /// returns. Paste, programmatic mutation, manual hook runs, and a /// stale `this_command` therefore observe nil, not a leftover record. #[derive(Debug, Clone)] pub struct TypedEditRecord { /// Buffer the self-insert landed in. pub buffer: BufferId, /// Window that was active when the self-insert ran. pub window: WindowId, /// The exact typed codepoint (payload immutability makes this /// authoritative even when an intercept relocated the edit). pub codepoint: char, /// Requested edit range: `start == end` for a plain insert; a CUA /// type-over requests a `Replace` over the consumed region. pub requested_start: u64, /// End of the requested range (see `requested_start`). pub requested_end: u64, /// Effective (post-intercept) range start in the old rope. pub effective_start: u64, /// Effective (post-intercept) range end in the old rope. pub effective_end: u64, /// Bytes actually inserted at `effective_start`. pub inserted_len: u64, /// The window cursor immediately after the self-insert. pub post_cursor: u64, /// True iff the effective triple equals the request. pub clean: bool, /// The edited buffer's revision immediately after the completing /// edit — a producer-side postcondition, not consumer surface (it /// is not exposed on the Lua record). `typed_edit_finish` drops /// the record when the buffer's revision has moved past this: a /// redefined `buffer.self-insert` that edits again after the /// insert (removing or replacing the typed character) must not /// leave a stale-but-"clean" record for the pairing hook (PR #110 /// round 1, finding 1). pub revision: u64, } /// In-flight arm for a [`TypedEditRecord`] (auto-pairing Q#AP9): the /// dispatch fallback declares "the next matching self-insert edit is /// the typed one" before invoking `buffer.self-insert`; the insert /// primitives complete the record when the edit lands. Private — /// nothing outside the arm/complete/finish trio observes the pending /// state. #[derive(Debug)] struct TypedEditPending { /// Frontend whose dispatch armed this. fid: FrontendId, /// The codepoint the dispatcher decoded from the keystroke; a /// completing edit must match it exactly. codepoint: char, /// Filled by the first matching insert primitive. record: Option, } /// 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, /// Per-buffer fold stores (Arc 6). Shared with `EditorState`, the /// semantic `FoldState` producer, and the `pmacs.fold` Lua surface /// — the same `Rc`. The core reaches it so the six point-anchored /// edit primitives can run the dispatch-layer pre-edit unfold /// (Q#FD5): a command-path self-insert/delete at a point inside a /// fold unfolds it before the edit applies. pub fold_registry: crate::fold::SharedFoldRegistry, /// 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). /// /// **Per frontend** (bottom-panel arc, Q#BP11c), matching /// `command_history`. Once a panel is a separate window, an entry /// must remember *which window* it was recorded in — otherwise `M-,` /// from a source file would switch the **document** window to the /// panel's buffer while the panel stays open, duplicating the /// presentation. Keying the whole ring by frontend additionally /// stops one frontend consuming or destroying another's navigation /// trail; detach purges the vector. pub jump_ring: HashMap>, /// 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, /// Shared theme handle (themes arc Q#TH9), injected once at editor /// bring-up right after `SyntaxRegistry` construction — the core /// owns no syntax state, but `ensure_search_overlay` constructs /// `SearchView`s that resolve wash faces through it. A bare core /// (unit-test construction) carries `None` and paints today's /// literals. pub theme: Option, /// 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)>, /// Per-frontend command boundaries (kill ring, Q#KR2) — Emacs's /// `this-command` / `last-command`, tracked **per frontend**: two /// attached frontends interleave their own command streams, and a /// kill chain or yank session on frontend A must not survive into /// frontend B's checks. Every input path updates this — commands /// rotate; non-command inputs (optimistic CRDT edits, pointer /// gestures, pastes, unbound keys) break the chain. Entries are /// pruned on `SessionDetached` (Q#KR11). pub command_history: HashMap, /// 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, /// Live query-replace interactive session (Arc 2), or `None`. The /// query-replace twin of `search`; drives the fifth dispatcher /// shadow. query_replace: Option, /// In-flight typed-edit arm (auto-pairing Q#AP9): set by the /// dispatch fallback just before it invokes `buffer.self-insert`, /// completed by the insert primitives, taken back by the /// dispatcher via [`Self::typed_edit_finish`] in the same /// dispatch. Never survives a dispatch cycle. typed_edit_pending: Option, /// The armed typed-edit record (auto-pairing Q#AP9), exposed to /// Lua as `pmacs.editor.take_typed_edit()` for the duration of /// exactly one `buffer.after-edit` fan-out. Keyed by frontend so /// two attached frontends can never see or consume each other's /// slot; the producer clears any untaken record when the fan-out /// returns. typed_edit_armed: Option<(FrontendId, TypedEditRecord)>, } 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, // LOCAL is the in-process grid editor (Q#FD21). fold_projection: true, // …and it renders side windows natively (Q#BP13). panel_capable: true, // Real geometry arrives with the first render/resize. frame_geometry: None, panel_hidden: false, }, ); Self { registry, fold_registry: crate::fold::make_shared_fold_registry(), windows, views, status: String::new(), quit: false, minibuffer: Minibuffer::new(), active_frontend: FrontendId::LOCAL, pending_crdt_ops: Vec::new(), jump_ring: HashMap::new(), search_store: crate::search::make_shared_store(), theme: None, search: None, clipboard_slot: Vec::new(), pending_clipboard: None, command_history: HashMap::new(), menu: crate::menu::make_shared_menu(), completion_popup: crate::completion::make_shared_popup(), round_trip_buffers: std::collections::HashSet::new(), query_replace: None, typed_edit_pending: None, typed_edit_armed: None, } } /// Build a core from raw bytes under `name`. Used by tests. /// Replaces the scratch buffer's content; the active window is /// retained. #[must_use] pub fn from_bytes(registry: SharedRegistry, name: impl Into, 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) } /// Whether the **acting** frontend's display collapses folds (Arc 6 /// Stage 2, Q#FD21). /// /// The gate on every command/event-time visible-line reckoning — /// motion, paging, wheel, the click inverse, the auto-scroll clamp. /// A `semantic_render` (GPU) session still displays every source /// line until Stage 3, so with this `false` those sites keep their /// raw-line behavior and its cursor never skips a line it is showing /// (even while a grid session folds the same shared buffer). #[must_use] pub fn fold_projection_active(&self) -> bool { self.active_view().fold_projection } /// The visible-line map for `win_id`'s buffer, or `None` when the /// acting frontend does not project folds, `win_id` is unknown, or /// that buffer has no folds (Q#FD12). /// /// The two axes are deliberately separate (round-3 F1): the **acting /// frontend** supplies the projection policy, while the /// **operation's target window** supplies the buffer and line /// offsets. Motion, paging, and auto-scroll target the active /// window; the click inverse and wheel scrolling name an explicit /// `win_id` — a wheel event over an inactive pane does not activate /// it, so deriving the active window's map there would project one /// buffer's folds onto another. #[must_use] pub fn fold_map_for_window( &self, win_id: WindowId, ) -> Option { if !self.fold_projection_active() { return None; } let window = self.windows.get(&win_id)?; crate::fold_view::map_for_window(&self.fold_registry, window) } /// [`Self::fold_map_for_window`] for the active window — the target /// of motion, paging, and the auto-scroll clamp. #[must_use] pub fn fold_map_active(&self) -> Option { self.fold_map_for_window(self.active_window_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); // Bottom-panel arc (Q#BP11c): a detached frontend's navigation // trail dies with its view — its `WindowId`s are gone, and no // other frontend may pop or destroy those entries. self.jump_ring.remove(&fid); if self.active_frontend == fid { self.active_frontend = FrontendId::LOCAL; } } /// [`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); } } /// Find the buffer already showing `path`, or load it fresh from /// disk into a new buffer — **without** switching the active window /// (Arc 3 desktop-restore builds its windows explicitly). Returns /// `(id, newly_loaded)`; `newly_loaded` is `false` on a dedup hit /// (the same file in two split panes) so the caller fires /// `buffer.after-load` at most once per buffer. /// /// # Errors /// Propagates a load failure (e.g. a since-deleted file) so restore /// can skip that leaf rather than abort. pub fn get_or_load_buffer(&mut self, path: &Path) -> std::io::Result<(BufferId, bool)> { if let Some(id) = self.find_buffer_for_path(path) { return Ok((id, false)); } let normalized = normalize_buffer_path(path.to_path_buf()); let (bytes, meta) = crate::file_io::load_file(path)?; let display_name = path.display().to_string(); let id = self .registry .borrow_mut() .create_from_bytes(display_name, &bytes); self.set_buffer_path(id, Some(normalized)); self.set_buffer_meta(id, Some(meta)); Ok((id, true)) } /// The buffer already bound to `path`, under the same normalization /// [`Self::get_or_load_buffer`] uses — **side-effect free**, so a /// target-aware display can resolve its destination *before* any I/O /// (Q#BP11b step 1: an ineligible destination must fail without /// loading the file). #[must_use] pub fn find_buffer_for_path(&self, path: &Path) -> Option { let normalized = normalize_buffer_path(path.to_path_buf()); self.registry.borrow().find_by_path(&normalized) } /// The shared resolve/load-without-switch primitive behind both /// `pmacs.window.display_file` and the daemon's initial-target /// bootstrap (Q#BP11b). /// /// Returns the buffer plus the hook Phase 2 must fire **with the /// destination window active**: `AfterSwitch` for a dedup hit /// (including a same-buffer no-op), `AfterLoad` for a fresh load, and /// `None` for a path that does not exist yet — a `NotFound` path /// becomes an empty path-backed buffer and fires nothing, matching /// the initial-target and local-startup contract. /// /// One primitive, so two path-normalization, dedup, and hook /// transactions cannot drift apart. /// /// # Errors /// Any load failure other than `NotFound`. pub fn resolve_target_buffer(&mut self, path: &Path) -> Result<(BufferId, HookKind), String> { match self.get_or_load_buffer(path) { Ok((buffer_id, true)) => Ok((buffer_id, HookKind::AfterLoad)), Ok((buffer_id, false)) => Ok((buffer_id, HookKind::AfterSwitch)), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { let display_path = path.display().to_string(); let buffer_id = self.registry.borrow_mut().create(display_path); self.set_buffer_path(buffer_id, Some(path.to_path_buf())); "[new file]".clone_into(&mut self.status); Ok((buffer_id, HookKind::None)) } Err(error) => Err(format!("cannot open {}: {error}", path.display())), } } /// 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 } /// Set the active window's cursor to a byte offset, clamped to the /// buffer extent (Arc 3 Q#PS1 — saveplace/desktop restore). Resets /// the goal column. Since `switch_active_buffer` zeroes the cursor, /// restore calls this *after* the open/switch. pub fn set_cursor_byte(&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; } /// Set the active window's `view_top` (first visible source line), /// clamped to the buffer's line count (Arc 3 Q#PS1 — desktop /// restore). A file that shrank since the desktop was saved can't /// scroll past its end. /// Arc 6 Stage 2 (Q#FD12/Q#FD18, round-4 F3): `view_top` stays a /// source-line index but must never *name* a hidden line, so a /// fold-projecting frontend also clamps **backward** to the visible /// head here. The render pass repairs a hidden `view_top` too, but /// only at the next frame — until then [`Self::view_top`] would hand /// out a collapsed line and command/event reckoning would start from /// a non-visible origin. This setter is the contract's home (it is /// what `saveplace` and `pmacs.editor.set_view_top` call), so the /// invariant is established here rather than repaired downstream. pub fn set_view_top(&mut self, top: usize) { let lines = self.active_window().text_view.line_count(); let clamped = top.min(lines.saturating_sub(1)); let clamped = self .fold_map_active() .map_or(clamped, |map| map.clamp_view_top(clamped)); self.active_window_mut().view_top = clamped; } /// 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 fid = self.active_frontend; let window_id = self.active_window_id(); let entry = JumpEntry { window_id, buffer_id: self.active_buffer_id(), position: self.cursor(), side_origin: self .windows .get(&window_id) .is_some_and(crate::window::Window::is_side), }; let ring = self.jump_ring.entry(fid).or_default(); if ring.len() >= Self::JUMP_RING_CAP { ring.remove(0); } ring.push(entry); } /// Drop one detached frontend's navigation trail (Q#BP11c). pub fn purge_jump_ring(&mut self, fid: FrontendId) { self.jump_ring.remove(&fid); } /// 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. /// /// # Origin windows (Q#BP11c) /// /// The entry restores into its **origin window** when that window is /// live, belongs to the acting frontend's layout, is not a hidden /// side window, and **still shows the recorded buffer**. A live panel /// that has since been replaced does not resurrect its old buffer. /// /// When revalidation fails the entry degrades differently by origin /// kind. A **non-side** origin falls back to today's active-window /// switch. A **side** origin is *skipped*: switching a panel's buffer /// into the document window is precisely the duplicate-presentation /// corruption this design removes. pub fn jump_back(&mut self) -> bool { let fid = self.active_frontend; loop { let Some(entry) = self.jump_ring.get_mut(&fid).and_then(std::vec::Vec::pop) else { return false; }; if !self.registry.borrow().contains(entry.buffer_id) { continue; } let origin_valid = self .views .get(&fid) .is_some_and(|view| view.layout.iter_ids().contains(&entry.window_id)) && self .windows .get(&entry.window_id) .is_some_and(|window| window.buffer_id == entry.buffer_id) && !self.side_window_is_hidden(fid, entry.window_id); if origin_valid { // Through `focus_window`, not `set_active_window_id`: // returning INTO a panel from a document window is a // focus transition like any other, so it refreshes // `origin_document` and a later `window.quit` returns to // the window the jump came from. self.focus_window(fid, entry.window_id); } else { // A stale SIDE origin is skipped outright: switching a // panel's buffer into the document window is exactly the // duplicate-presentation corruption this design removes. // A stale non-side origin keeps today's active-window // fallback. if entry.side_origin { continue; } if self.active_buffer_id() != entry.buffer_id && self.switch_active_buffer(entry.buffer_id).is_err() { continue; } } let clamped = entry.position.min(self.active_buffer_len()); let aw = self.active_window_mut(); aw.cursor = clamped; aw.goal_col = None; return true; } } /// True when `win` is a side window on `fid` and that frontend's /// panel is currently derived-hidden (Q#BP2b). #[must_use] fn side_window_is_hidden(&self, fid: FrontendId, win: WindowId) -> bool { self.windows .get(&win) .is_some_and(crate::window::Window::is_side) && self.views.get(&fid).is_some_and(|view| view.panel_hidden) } // ---- 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. Stale matches read as absent (Q#AI8 /// fail-closed): the highlights they count are already suppressed, /// so the prompt must not advertise them either. #[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"); if guard.is_stale(bid) { return (None, 0); } 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(); // Themes Q#TH9: pass the injected theme through unconditionally // — a bare core (None) constructs a working unthemed view. let theme = self.theme.clone(); let win = self.active_window_mut(); if !win.overlay_kinds().contains(&"search") { win.push_overlay(Box::new(crate::search::SearchView::new(store, theme))); } } /// Append a character to the query and re-search. pub fn search_input_char(&mut self, ch: char) { let Some(session) = self.search.as_mut() else { return; }; session.query.push(ch); self.search_recompute(); } /// Drop the last character of the query and re-search. With an /// empty query this is a no-op (the search stays open, empty). pub fn search_backspace(&mut self) { let Some(session) = self.search.as_mut() else { return; }; session.query.pop(); self.search_recompute(); } /// Re-run `find_all` for the current query against the origin /// buffer, refresh the store, and move the cursor to the match /// nearest the origin (first match at/after the origin cursor, /// wrapping). An empty query or no match anchors the cursor back /// at the origin so a failing search never drifts the view. fn search_recompute(&mut self) { let Some(session) = self.search.as_ref() else { return; }; let bid = session.origin.0; let origin_byte = session.origin.1; let query = session.query.clone(); let regex = session.regex; let bytes = self.buffer_bytes(bid); // Regex: `None` ⇒ the pattern won't compile (mark invalid, drop // matches). Literal substring never fails. An invalid pattern // clears the store (no stale matches paint) and shows // `[invalid]` via the prompt. let (matches, invalid) = if regex { match crate::search::find_all_regex(&bytes, &query) { Some(m) => (m, false), None => (Vec::new(), true), } } else { (crate::search::find_all(&bytes, &query), false) }; if let Some(session) = self.search.as_mut() { session.invalid = invalid; } let focus = { let mut guard = self .search_store .lock() .expect("search store mutex poisoned"); guard.set(bid, query, matches); guard.focus_from(bid, origin_byte) }; let target = focus.map_or(origin_byte, |range| range.start); self.search_place_cursor(target); } /// Step the active buffer's match focus forward/backward (wrapping) /// and move the cursor to it. Operates on [`Self::search_store`] /// directly, so it works both during a live session (C-s / C-r) /// and after accept (a `search.next` navigation command). A no-op /// when the active buffer has no matches. pub fn search_step(&mut self, forward: bool) { if let Some(session) = self.search.as_mut() { session.forward = forward; } let bid = self.active_buffer_id(); let stepped = { let mut guard = self .search_store .lock() .expect("search store mutex poisoned"); guard.step(bid, forward) }; if let Some(range) = stepped { self.search_place_cursor(range.start); } } /// End the active search. `accept` keeps the cursor at the current /// match and leaves the matches in the store (so they stay /// highlighted, and `search.next` can resume, until the next edit /// marks them stale). Cancel restores the origin cursor and clears /// the matches. A no-op when no search is running. pub fn search_finish(&mut self, accept: bool) { let Some(session) = self.search.take() else { return; }; if accept { return; } let (bid, origin_byte) = session.origin; if self.active_buffer_id() == bid { self.search_place_cursor(origin_byte); } self.search_store .lock() .expect("search store mutex poisoned") .clear(bid); } /// Move the active window's cursor to a byte offset (clamped to the /// buffer extent), resetting the goal column. Shared by the search /// motions. fn search_place_cursor(&mut self, byte: u64) { let clamped = byte.min(self.active_buffer_len()); let aw = self.active_window_mut(); aw.cursor = clamped; aw.goal_col = None; } // ---- query-replace (Arc 2, Q#QR1-10) ----------------------------------- /// True while a query-replace interactive session is running (the /// fifth dispatcher-shadow predicate; also drives `dispatch_idle` /// and the modal-close guard). #[must_use] pub fn query_replace_active(&self) -> bool { self.query_replace.is_some() } /// The buffer a running query-replace is pinned to, or `None`. The /// dispatcher reads this so the `buffer.after-edit` revision compare /// targets the *edited* buffer, not whichever is active. #[must_use] pub fn query_replace_origin_buffer(&self) -> Option { self.query_replace.as_ref().map(|s| s.origin.0) } /// Query-replace's wrong-buffer guard. Every edit and cursor move it /// makes goes through the *active* window/buffer, but the session is /// pinned to the buffer it started in — and focus can drift /// mid-session (a click into another split, a key from another /// frontend). Before touching the buffer, verify the active buffer /// is still the origin buffer; if not, **abort without editing** so /// a match found in the origin buffer can never be applied to an /// unrelated one. Returns `true` when it is safe to proceed. fn query_replace_on_origin(&mut self) -> bool { let Some(origin_bid) = self.query_replace.as_ref().map(|s| s.origin.0) else { return false; }; if self.active_buffer_id() == origin_bid { return true; } // Focus moved off the origin buffer — abort, don't corrupt. if let Some(session) = self.query_replace.take() { self.search_store .lock() .expect("search store mutex poisoned") .clear(session.origin.0); } self.status = "query-replace aborted: active buffer changed".into(); false } /// Begin a query-replace from the cursor forward (Q#QR8). `regex` /// selects `query-replace-regexp` (Q#QR9). An invalid regex refuses /// to start (Q#QR2). Immediately advances to (and prompts on) the /// first match, or finishes with "No matches" when there are none. pub fn query_replace_begin(&mut self, from: String, to: String, regex: bool) { if self.query_replace.is_some() || from.is_empty() { return; } let re = if regex { let Some(re) = crate::search::compile_search_regex(&from) else { self.status = format!("Invalid regex: {from}"); return; }; Some(re) } else { None }; let origin = (self.active_buffer_id(), self.cursor()); // Reuse the isearch match-wash overlay to highlight the current // match in the TUI; the GPU gets it via SearchMatch decorations. self.ensure_search_overlay(); self.query_replace = Some(QueryReplaceSession { from, to, re, origin, next_from: origin.1, current: None, replaced: 0, found_any: false, }); self.query_replace_advance(); } /// Find the next match at/after `next_from` on the live buffer. On /// a hit: highlight it, reveal it (cursor to its start, Q#QR2), and /// prompt. On a miss: finish (natural end / nothing-matched). fn query_replace_advance(&mut self) { let Some(session) = self.query_replace.as_ref() else { return; }; let bid = session.origin.0; let bytes = self.buffer_bytes(bid); let start = (session.next_from as usize).min(bytes.len()); let found = match &session.re { Some(re) => crate::search::find_first_regex_from(&bytes, re, start), None => crate::search::find_first_from(&bytes, &session.from, start), }; let Some(range) = found else { self.query_replace_finish(); return; }; let from = session.from.clone(); if let Some(session) = self.query_replace.as_mut() { session.current = Some(range); session.found_any = true; } // Highlight just this match: a single-element store set renders // it as SearchMatchActive in both frontends (Q#QR5). { let mut guard = self .search_store .lock() .expect("search store mutex poisoned"); guard.set(bid, from, vec![range]); } self.search_place_cursor(range.start); self.query_replace_set_prompt(); } /// Set `core.status` to the per-match prompt (Q#QR4) — shown in /// both frontends via the v15 `StatusFacts.message` band. fn query_replace_set_prompt(&mut self) { if let Some(session) = self.query_replace.as_ref() { self.status = format!( "Query replacing '{}' with '{}' (y/n, ! all, . last, q quit)", session.from, session.to ); } } /// Replace the current match with the to-string as a single edit /// (Q#QR7), advancing `next_from` past the inserted text so it is /// never re-matched (Q#QR2). Returns `true` when an edit was /// applied. Does NOT advance to the next match — callers chain /// `query_replace_advance` (or finish) as their flow needs. fn query_replace_apply_current(&mut self) -> bool { let Some(session) = self.query_replace.as_ref() else { return false; }; let Some(range) = session.current else { return false; }; let to = session.to.clone(); if let Err(e) = self.apply_active_edit(EditOp::Replace { range: Range { start: range.start, end: range.end, }, bytes: to.as_bytes(), }) { self.status = format!("query-replace: {e}"); return false; } let new_next = range.start + to.len() as u64; if let Some(session) = self.query_replace.as_mut() { session.next_from = new_next; session.current = None; session.replaced += 1; } self.search_place_cursor(new_next); true } /// `y` / `SPC` — replace the current match, then advance to the next. pub fn query_replace_replace(&mut self) { if self.query_replace_on_origin() && self.query_replace_apply_current() { self.query_replace_advance(); } } /// `n` / `DEL` — leave the current match, advance past it to the next. pub fn query_replace_skip(&mut self) { if !self.query_replace_on_origin() { return; } if let Some(session) = self.query_replace.as_mut() && let Some(range) = session.current { session.next_from = range.end; session.current = None; } self.query_replace_advance(); } /// `!` — replace the current match and all remaining without /// prompting, then finish (Q#QR6). One `after-edit` hook fires for /// the batch (the dispatcher compares revision across the handler). pub fn query_replace_all(&mut self) { if !self.query_replace_on_origin() { return; } while self.query_replace_apply_current() { // Find the next match (mirrors advance's search, without the // highlight/prompt work — we're not stopping to ask). let Some(session) = self.query_replace.as_ref() else { break; }; let bid = session.origin.0; let bytes = self.buffer_bytes(bid); let start = (session.next_from as usize).min(bytes.len()); let found = match &session.re { Some(re) => crate::search::find_first_regex_from(&bytes, re, start), None => crate::search::find_first_from(&bytes, &session.from, start), }; match found { Some(range) => { if let Some(session) = self.query_replace.as_mut() { session.current = Some(range); } } None => break, } } self.query_replace_finish(); } /// `.` — replace the current match, then finish (Q#QR6). pub fn query_replace_replace_and_quit(&mut self) { if !self.query_replace_on_origin() { return; } self.query_replace_apply_current(); self.query_replace_finish(); } /// End the session (Q#QR10): clear the highlight, restore the origin /// cursor *only* if nothing ever matched, and set the count status. /// Every other exit leaves point where the last step put it. pub fn query_replace_finish(&mut self) { let Some(session) = self.query_replace.take() else { return; }; let bid = session.origin.0; self.search_store .lock() .expect("search store mutex poisoned") .clear(bid); if session.found_any { let n = session.replaced; self.status = format!("Replaced {n} occurrence{}", if n == 1 { "" } else { "s" }); } else { if self.active_buffer_id() == bid { self.search_place_cursor(session.origin.1); } self.status = format!("No matches for '{}'", session.from); } } /// Snapshot a buffer's full byte content (empty if the id is /// stale). O(1) rope snapshot + one copy; used to feed `find_all`. fn buffer_bytes(&self, buffer_id: BufferId) -> Vec { 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 effective [`Edit`] — the /// post-intercept range and inserted length (auto-pairing Q#AP9 /// needs the effective triple; every other caller reads /// `new_rope.len()` or discards it). /// /// # Errors /// /// Returns a stringified error on buffer or view failure. pub fn apply_active_edit(&mut self, op: EditOp<'_>) -> Result { // Arc 6 Stage 2 (Q#FD19): ONE pre-edit unfold funnel for every // local point-anchored edit. This subsumes the six `dispatch_key` // primitives' individual calls (retired) and widens the behavior // to **yank** and **query-replace**, which reach the buffer // through here rather than through those primitives — both place // point at the edit site first, so keying on the active point // covers them exactly. `apply_active_edit` is never the // remote-apply path, so this funnel is inherently local: a remote // peer's edit inside my fold must not unfold it (Stage 3). self.unfold_before_point_edit(); let buffer_id = self.active_buffer_id(); // Scope the registry borrow: the origin translation below needs // `&mut self` after the views have been notified. let edit = { 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); } } } 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 (M11.8): the producer / // TUI overlay suppress them until a fresh search re-runs. The // headline isearch bet — "stale-after-edit linger" — is // closed here. self.search_invalidate_for_edit(buffer_id, &edit); Ok(edit) } /// Q#AI8 search invalidation for a landed edit: mark the buffer's /// matches stale (no-op without search state) and right-gravity- /// translate the live session origin. ONE helper so every edit /// path — dispatch ([`Self::apply_active_edit`]), direct /// notification ([`Self::notify_buffer_edit`]), and history /// ([`Self::undo`] / [`Self::redo`]) — invalidates identically; /// a path that skips this leaves highlights, step targets, and /// the n/m count pointing at pre-edit offsets. fn search_invalidate_for_edit(&mut self, buffer_id: BufferId, edit: &Edit) { self.search_store .lock() .expect("search store mutex poisoned") .mark_stale(buffer_id); self.translate_search_origin(buffer_id, edit); } /// Right-gravity-translate the live search origin through an edit /// to `buffer_id` (Q#AI8; the `src/daemon.rs` optimistic-arm /// shape). The origin is a raw byte offset captured at /// [`Self::search_begin`]; without translation an insert/delete /// before it skews every later recompute focus and the cancel /// restore, even when the match set itself is fresh. fn translate_search_origin(&mut self, buffer_id: BufferId, edit: &Edit) { let Some(session) = self.search.as_mut() else { return; }; if session.origin.0 != buffer_id { return; } let start = edit.range.start; let end = edit.range.end; let pos = session.origin.1; session.origin.1 = if pos < start { pos } else if pos > end { pos - (end - start) + edit.inserted_len } else { start + edit.inserted_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. /// /// Q#AI8: direct edits must also invalidate search state exactly /// like [`Self::apply_active_edit`] does — mark the matches stale /// and translate the live origin — otherwise accepted-match /// highlights and the session origin survive at pre-edit offsets /// for every Lua mutator edit and applied CRDT op. pub fn notify_buffer_edit(&mut self, buffer_id: BufferId, edit: &Edit) { self.search_invalidate_for_edit(buffer_id, 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 { self.save_inner(false) } /// [`save`](Self::save), overwriting the file even though it changed on /// disk since this buffer read it. The escape hatch for when the user /// has looked and decided their buffer wins. pub fn save_ignoring_disk_changes(&mut self) -> bool { self.save_inner(true) } /// True when writing this buffer to `path` would destroy content the /// buffer has never seen — i.e. a file exists there whose identity /// differs from the [`FileMeta`] recorded when the buffer last read or /// wrote it. /// /// Two cases count as "changed": /// /// * the buffer recorded a meta and the on-disk meta differs — someone /// else edited the file (another editor, a `git checkout`); /// * the buffer recorded **no** meta (a `[new file]`, or a buffer whose /// path was set without reading) yet a file now exists — it was /// created underneath us, and we have never seen its contents. /// /// A **missing** file is not a clobber: there is nothing there to /// destroy, so recreating a deleted file saves normally. An unstattable /// path likewise falls through, and `save_atomic` reports the real /// error. #[must_use] pub fn save_would_clobber(&self, id: BufferId, path: &Path) -> bool { let Ok(current) = crate::file_io::current_meta(path) else { return false; // absent, or we cannot stat it }; let reg = self.registry.borrow(); let Ok(buffer) = reg.get(id) else { return false; }; buffer.file_meta() != Some(¤t) } fn save_inner(&mut self, force: bool) -> 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; }; // Refuse to silently overwrite a file that changed underneath us. // Without this, pmacs clobbers another editor's (or a `git // checkout`'s) writes with a buffer that never saw them. if !force && self.save_would_clobber(id, &path) { self.status = format!( "{} changed on disk since it was read --- M-x buffer.save-anyway to overwrite", path.display() ); 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. /// /// Arc 6 Stage 2 (Q#FD17, ruled: include): on a fold-projecting /// frontend this steps to the previous **visible** line, so a /// collapsed region is one motion step and the cursor never comes to /// rest hidden. Motion that *begins* from a hidden logical cursor (a /// shared fold, or goto-line into one) first normalizes to the /// visible head. Scoped by Q#FD21 — a semantic frontend keeps /// raw-line motion until Stage 3. pub fn move_up(&mut self) { let folds = self.fold_map_active(); // Normalize FIRST and as a real mutation (round-4 F2): a hidden // logical cursor moves to its component's head POSITION — head // row *and* head end-of-content column — before any step is // computed. Deriving the goal column from the hidden line would // carry a column the head may not even have, and returning early // at a buffer boundary (a fold headed on line 0) would leave the // cursor hidden entirely. self.normalize_cursor_to_visible(folds.as_ref()); 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 aw = self.active_window(); let coord = aw .text_view .pos_to_display(buffer, cursor) .unwrap_or_default(); let from_row = coord.row as usize; if from_row == 0 { return; } let target_row = folds .as_ref() .map_or(from_row - 1, |map| map.prev_visible(from_row)); let goal = goal_col.unwrap_or(coord.col); let Ok(target_row) = u32::try_from(target_row) else { return; }; let target = DisplayCoord::new(target_row, goal); let new_pos = aw.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. /// Visible-line stepping mirrors [`Self::move_up`] (Q#FD17/FD21). pub fn move_down(&mut self) { let folds = self.fold_map_active(); self.normalize_cursor_to_visible(folds.as_ref()); 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 aw = self.active_window(); let coord = aw .text_view .pos_to_display(buffer, cursor) .unwrap_or_default(); let from_row = coord.row as usize; let next_row = folds .as_ref() .map_or(from_row + 1, |map| map.next_visible(from_row)); if next_row >= aw.text_view.line_count() { return; } let goal = goal_col.unwrap_or(coord.col); let Ok(next_row) = u32::try_from(next_row) else { return; }; let target = DisplayCoord::new(next_row, goal); let new_pos = aw.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) { // Arc 6 Stage 2 (Q#FD18/FD21): a screenful is a screenful of // VISIBLE lines, and `view_top` never lands hidden. Paging shares // vertical motion's hidden-cursor normalization (round-4 F2) — // the goal column must come from the head, not the hidden line. let folds = self.fold_map_active(); self.normalize_cursor_to_visible(folds.as_ref()); 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); let goal_col = aw.goal_col.unwrap_or(coord.col); let (target_row, new_top) = match folds.as_ref() { Some(map) => ( map.nth_visible_from(coord.row as usize, step as usize) .min(map.visible_head_of(max_line)), map.nth_visible_from(view_top, step as usize), ), None => ( (coord.row as usize + step as usize).min(max_line), view_top.saturating_add(step as usize), ), }; let Ok(target_row) = u32::try_from(target_row) else { return; }; let target = DisplayCoord::new(target_row, goal_col); let new_pos = aw.text_view.display_to_pos(buffer, target); (goal_col, new_pos, new_top) }; 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); let clamped = new_top.min(max_top); aw.view_top = folds .as_ref() .map_or(clamped, |map| map.clamp_view_top(clamped)); } /// Move the cursor up by approximately one screenful. Mirror of /// [`Self::move_page_down`]. pub fn move_page_up(&mut self) { let folds = self.fold_map_active(); self.normalize_cursor_to_visible(folds.as_ref()); 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, new_top) = match folds.as_ref() { Some(map) => ( map.nth_visible_back(coord.row as usize, step as usize), map.nth_visible_back(view_top, step as usize), ), None => ( (coord.row as usize).saturating_sub(step as usize), view_top.saturating_sub(step as usize), ), }; let Ok(target_row) = u32::try_from(target_row) else { return; }; let target = DisplayCoord::new(target_row, goal_col); let new_pos = aw.text_view.display_to_pos(buffer, target); (goal_col, new_pos, new_top) }; 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; } /// Pre-edit unfold (Arc 6, Q#FD5 / Stage 2 Q#FD19). Before a local /// point-anchored edit, unfold every fold containing the active point /// so an edit inside a collapsed region reveals it rather than /// landing invisibly. Keyed on the authenticated source frontend's /// active point (`active_window().cursor`), not the transport. A /// no-op when the buffer has no folds. /// /// **Stage 2 widening:** Stage 1 called this from each of the six /// `dispatch_key` edit primitives. It now runs once at the top of /// [`Self::apply_active_edit`] — the single funnel those primitives /// (and yank, and query-replace) all pass through. Interactive /// Lua-mutator edits (comment-toggle, yank-pop) take a *different* /// path and are hooked at `run_buffer_edit` in the Lua bindings; the /// remote/optimistic-CRDT apply path is deliberately excluded /// (Stage 3), as is undo/redo (deferred). /// Move a hidden logical cursor to its component's visible head /// **position** — the head row *and* that head's end-of-content /// column, i.e. exactly where Stage 1 moves point on a fold-at-cursor /// (Q#FD16/Q#FD17, round-4 F2). /// /// A cursor can be hidden without this frontend ever having moved it /// there: another frontend folds through the shared store, or /// goto-line targets a line inside a collapse. Vertical motion and /// paging normalize through here *before* computing their step, so /// the goal column is the head's — never a column of a line that /// renders no row — and so a step that turns out to be a no-op at a /// buffer boundary still leaves the cursor visible. /// /// The jump is discontinuous, so the sticky goal column is dropped /// (the click/goto convention), and only when the cursor actually /// was hidden: ordinary motion keeps its sticky column untouched. fn normalize_cursor_to_visible(&mut self, folds: Option<&crate::fold_view::VisibleLineMap>) { let Some(map) = folds else { return }; let aw = self.active_window(); let line = aw.text_view.line_at_offset(aw.cursor); let projected = map.visible_position(line, aw.cursor); if projected != aw.cursor { let aw = self.active_window_mut(); aw.cursor = projected; aw.goal_col = None; } } fn unfold_before_point_edit(&self) { let id = self.active_buffer_id(); let point = self.active_window().cursor; self.fold_registry.unfold_containing(id, point); } /// Insert a single character at the cursor. Returns `true` iff the /// edit landed: a rejecting buffer intercept reports via the status /// line and returns `false`, and callers must not mutate dependent /// state (e.g. selection anchors) on a failed insert (Q#AI9). pub fn insert_char(&mut self, ch: char) -> bool { 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; // Q#AP9: the buffer/window the request was made in, captured // BEFORE the edit — a legal intercept may switch the active // context mid-edit, and the record must name where the // self-insert actually landed, not where the intercept went. let (buffer_id, window_id) = (self.active_buffer_id(), self.active_window_id()); let edit = match self.apply_active_edit(EditOp::Insert { pos, bytes }) { Ok(edit) => edit, Err(e) => { self.status = format!("insert failed: {e}"); return false; } }; self.active_window_mut().cursor += bytes.len() as u64; self.typed_edit_complete( ch, (buffer_id, window_id), Range::new(pos, pos), bytes.len() as u64, &edit, ); true } /// 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 { // Q#AI9: an empty selection (anchor == cursor) reports no // region yet stays armed — the insert moves the cursor off // the anchor and the very NEXT key type-overs the fresh // text. Clear it, but only when the edit landed: a // rejecting intercept must leave the anchor untouched. if self.insert_char(ch) { self.active_window_mut().selection = None; } return; }; self.active_window_mut().goal_col = None; let mut buf = [0u8; 4]; let bytes = ch.encode_utf8(&mut buf).as_bytes(); // Q#AP9: capture the request's context before the edit (see // the twin comment in [`Self::insert_char`]). let (buffer_id, window_id) = (self.active_buffer_id(), self.active_window_id()); let edit = match self.apply_active_edit(EditOp::Replace { range: Range { start: lo, end: hi }, bytes, }) { Ok(edit) => edit, Err(e) => { self.status = format!("replace failed: {e}"); return; } }; let aw = self.active_window_mut(); aw.cursor = lo + bytes.len() as u64; aw.selection = None; self.typed_edit_complete( ch, (buffer_id, window_id), Range::new(lo, hi), bytes.len() as u64, &edit, ); } /// 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); // Q#AI8 (PR #109 round 1): history edits move bytes // like any other edit — invalidate search state. self.search_invalidate_for_edit(buffer_id, &edit); // 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); // Q#AI8 — same as undo above. self.search_invalidate_for_edit(buffer_id, &edit); // 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 ------------------------------------------------- /// [`Self::split_active`], refusing a side window (Q#BP6): the panel /// is a leaf of the root-level wrapper, so splitting it would produce /// a second, unallocatable side slot. /// /// # Errors /// When the active window is a side window. pub fn try_split_active( &mut self, orientation: Orientation, same_buffer: bool, ) -> Result { if self .windows .get(&self.active_window_id()) .is_some_and(crate::window::Window::is_side) { return Err("window.split: not available in a side window".into()); } Ok(self.split_active(orientation, same_buffer)) } /// 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 mut new_window = Window::new(new_id, buffer_id, text_view); let active = self.active_window_id(); // A same-buffer split starts from an empty overlay list and // fires no switch hook, so store-backed render overlays // (ANSI styling on a compile buffer) would silently vanish // from the new pane (PR #113 round-6 finding 1). Views that // carry across splits say so via `clone_for_split`. if same_buffer && let Some(src) = self.windows.get(&active) { for overlay in &src.overlays { if let Some(copy) = overlay.clone_for_split() { new_window.overlays.push(copy); } } } self.windows.insert(new_id, new_window); 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) { self.focus_step(true); } /// Move focus to the previous window in iteration order. pub fn focus_prev(&mut self) { self.focus_step(false); } /// Shared `C-x o` traversal, skipping a **hidden** side window /// (Q#BP6): keys must never route to an invisible panel, and once it /// reappears traversal reaches it normally again. /// /// Also the seam that refreshes `origin_document` (Q#BP2c): entering /// the panel from document window B must retarget `display_target`, /// panel visits, and a `Delete`-form `window.quit` at B rather than /// at whichever window happened to create the panel. fn focus_step(&mut self, forward: bool) { let fid = self.active_frontend_key(); let active = self.active_window_id(); let hidden_panel = if self.views.get(&fid).is_some_and(|view| view.panel_hidden) { self.side_window_for(fid) } else { None }; let next = self .active_layout() .focus_step(active, forward, &|id| Some(id) != hidden_panel); self.set_active_window_id(next); self.note_focus_transition(fid, active, next); } /// Focus an explicit window in the acting frontend, refreshing the /// panel's remembered document origin on the way (Q#BP2c). /// /// **The caller must have validated `target`** — that it is live and /// belongs to `fid`'s layout. Every Lua path does so through /// `lookup_window` or the display transaction's own revalidation; /// this function only `debug_assert!`s it, so a release-mode caller /// passing a foreign or dead id would leave `view.active` dangling. pub fn focus_window(&mut self, fid: FrontendId, target: WindowId) { let Some(view) = self.views.get_mut(&fid) else { return; }; let previous = view.active; view.active = target; self.note_focus_transition(fid, previous, target); } /// Close the active window. Returns false when the layout would be /// left with no **document** window. /// /// Q#BP6 narrows the pre-arc "unless it's the only one" rule: a side /// window is never load-bearing, so closing the panel itself is /// always legal — including when it is the only other window — while /// closing the last *non-side* window is always refused. 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. let fid = self.active_frontend_key(); let target = self.active_window_id(); let target_is_side = self .windows .get(&target) .is_some_and(crate::window::Window::is_side); if !target_is_side { let remaining_documents = self .active_layout() .iter_ids() .into_iter() .filter(|id| { *id != target && !self .windows .get(id) .is_some_and(crate::window::Window::is_side) }) .count(); if remaining_documents == 0 { return false; } } self.active_layout_mut().close_window(target); self.windows.remove(&target); if target_is_side && let Some(view) = self.views.get_mut(&fid) { view.panel_hidden = false; } // Pick an adjacent window as the new focus, preferring a document. let ids = self.active_layout().iter_ids(); let next = ids .iter() .copied() .find(|id| { !self .windows .get(id) .is_some_and(crate::window::Window::is_side) }) .unwrap_or_else(|| *ids.first().expect("at least one window remains")); let previous = self.active_window_id(); self.set_active_window_id(next); self.note_focus_transition(fid, previous, next); true } /// Close every window except the active one, *within the active /// frontend* — including the panel (Q#BP6). /// /// # Errors /// From a side window: a panel cannot swallow the document tree. pub fn close_others(&mut self) -> Result<(), String> { // 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(); if self .windows .get(&keep) .is_some_and(crate::window::Window::is_side) { return Err("window.close-others: not available in a side window".into()); } let fid = self.active_frontend_key(); 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); } if let Some(view) = self.views.get_mut(&fid) { view.panel_hidden = false; } Ok(()) } /// The `views` key the active-frontend accessors resolve to. #[must_use] pub fn active_frontend_key(&self) -> FrontendId { if self.views.contains_key(&self.active_frontend) { self.active_frontend } else { FrontendId::LOCAL } } // ---- side windows + display policy (bottom-panel arc) ------------------ /// The one side leaf in `fid`'s layout, if it has one (Q#BP2a). #[must_use] pub fn side_window_for(&self, fid: FrontendId) -> Option { let view = self.views.get(&fid)?; view.layout.side_leaf(|id| { self.windows .get(&id) .is_some_and(crate::window::Window::is_side) }) } /// Whether `fid`'s side window exists but is currently hidden. #[must_use] pub fn panel_hidden_for(&self, fid: FrontendId) -> bool { self.views.get(&fid).is_some_and(|view| view.panel_hidden) && self.side_window_for(fid).is_some() } /// Whether `fid` can render a side window at all (Q#BP13). #[must_use] pub fn panel_capable_for(&self, fid: FrontendId) -> bool { self.views.get(&fid).is_some_and(|view| view.panel_capable) } /// **The** primary document window for `fid` (Q#BP14). /// /// The frontend's active window when it is non-side, else its /// non-side target. Every consumer classified *Projection* in the /// framing's §1.3 census routes through this rather than through /// `active_window_for` / `active_buffer_id`, so focusing a panel /// re-sends no snapshot, suppresses no document, swaps no mirror, /// and cannot leak into a newly attached frontend's document view. #[must_use] pub fn primary_document_window(&self, fid: FrontendId) -> Option { let view = self.views.get(&fid)?; if !self .windows .get(&view.active) .is_some_and(crate::window::Window::is_side) { return Some(view.active); } self.non_side_target(fid).ok() } /// [`Self::primary_document_window`]'s buffer, falling back to the /// focused window's when the layout is degenerate. #[must_use] pub fn primary_document_buffer(&self, fid: FrontendId) -> Option { let win = self.primary_document_window(fid)?; self.windows.get(&win).map(|window| window.buffer_id) } /// The non-side target rule (Q#BP11a). /// /// 1. the selected window when it is **not** a side window /// (byte-identical to pre-arc behavior), /// 2. else the remembered `origin_document`, when it revalidates, /// 3. else the first non-side window in `iter_ids()` order, /// 4. else a pointed error. There is no document leaf from which a /// valid fallback could be fabricated, and Q#BP6 forbids this as /// a resting state, so the broken invariant is asserted rather /// than papered over. /// /// # Errors /// When `fid` has no view, or its layout holds no non-side window. pub fn non_side_target(&self, fid: FrontendId) -> Result { let view = self .views .get(&fid) .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; let is_side = |id: WindowId| { self.windows .get(&id) .is_some_and(crate::window::Window::is_side) }; if !is_side(view.active) { return Ok(view.active); } if let Some(origin) = self .windows .get(&view.active) .and_then(|w| w.params.origin_document()) && view.layout.iter_ids().contains(&origin) && !is_side(origin) { return Ok(origin); } if let Some(first) = view.layout.iter_ids().into_iter().find(|id| !is_side(*id)) { return Ok(first); } debug_assert!( false, "invariant (Q#BP6): a frontend layout always retains at least one non-side window" ); Err("no document window is available".into()) } /// Record the document window a focus transition into the panel came /// from (Q#BP2c). /// /// Called on every focus change. Only a **non-side → side** /// transition refreshes the memory: panel→panel redisplay and /// passive display must not overwrite it, and a creation-only /// origin would go stale the moment the user entered the panel from /// a different document split. pub fn note_focus_transition(&mut self, fid: FrontendId, from: WindowId, to: WindowId) { if from == to { return; } debug_assert!( self.views .get(&fid) .is_some_and(|view| view.layout.iter_ids().contains(&to)), "focus transition target must belong to the acting frontend's layout" ); let from_side = self .windows .get(&from) .is_some_and(crate::window::Window::is_side); let to_side = self .windows .get(&to) .is_some_and(crate::window::Window::is_side); if from_side || !to_side { return; } if let Some(window) = self.windows.get_mut(&to) { window.params.set_origin_document(Some(from)); } } /// Minimum outer rows the document subtree beneath `fid`'s panel /// wrapper needs (Q#BP2). Falls back to the whole root when the tree /// does not have the wrapper shape. #[must_use] fn document_min_rows(&self, fid: FrontendId) -> u32 { let Some(view) = self.views.get(&fid) else { return MIN_WINDOW_OUTER_ROWS; }; let node = self .side_window_for(fid) .and_then(|side| view.layout.document_subtree(side)) .unwrap_or(&view.layout.root); subtree_min_rows(node) } /// The panel's **effective** row allocation on a frame whose window /// area is `area_rows` (Q#BP2), or `None` when it cannot be /// satisfied and must be hidden. /// /// `min(requested, area_rows - subtree_min_rows(document_root))`, then /// the structural floor. This is the whole bounded promise: the panel /// allocator never makes an otherwise satisfiable document tree /// unsatisfiable, and what the frame does to a document tree that /// could not fit anyway is unchanged behavior. #[must_use] pub fn panel_allocation(&self, fid: FrontendId, area_rows: u32) -> Option { let side = self.side_window_for(fid)?; let requested = self.windows.get(&side)?.params.fixed_rows?; let allowed = area_rows.saturating_sub(self.document_min_rows(fid)); let alloc = requested.min(allowed); (alloc >= MIN_WINDOW_OUTER_ROWS).then_some(alloc) } /// The fixed-extent map both [`crate::window::Layout::compute`] /// production callers feed in (Q#BP2, R5-B1). /// /// Derived by this one shared helper rather than assembled at each /// call site: `window_placements` and the peer-presence overlay pass /// build different areas, and leaving the second on unfixed geometry /// would paint every peer cursor at the row it would occupy with no /// panel open. /// /// A hidden panel maps to `0`, which is Q#BP2's exact effective /// geometry for that state: the side leaf gets an empty rect, the /// document subtree receives every reclaimed row, and the stored /// request, wrapper, ids, weights, and order all stay intact. #[must_use] pub fn panel_fixed_rows(&self, fid: FrontendId, area_rows: u32) -> HashMap { let mut fixed = HashMap::new(); let Some(side) = self.side_window_for(fid) else { return fixed; }; if self.views.get(&fid).is_some_and(|view| view.panel_hidden) { fixed.insert(side, 0); return fixed; } fixed.insert(side, self.panel_allocation(fid, area_rows).unwrap_or(0)); fixed } /// Delete `side` from `fid`'s layout, collapsing the root-level /// wrapper and rehoming focus (Q#BP2a). /// /// Idempotent and safe to call from `kill_buffer`: the wrapper /// collapse is `Layout::close_window`'s existing /// `collapse_single_child_splits` pass, so no new tree code runs. pub fn remove_side_window(&mut self, fid: FrontendId, side: WindowId) { let Some(view) = self.views.get_mut(&fid) else { return; }; if !view.layout.close_window(side) { return; } view.panel_hidden = false; let was_active = view.active == side; if was_active { let fallback = *view .layout .iter_ids() .first() .expect("Q#BP6: a document leaf always survives the wrapper collapse"); view.active = fallback; } self.windows.remove(&side); if was_active && let Ok(target) = self.non_side_target(fid) && let Some(view) = self.views.get_mut(&fid) { view.active = target; } // A remembered origin pointing at a now-dead window is cleared by // `non_side_target`'s revalidation on next use; nothing else here // may reference the removed id. for window in self.windows.values_mut() { if window.params.origin_document() == Some(side) { window.params.set_origin_document(None); } } } /// Phase 1 of `window.quit` (Q#BP2c / Q#BP11b). /// /// Executes the window's recorded [`QuitAction`], returning the /// Phase-2 transaction Q#BP4 owns. A `Restore` whose buffer has been /// killed fails closed to `Delete`, dropping the unusable chain. /// /// # Errors /// A window with no recorded action returns a pointed error **without /// closing or switching anything** — non-side adopter fallbacks call /// their own existing restore path instead. pub fn quit_window( &mut self, fid: FrontendId, target: WindowId, ) -> Result { let action = self .windows .get(&target) .ok_or_else(|| format!("window {} is not live", target.raw()))? .params .quit_action() .cloned() .ok_or_else(|| "window.quit: this window has no quit action".to_string())?; let action = match action { QuitAction::Restore { buffer_id, .. } if !self.registry.borrow().contains(buffer_id) => { QuitAction::Delete } other => other, }; match action { QuitAction::Delete => { // Capture the remembered origin BEFORE the window dies: // executing `Delete` focuses the revalidated origin, not // merely whatever leaf the wrapper collapse surfaced // (Q#BP11b). Entering the panel from document window B // must therefore return focus to B, not to the window // that happened to create the panel. let origin = self .windows .get(&target) .and_then(|window| window.params.origin_document()); self.remove_side_window(fid, target); let origin_valid = origin.is_some_and(|origin| { self.views .get(&fid) .is_some_and(|view| view.layout.iter_ids().contains(&origin)) && !self .windows .get(&origin) .is_some_and(crate::window::Window::is_side) }); if origin_valid && let Some(origin) = origin && let Some(view) = self.views.get_mut(&fid) { view.active = origin; } Ok(QuitOutcome::Deleted { focus: self.views.get(&fid).map(|view| view.active), }) } QuitAction::Restore { buffer_id, fixed_rows, dedicated, cursor, view_top, goal_col, selection, then, } => { self.install_buffer_in_window(target, buffer_id)?; let len = { let reg = self.registry.borrow(); reg.get(buffer_id).map_or(0, Buffer::len) }; let window = self .windows .get_mut(&target) .ok_or_else(|| "window.quit: target vanished".to_string())?; window.params.fixed_rows = Some(fixed_rows.max(MIN_WINDOW_OUTER_ROWS)); window.params.dedicated = dedicated; window.params.set_quit_action(Some(*then)); // Clamp saved positions against the buffer's CURRENT // contents: it may have shrunk while the panel showed // something else. Derived `last_visible_rows` and // trait-object overlays are deliberately not snapshotted — // the switch hook reattaches overlays. window.cursor = cursor.min(len); window.view_top = view_top; window.goal_col = goal_col; window.selection = selection.filter(|sel| sel.anchor <= len); Ok(QuitOutcome::Restored { target, buffer_id }) } } } /// Clamp a programmatic `fixed_rows` request (Q#BP2). /// /// # Errors /// A request of `0` is rejected rather than being an invisible /// "open". pub fn clamp_panel_rows(rows: u32) -> Result { if rows == 0 { return Err("panel height must be at least 1 row".into()); } Ok(rows.max(MIN_WINDOW_OUTER_ROWS)) } /// The window area a frontend's layout is computed into: the whole /// declared frame minus the one global status row, matching /// `window_placements`. `None` while geometry is **unknown**. #[must_use] pub fn frontend_area_rows(&self, fid: FrontendId) -> Option { let geometry = self.views.get(&fid)?.frame_geometry?; (geometry.total.rows >= 2 && geometry.total.cols > 0).then(|| geometry.total.rows - 1) } /// Cache a frontend's authoritative frame capacity (Q#BP2b). /// /// Grid / `LOCAL` views call this from their real attach and resize /// sizes with an internally minted epoch; a semantic view stays /// `None` until Stage 2's authenticated declaration. A repeated /// identical size is not a new declaration. pub fn declare_frame_geometry(&mut self, fid: FrontendId, total: crate::cell::CellSize) { let Some(view) = self.views.get_mut(&fid) else { return; }; if view .frame_geometry .is_some_and(|geometry| geometry.total == total) { return; } let next = view .frame_geometry .map_or(1, |geometry| geometry.geometry_epoch.saturating_add(1)); view.frame_geometry = Some(crate::window::DeclaredFrameGeometry { geometry_epoch: next, total, }); } /// Core half of the idempotent panel-reconciliation transaction /// (Q#BP2b). The caller owns the terminal manager, so releasing a /// controller is reported rather than performed. /// /// Hiding is a **durable state transition**, not a per-frame effect: /// a render-time dodge would still route keys to an invisible window /// and would leave the terminal controller claimed, because the /// resize path merely returns on zero content without releasing it. pub fn reconcile_panel_layout_core(&mut self, fid: FrontendId) -> PanelReconciliation { let mut result = PanelReconciliation::default(); let Some(side) = self.side_window_for(fid) else { // `panel_hidden` never describes a panel that no longer // exists. if let Some(view) = self.views.get_mut(&fid) { result.changed = view.panel_hidden; view.panel_hidden = false; } return result; }; let was_hidden = self.views.get(&fid).is_some_and(|view| view.panel_hidden); // Unknown geometry (a semantic view before Stage 2's declaration) // and a zero-column frame are both non-presentable, and follow the // hidden arm rather than being sized against a placeholder. let satisfiable = self .frontend_area_rows(fid) .and_then(|rows| self.panel_allocation(fid, rows)) .is_some(); let Some(view) = self.views.get_mut(&fid) else { return result; }; view.panel_hidden = !satisfiable; result.hidden = !satisfiable; result.changed = was_hidden != result.hidden; if satisfiable { // Focus is deliberately NOT restored when the panel // reappears — the user moved on; `C-x o` returns. return result; } if view.active == side { // Durable transition: move focus out and tell the caller to // release the terminal controller for this view key. result.released_terminal = Some(side); if let Ok(target) = self.non_side_target(fid) && let Some(view) = self.views.get_mut(&fid) { view.active = target; } } result } /// Move the horizontal boundary that `win` owns by `delta_rows`, /// growing `win` (Q#BP5 / Q#BP5b). /// /// `min_for` resolves each leaf's `window.min-height` preference; it /// is snapshotted by the caller **before** any geometry changes, so /// one gesture uses one set of minima. /// /// # Errors /// When `win` is not live in `fid`'s layout, when the panel is /// hidden, or when no adjustable horizontal boundary exists. #[allow( clippy::too_many_lines, reason = "one boundary-resize transaction: resolve, snapshot minima, clamp, write back" )] pub fn resize_boundary( &mut self, fid: FrontendId, win: WindowId, delta_rows: i32, area_rows: u32, min_for: &impl Fn(WindowId) -> u32, ) -> Result<(), String> { let view = self .views .get(&fid) .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; if !view.layout.iter_ids().contains(&win) { return Err(format!( "window {} does not belong to this frontend", win.raw() )); } let win_is_side = self .windows .get(&win) .is_some_and(crate::window::Window::is_side); if win_is_side && view.panel_hidden { return Err("window.resize: the panel is not currently visible".into()); } // Q#BP5b rule 1: a side window resolves to its OWN fixed // boundary; rule 2: any other window resolves to the nearest // horizontal ancestor at which its path child has a following // sibling — the same boundary a drag on its bottom mode-line row // moves. let (boundary, lower_grows) = if win_is_side { let side = self .side_window_for(fid) .ok_or_else(|| "window.resize: no side window".to_string())?; let path = view .layout .path_to(side) .ok_or_else(|| "window.resize: side window is not in the layout".to_string())?; let (&last, parent) = path .split_last() .ok_or_else(|| "window.resize: no adjustable horizontal boundary".to_string())?; if last == 0 { return Err("window.resize: no adjustable horizontal boundary".into()); } ( crate::window::SplitBoundary { path: parent.to_vec(), upper: last - 1, }, true, ) } else { ( view.layout.boundary_below(win).ok_or_else(|| { "window.resize: no adjustable horizontal boundary".to_string() })?, false, ) }; let placements = view.layout.compute( crate::window::Rect::new(0, 0, area_rows, 1), &self.panel_fixed_rows(fid, area_rows), ); let view = self .views .get(&fid) .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; let LayoutNode::Split { children, .. } = view .layout .node_at(&boundary.path) .ok_or_else(|| "window.resize: boundary vanished".to_string())? else { return Err("window.resize: boundary is not a split".into()); }; let upper_node = &children[boundary.upper]; let lower_node = &children[boundary.upper + 1]; let upper_rows = node_row_extent(upper_node, &placements); let lower_rows = node_row_extent(lower_node, &placements); let total = upper_rows + lower_rows; let min_upper = crate::window::interactive_min_rows(upper_node, min_for); let min_lower = crate::window::interactive_min_rows(lower_node, min_for); // Preserve the preferred minimum on BOTH sides when the frame can // satisfy it; when it is already smaller, the motion may not make // either side worse than it already is. let floor_upper = min_upper.min(upper_rows); let floor_lower = min_lower.min(lower_rows); let boundary_delta = if lower_grows { -delta_rows } else { delta_rows }; let proposed = i64::from(upper_rows) + i64::from(boundary_delta); let lo = i64::from(floor_upper); let hi = i64::from(total.saturating_sub(floor_lower)); if hi < lo { return Err("window.resize: no room to move this boundary".into()); } let new_upper = u32::try_from(proposed.clamp(lo, hi)) .map_err(|_| "window.resize: boundary out of range".to_string())?; let new_lower = total - new_upper; // A side window writes `fixed_rows` (its ABSOLUTE height survives // a terminal resize); a flexible pair writes weights (its RATIO // survives). That difference is the point. let lower_id = match lower_node { LayoutNode::Leaf(id) => Some(*id), LayoutNode::Split { .. } => None, }; let lower_is_side = lower_id.is_some_and(|id| { self.windows .get(&id) .is_some_and(crate::window::Window::is_side) }); if lower_is_side { let id = lower_id.expect("checked above"); if let Some(window) = self.windows.get_mut(&id) { window.params.fixed_rows = Some(new_lower.max(MIN_WINDOW_OUTER_ROWS)); } return Ok(()); } // Rewrite every flexible child's weight as its current row // extent, with the two adjacent children replaced. Untouched // siblings therefore keep the extents they already had. let extents: Vec = children .iter() .enumerate() .map(|(i, child)| { if i == boundary.upper { new_upper } else if i == boundary.upper + 1 { new_lower } else { node_row_extent(child, &placements) } }) .collect(); let fixed = self.panel_fixed_rows(fid, area_rows); let view = self .views .get_mut(&fid) .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; let Some(LayoutNode::Split { weights, children, .. }) = view.layout.node_at_mut(&boundary.path) else { return Err("window.resize: boundary vanished".into()); }; weights.resize(children.len(), 1); for (i, child) in children.iter().enumerate() { let pinned = matches!(child, LayoutNode::Leaf(id) if fixed.contains_key(id)); if !pinned { weights[i] = extents[i].max(1); } } Ok(()) } /// Install `buffer_id` in an explicit window, resetting its view /// state exactly as [`Self::switch_active_buffer_for`] does — except /// that redisplaying the buffer a window **already shows** is a no-op /// on cursor, viewport, selection, and overlays. /// /// # Errors /// Unknown window or buffer. pub fn install_buffer_in_window( &mut self, window_id: WindowId, 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 window = self .windows .get_mut(&window_id) .ok_or_else(|| format!("window {window_id:?} is not live"))?; if window.buffer_id == buffer_id { return Ok(()); } window.buffer_id = buffer_id; window.text_view = text_view; window.overlays.clear(); window.cursor = 0; window.selection = None; window.view_top = 0; window.goal_col = None; Ok(()) } /// Phase 1 of the display transaction (Q#BP4): choose a target, /// install the buffer, and report what Phase 2 must do. /// /// Contains **no** Lua: the hook fan-out, the reconciliation, and the /// final-focus matrix all belong to the layer that owns the Lua host. /// /// # Errors /// An unusable exact target, an unsatisfiable placement request, or a /// layout with no eligible document window. pub fn display_buffer( &mut self, fid: FrontendId, request: &DisplayRequest, ) -> Result { let saved_active = self .views .get(&fid) .ok_or_else(|| format!("frontend {fid:?} has no window layout"))? .active; let placement = self.resolve_placement(fid, request)?; self.apply_placement(fid, request, &placement)?; let select = request .select .unwrap_or(!matches!(placement.kind, PlacementKind::Side { .. })); Ok(DisplayOutcome { target: placement.target, saved_active, select, created_side: matches!(placement.kind, PlacementKind::Side { created: true, .. }), }) } /// Answer "is there a usable destination for this visit?" **without /// loading anything** (Q#BP11b step 2, R3-B17). /// /// `existing` is the side-effect-free dedup result: `None` means the /// file is not open yet, in which case an eligible destination must /// not be dedicated to *any* buffer — otherwise a dedicated origin /// could force a load that then has nowhere to go. /// /// # Errors /// An exact target that is dead, foreign, or dedicated; or a layout /// with no eligible document window. pub fn probe_display_target( &self, fid: FrontendId, existing: Option, window: Option, ) -> Result { let view = self .views .get(&fid) .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; let eligible = |id: WindowId| { self.windows.get(&id).is_some_and(|w| { !w.params.dedicated || existing.is_some_and(|buffer_id| w.buffer_id == buffer_id) }) }; if let Some(target) = window { if !view.layout.iter_ids().contains(&target) { return Err(format!( "display_file: window {} does not belong to this frontend", target.raw() )); } if !eligible(target) { return Err(format!( "display_file: window {} is dedicated to another buffer", target.raw() )); } return Ok(target); } let is_side = |id: WindowId| { self.windows .get(&id) .is_some_and(crate::window::Window::is_side) }; if let Some(buffer_id) = existing && let Some(showing) = view.layout.iter_ids().into_iter().find(|id| { !is_side(*id) && self .windows .get(id) .is_some_and(|w| w.buffer_id == buffer_id) }) { return Ok(showing); } let mut candidates: Vec = Vec::new(); if let Ok(preferred) = self.non_side_target(fid) { candidates.push(preferred); } candidates.extend( view.layout .iter_ids() .into_iter() .filter(|id| !is_side(*id)), ); candidates .into_iter() .find(|id| eligible(*id)) .ok_or_else(|| "display_file: no eligible document window is available".into()) } /// Q#BP3's precedence: exact target, then side affinity, then /// ordinary reuse. Placement affinity precedes generic reuse — /// otherwise a persistent `*compilation*` buffer already visible in a /// document window makes `{side = "bottom"}` silently ignore its /// requested placement. #[allow( clippy::too_many_lines, reason = "Q#BP3's precedence ladder reads as one ordered policy" )] fn resolve_placement( &self, fid: FrontendId, request: &DisplayRequest, ) -> Result { if request.window.is_some() && request.side.is_some() { return Err("display: `window` and `side` are mutually exclusive".into()); } let view = self .views .get(&fid) .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; // 1. Exact target. if let Some(target) = request.window { if !view.layout.iter_ids().contains(&target) { return Err(format!( "display: window {} does not belong to this frontend", target.raw() )); } let window = self .windows .get(&target) .ok_or_else(|| format!("display: window {} is not live", target.raw()))?; if window.params.dedicated && window.buffer_id != request.buffer_id { return Err(format!( "display: window {} is dedicated to another buffer", target.raw() )); } if request.height.is_some() && !window.is_side() { return Err("display: `height` requires a side window".into()); } return Ok(Placement { target, kind: if window.is_side() { PlacementKind::Side { created: false, replacing: window.buffer_id != request.buffer_id, } } else { PlacementKind::Ordinary }, }); } // 2. Side target — only on a panel-capable frontend. if request.side.is_some() && view.panel_capable { match self.side_window_for(fid) { Some(side) => { let window = self .windows .get(&side) .ok_or_else(|| "display: side window is not live".to_string())?; if window.buffer_id == request.buffer_id { return Ok(Placement { target: side, kind: PlacementKind::Side { created: false, replacing: false, }, }); } if !window.params.dedicated { return Ok(Placement { target: side, kind: PlacementKind::Side { created: false, replacing: true, }, }); } // The one side slot is dedicated to another buffer. // Never create a second one: fall through to the // ordinary policy, discarding every side-specific // parameter (Q#BP3 2.iii). } None => { return Ok(Placement { target: WindowId::next(), kind: PlacementKind::Side { created: true, replacing: false, }, }); } } } else if request.side.is_none() && request.height.is_some() { // A freestanding `height` with no side request is a mistake. // A `height` that arrived WITH a side request and fell // through (not panel-capable, or the one slot is dedicated // elsewhere) is discarded, not rejected — capability // fallback must not turn into an error (Q#BP2c). return Err("display: `height` requires a side window".into()); } // 3. Ordinary target. let is_side = |id: WindowId| { self.windows .get(&id) .is_some_and(crate::window::Window::is_side) }; // 3.i — reuse a visible NON-side window already showing it. An // ordinary display never selects the panel by coincidence. if let Some(existing) = view.layout.iter_ids().into_iter().find(|id| { !is_side(*id) && self .windows .get(id) .is_some_and(|w| w.buffer_id == request.buffer_id) }) { return Ok(Placement { target: existing, kind: PlacementKind::Ordinary, }); } // 3.ii — the Q#BP11a candidate, then `iter_ids()` order, skipping // any window dedicated to a different buffer. let mut candidates: Vec = Vec::new(); if let Ok(preferred) = self.non_side_target(fid) { candidates.push(preferred); } candidates.extend( view.layout .iter_ids() .into_iter() .filter(|id| !is_side(*id)), ); for candidate in candidates { let eligible = self .windows .get(&candidate) .is_some_and(|w| !w.params.dedicated || w.buffer_id == request.buffer_id); if eligible { return Ok(Placement { target: candidate, kind: PlacementKind::Ordinary, }); } } Err("display: no eligible document window is available".into()) } /// Create the side window when needed, then install the buffer and /// reconcile the parameter semantics of Q#BP3. fn apply_placement( &mut self, fid: FrontendId, request: &DisplayRequest, placement: &Placement, ) -> Result<(), String> { let side = match placement.kind { PlacementKind::Ordinary => { // Reaching Ordinary while a side was REQUESTED means the // request fell back (not panel-capable, or the one slot // is dedicated elsewhere). A failed placement request may // never pin or dedicate a document window, so `side`, // `height`, `dedicated`, and quit bookkeeping are all // discarded here; only an explicit `select` survives, and // that is Phase 2's business. let fell_back = request.side.is_some(); let same_buffer_redisplay = self .windows .get(&placement.target) .is_some_and(|w| w.buffer_id == request.buffer_id); self.install_buffer_in_window(placement.target, request.buffer_id)?; let window = self .windows .get_mut(&placement.target) .ok_or_else(|| "display: target window vanished".to_string())?; match request.dedicated { Some(dedicated) if !fell_back => window.params.dedicated = dedicated, // A same-buffer redisplay must not silently unpin a // window; a genuine replacement starts undedicated. _ if !same_buffer_redisplay => window.params.dedicated = false, _ => {} } return Ok(()); } PlacementKind::Side { created, replacing } => (created, replacing), }; let (created, replacing) = side; let requested_side = request.side.unwrap_or(Side::Bottom); if created { let rows = Self::clamp_panel_rows(request.height.unwrap_or(request.default_panel_rows))?; let origin = self.non_side_target(fid).ok(); let text_view = { let reg = self.registry.borrow(); let buf = reg.get(request.buffer_id).map_err(|e| e.to_string())?; TextView::new(buf) }; let mut window = Window::new(placement.target, request.buffer_id, text_view); window.params.side = Some(requested_side); window.params.fixed_rows = Some(rows); window.params.dedicated = request.dedicated.unwrap_or(false); window.params.set_quit_action(Some(QuitAction::Delete)); window.params.set_origin_document(origin); self.windows.insert(placement.target, window); self.views .get_mut(&fid) .ok_or_else(|| format!("frontend {fid:?} has no window layout"))? .layout .install_side_leaf(placement.target); return Ok(()); } // Reusing the existing slot. Capture the outgoing presentation // BEFORE the install resets the window's view state. let prior = { let window = self .windows .get(&placement.target) .ok_or_else(|| "display: side window vanished".to_string())?; QuitAction::Restore { buffer_id: window.buffer_id, fixed_rows: window.params.fixed_rows.unwrap_or(MIN_WINDOW_OUTER_ROWS), dedicated: window.params.dedicated, cursor: window.cursor, view_top: window.view_top, goal_col: window.goal_col, selection: window.selection, then: Box::new( window .params .quit_action() .cloned() .unwrap_or(QuitAction::Delete), ), } }; self.install_buffer_in_window(placement.target, request.buffer_id)?; let height = match request.height { Some(rows) => Some(Self::clamp_panel_rows(rows)?), None => None, }; let window = self .windows .get_mut(&placement.target) .ok_or_else(|| "display: side window vanished".to_string())?; if let Some(rows) = height { window.params.fixed_rows = Some(rows); } if replacing { // A replacement's new presentation defaults to undedicated so // the one slot stays replaceable; an explicit dedication // applies only after the OLD presentation already passed // eligibility, so `dedicated = false` cannot clear-and-bypass // an existing dedication in the same call. window.params.dedicated = request.dedicated.unwrap_or(false); let mut action = prior; action.truncate_to(MAX_PANEL_QUIT_DEPTH); window.params.set_quit_action(Some(action)); } else if let Some(dedicated) = request.dedicated { window.params.dedicated = dedicated; } Ok(()) } // ---- 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 }, })? .new_rope .len(); 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) } // ---- command boundaries (kill ring, Q#KR2) -------------------------- /// Record `name` as `fid`'s executing command: `last = this; /// this = name`. Called once per interactive command dispatch — /// keybound commands, the self-insert fallback, menu items, and /// `pmacs.command.invoke_interactive` (`M-x`). pub fn rotate_command(&mut self, fid: FrontendId, name: &str) { let entry = self.command_history.entry(fid).or_default(); entry.last = entry.this.take(); entry.this = Some(name.to_owned()); } /// Break `fid`'s command chain: a non-command input happened (an /// optimistic CRDT edit, a point-moving pointer gesture, an inbound /// paste, an unbound key). Sets `this = None`, so the next /// rotation yields `last = None` and every chain-sensitive check /// (kill append, `M-y`) fails. pub fn break_command_chain(&mut self, fid: FrontendId) { self.command_history.entry(fid).or_default().this = None; } /// The active frontend's previous command — Emacs's `last-command` /// as observed *from inside* the currently-running command (its own /// rotation already happened). #[must_use] pub fn last_command(&self) -> Option<&str> { self.command_history .get(&self.active_frontend)? .last .as_deref() } /// The active frontend's *current* command — Emacs's `this-command`. /// Inside a `buffer.after-edit` hook this names the command that /// produced the edit, which is the **input-origin signal**: /// `"buffer.self-insert"` means the edit was a typed character /// (keybound or optimistic), while a paste / pointer / unbound input /// left it `None`. Per-frontend, so two attached frontends never /// misclassify each other's input. #[must_use] pub fn this_command(&self) -> Option<&str> { self.command_history .get(&self.active_frontend)? .this .as_deref() } // ---- typed-edit provenance (auto-pairing, Q#AP9) --------------------- /// Declare that `fid`'s dispatch is about to invoke /// `buffer.self-insert` for `codepoint`: the next insert primitive /// whose character matches completes the [`TypedEditRecord`]. /// Called by the dispatch fallback only — programmatic /// `pmacs.command.invoke("buffer.self-insert")` deliberately never /// arms, so a hook run after it observes no record. pub fn typed_edit_arm(&mut self, fid: FrontendId, codepoint: char) { self.typed_edit_pending = Some(TypedEditPending { fid, codepoint, record: None, }); } /// Complete the pending typed-edit record from the effective edit, /// if one is armed for this character and hasn't completed yet. /// First match wins: a command body that somehow self-inserts the /// same character twice records the first landing (the one the /// dispatcher's keystroke produced). `context` is the caller's /// pre-edit `(buffer, window)` — the buffer the edit landed in /// even when an intercept switched the active context mid-edit. fn typed_edit_complete( &mut self, ch: char, context: (BufferId, WindowId), requested: Range, requested_len: u64, edit: &Edit, ) { let matches = self.typed_edit_pending.as_ref().is_some_and(|p| { p.record.is_none() && p.codepoint == ch && p.fid == self.active_frontend }); if !matches { return; } // The revision postcondition anchor: if the buffer vanished // (killed mid-command), no record — absence fails closed. let Some(revision) = self .registry .borrow() .get(context.0) .ok() .map(Buffer::revision) else { return; }; let clean = edit.range == requested && edit.inserted_len == requested_len; let record = TypedEditRecord { buffer: context.0, window: context.1, codepoint: ch, requested_start: requested.start, requested_end: requested.end, effective_start: edit.range.start, effective_end: edit.range.end, inserted_len: edit.inserted_len, post_cursor: self.active_window().cursor, clean, revision, }; if let Some(p) = self.typed_edit_pending.as_mut() { p.record = Some(record); } } /// Take back the pending arm at the end of `fid`'s dispatch, /// yielding the completed record (or `None` if the self-insert /// never landed — rejected edit, command error). Always clears the /// pending state: an arm never survives its dispatch cycle. /// /// Postcondition (PR #110 round 1, finding 1): the record is /// yielded only if the edited buffer's revision still equals the /// one captured at completion. A command body that edited again /// after the self-insert — replacing or removing the typed /// character while leaving the cursor in place — produced state /// the record no longer describes; the record dies here, before /// it can be armed for the hook. pub fn typed_edit_finish(&mut self, fid: FrontendId) -> Option { let pending = self.typed_edit_pending.take()?; if pending.fid != fid { return None; } let record = pending.record?; let current = self .registry .borrow() .get(record.buffer) .ok() .map(Buffer::revision); if current != Some(record.revision) { return None; } Some(record) } /// Arm `record` for consumption during the `buffer.after-edit` /// fan-out the caller is about to run. The caller MUST clear the /// slot when the fan-out returns ([`Self::typed_edit_clear_armed`]), /// error paths included — the record must never outlive its hook. pub fn typed_edit_set_armed(&mut self, fid: FrontendId, record: TypedEditRecord) { self.typed_edit_armed = Some((fid, record)); } /// Drop any untaken armed record. Producers call this immediately /// after their `buffer.after-edit` fan-out returns. pub fn typed_edit_clear_armed(&mut self) { self.typed_edit_armed = None; } /// One-shot consume of the armed typed-edit record, per frontend: /// yields the record iff one is armed for the *active* frontend, /// clearing the slot. Second and later takes — including from a /// nested manual `pmacs.hook.run("buffer.after-edit")` — observe /// `None`, as does any context where no producer armed a record /// (paste, programmatic mutation, standalone manual hook runs). pub fn take_typed_edit(&mut self) -> Option { if self.typed_edit_armed.as_ref()?.0 != self.active_frontend { return None; } self.typed_edit_armed.take().map(|(_, rec)| rec) } /// 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) } /// Set the clipboard slot to arbitrary bytes and queue the /// OS-clipboard publish to the acting frontend (kill ring Q#KR1). /// The ring's kills have no region for [`Self::clipboard_copy`] to /// read (`C-k`'s killed line, an appended chain), so the Lua ring /// pushes the exact bytes here. pub fn clipboard_set(&mut self, bytes: Vec) { self.clipboard_set_for(self.active_frontend, bytes); } /// Set and publish clipboard bytes to one authenticated frontend. pub fn clipboard_set_for(&mut self, frontend_id: FrontendId, bytes: Vec) { self.clipboard_slot.clone_from(&bytes); self.pending_clipboard = Some((frontend_id, bytes)); } /// The clipboard slot's current bytes, or `None` when empty (kill /// ring Q#KR6 — the yank-time "did external content arrive via a /// paste since our last kill" check). #[must_use] pub fn clipboard_get(&self) -> Option<&[u8]> { if self.clipboard_slot.is_empty() { None } else { Some(&self.clipboard_slot) } } /// 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()) } /// Whether an explicit buffer requires daemon-owned round-trip input. #[must_use] pub fn buffer_round_trips(&self, buffer_id: BufferId) -> bool { self.round_trip_buffers.contains(&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*"), } } } }; // Q#BP10a: a side window showing the victim is CLOSED, not // redirected to `*scratch*`. Redirecting would strand an // unrelated buffer in the panel slot; the wrapper collapse // restores the prior root, which by construction holds a leaf. let doomed_sides: Vec<(FrontendId, WindowId)> = self .views .iter() .filter_map(|(fid, view)| { let side = view.layout.side_leaf(|id| { self.windows .get(&id) .is_some_and(crate::window::Window::is_side) })?; (self.windows.get(&side)?.buffer_id == buffer_id).then_some((*fid, side)) }) .collect(); for (fid, side) in doomed_sides { self.remove_side_window(fid, side); } { 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 one frontend's active window to a different buffer, allocating /// a fresh [`TextView`] for it without changing global active state. pub fn switch_active_buffer_for( &mut self, frontend_id: FrontendId, 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_for(frontend_id) .ok_or_else(|| format!("frontend {frontend_id:?} has no active window"))?; 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(()) } /// Switch the globally active frontend's active window. pub fn switch_active_buffer(&mut self, buffer_id: BufferId) -> Result<(), String> { self.switch_active_buffer_for(self.active_frontend, buffer_id) } } // --------------------------------------------------------------------------- // 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. /// /// Public because dired needs the *same* canonical form the buffer /// registry keys on (Q#DR2): its buffer-per-directory naming and /// `find_buffer_for_path`'s dedup have to agree, and a Lua-side mirror /// of this function would be a second implementation of a canonical /// form — the tab-width-constants class in miniature. `pmacs.path /// .canonicalize` is this function, not a copy of it. pub 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. pub 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. pub(crate) 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, fold_projection: true, panel_capable: true, frame_geometry: None, panel_hidden: false, }, ); 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().expect("document window may 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); s.active_frontend = fid; 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()); // Removing the selected frontend restores the always-registered // LOCAL view as the ambient fallback. assert_eq!(s.active_frontend, FrontendId::LOCAL); 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(); } // Bottom-panel arc (Q#BP11c): the cap applies independently to // each frontend's own vector, with today's oldest-entry eviction. assert_eq!( s.jump_ring[&FrontendId::LOCAL].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 stale_matches_fail_closed_for_step_and_summary() { // Q#AI8: once an edit marks matches stale, the highlights are // suppressed — stepping and the n/m prompt must fail closed // with them instead of navigating/advertising dead offsets. let mut s = from_bytes(b"foo bar foo"); s.active_window_mut().cursor = 0; s.search_begin(true, false); type_query(&mut s, "foo"); s.search_finish(true); // accept keeps the matches assert_eq!(s.search_match_summary(), (Some(0), 2)); s.active_window_mut().cursor = 0; assert!(s.insert_char('x'), "plain insert lands"); assert_eq!( s.search_match_summary(), (None, 0), "stale counts must not reach the prompt" ); let before = s.cursor(); s.search_step(true); assert_eq!(s.cursor(), before, "stale step is a no-op"); } #[test] fn live_search_origin_translates_through_local_edits() { // Q#AI8: the session origin is a raw byte offset; an edit // before it must shift it (right-gravity) so cancel restores // the same TEXT position, not the same number. let mut s = from_bytes(b"foo bar foo"); s.active_window_mut().cursor = 5; s.search_begin(true, false); // origin byte 5 type_query(&mut s, "foo"); assert_eq!(s.cursor(), 8, "focused the match after the origin"); s.active_window_mut().cursor = 0; assert!(s.insert_char('x')); assert!(s.insert_char('y')); s.search_finish(false); // cancel assert_eq!( s.cursor(), 7, "cancel restores the translated origin (5 + 2 inserted bytes)" ); } #[test] fn live_search_recompute_focuses_from_the_translated_origin() { let mut s = from_bytes(b"foo bar foo"); s.active_window_mut().cursor = 1; s.search_begin(true, false); // origin byte 1 type_query(&mut s, "fo"); assert_eq!(s.cursor(), 8, "first match at/after the origin"); s.active_window_mut().cursor = 0; assert!(s.insert_char('x')); assert!(s.insert_char('y')); assert!(s.insert_char('z')); // "xyzfoo bar foo": origin 1 -> 4. Growing the query recomputes // and must focus from the TRANSLATED origin: the match at 11, // not the pre-edit offset 1's neighbor at 3. type_query(&mut s, "o"); assert_eq!( s.cursor(), 11, "recompute focuses the first match at/after the translated origin" ); } #[test] fn notify_buffer_edit_marks_stale_and_translates_the_origin() { // Q#AI8 at the direct-edit seam (Lua mutators / applied CRDT // ops): notify_buffer_edit must invalidate matches and shift // the live origin exactly like apply_active_edit does. let mut s = from_bytes(b"foo bar foo"); let bid = s.active_buffer_id(); s.active_window_mut().cursor = 1; s.search_begin(true, false); // origin byte 1 type_query(&mut s, "foo"); let edit = { let mut reg = s.registry.borrow_mut(); let buffer = reg.get_mut(bid).expect("buffer"); buffer .apply_edit(crate::buffer::EditOp::Insert { pos: 0, bytes: b"zz", }) .expect("direct insert") }; s.notify_buffer_edit(bid, &edit); assert!( s.search_store.lock().expect("store").is_stale(bid), "direct edits mark the matches stale" ); s.search_finish(false); // cancel assert_eq!( s.cursor(), 3, "cancel restores the origin translated through the direct edit" ); } #[test] fn undo_and_redo_stale_accepted_search_navigation() { // Q#AI8 (PR #109 round 1): history edits move bytes like any // other edit — undo/redo must invalidate search state. let mut s = from_bytes(b"foo bar foo"); s.active_window_mut().cursor = 0; assert!(s.insert_char('x')); // the history entry: "xfoo bar foo" s.search_begin(true, false); type_query(&mut s, "foo"); s.search_finish(true); // accept assert_eq!(s.search_match_summary(), (Some(0), 2)); s.undo(); // back to "foo bar foo": offsets moved assert_eq!( s.search_match_summary(), (None, 0), "undo stales the accepted matches" ); let before = s.cursor(); s.search_step(true); assert_eq!(s.cursor(), before, "stale step is a no-op after undo"); // Redo the same way: refresh the matches first (a fresh set // clears staleness), then redo must stale them again. s.search_begin(true, false); type_query(&mut s, "foo"); s.search_finish(true); assert_eq!(s.search_match_summary().1, 2); s.redo(); // forward to "xfoo bar foo" again assert_eq!( s.search_match_summary(), (None, 0), "redo stales the accepted matches" ); } #[test] fn undo_translates_the_live_search_origin() { let mut s = from_bytes(b"foo bar foo"); s.active_window_mut().cursor = 0; assert!(s.insert_char('x')); // "xfoo bar foo" s.active_window_mut().cursor = 5; s.search_begin(true, false); // origin 5 ('b' of "bar") type_query(&mut s, "foo"); s.undo(); // removes the 'x' at 0: origin must shift to 4 s.search_finish(false); // cancel assert_eq!( s.cursor(), 4, "cancel lands on the origin translated through the undo" ); } #[test] fn origin_translates_through_deletes_on_both_paths() { // Dispatch path (apply_active_edit): backspace before the // origin. let mut s = from_bytes(b"foo bar foo"); s.active_window_mut().cursor = 5; s.search_begin(true, false); // origin 5 type_query(&mut s, "foo"); s.active_window_mut().cursor = 2; s.backspace(); // deletes byte 1: origin 5 -> 4 s.search_finish(false); assert_eq!(s.cursor(), 4, "origin shifted left by the deleted byte"); // Direct-notification path: a delete edit through // notify_buffer_edit. 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); // origin 5 type_query(&mut s, "foo"); let edit = { let mut reg = s.registry.borrow_mut(); let buffer = reg.get_mut(bid).expect("buffer"); buffer .apply_edit(crate::buffer::EditOp::Delete { range: Range::new(0, 2), }) .expect("direct delete") }; s.notify_buffer_edit(bid, &edit); s.search_finish(false); assert_eq!( s.cursor(), 3, "origin shifted left by the two directly deleted bytes" ); } #[test] fn active_search_fails_closed_while_stale_and_recovers_on_retype() { // Q#AI8 during a LIVE session: an external edit mid-search // makes step and summary fail closed; the next pattern // keystroke recomputes (set clears staleness) and resumes. 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, "fo"); assert_eq!(s.search_match_summary().1, 2); let edit = { let mut reg = s.registry.borrow_mut(); let buffer = reg.get_mut(bid).expect("buffer"); buffer .apply_edit(crate::buffer::EditOp::Insert { pos: 0, bytes: b"zz", }) .expect("direct insert") }; s.notify_buffer_edit(bid, &edit); assert_eq!( s.search_match_summary(), (None, 0), "summary fails closed mid-search" ); let before = s.cursor(); s.search_step(true); assert_eq!(s.cursor(), before, "step fails closed mid-search"); // Growing the query recomputes against the current text. type_query(&mut s, "o"); assert_eq!( s.search_match_summary().1, 2, "the next pattern keystroke refreshes the match set" ); assert_eq!(s.cursor(), 2, "focus lands from the translated origin"); s.search_step(true); assert_eq!(s.cursor(), 10, "stepping resumes after the refresh"); } #[test] fn search_backspace_widens_the_match_set() { let mut s = from_bytes(b"fo foo food"); s.active_window_mut().cursor = 0; s.search_begin(true, false); type_query(&mut s, "foo"); // matches "foo" at 3..6, 7..10 assert_eq!(s.search_match_summary().1, 2); s.search_backspace(); // query "fo" assert_eq!(s.search_query(), "fo"); assert_eq!(s.search_match_summary().1, 3); } #[test] fn search_smart_case_is_case_sensitive_with_uppercase() { let mut s = from_bytes(b"Foo foo FOO"); s.active_window_mut().cursor = 0; s.search_begin(true, false); type_query(&mut s, "Foo"); // uppercase => case-sensitive assert_eq!(s.search_match_summary().1, 1); s.search_backspace(); s.search_backspace(); s.search_backspace(); type_query(&mut s, "foo"); // lowercase => smart-case folds all assert_eq!(s.search_match_summary().1, 3); } #[test] fn edit_marks_accepted_matches_stale() { let mut s = from_bytes(b"foo foo"); let bid = s.active_buffer_id(); s.active_window_mut().cursor = 0; s.search_begin(true, false); type_query(&mut s, "foo"); s.search_finish(true); // matches persist after accept assert!(!s.search_store.lock().expect("store").is_stale(bid)); s.insert_char('x'); // any edit invalidates the match offsets assert!( s.search_store.lock().expect("store").is_stale(bid), "an edit marks the buffer's matches stale (linger fix)" ); } // ---- regex search (Q#RX3) ------------------------------------------ #[test] fn regex_search_matches_pattern() { let mut s = from_bytes(b"a1 b2 c3"); s.active_window_mut().cursor = 0; s.search_begin(true, true); assert!(s.search_is_regex()); type_query(&mut s, r"\d"); assert_eq!(s.search_match_summary().1, 3, "\\d matches 1, 2, 3"); assert!(!s.search_is_invalid()); } #[test] fn regex_invalid_pattern_flags_and_recovers() { let mut s = from_bytes(b"foo"); s.active_window_mut().cursor = 0; s.search_begin(true, true); type_query(&mut s, "fo("); // unbalanced group mid-typing assert!(s.search_is_invalid(), "incomplete group is invalid"); assert_eq!(s.search_match_summary().1, 0, "invalid ⇒ no matches"); type_query(&mut s, "o)"); // completes the group: regex fo(o) → "foo" assert!(!s.search_is_invalid(), "valid pattern recovers"); assert_eq!(s.search_match_summary().1, 1); } #[test] fn toggle_regex_reinterprets_the_query() { let mut s = from_bytes(b"a.b axb"); s.active_window_mut().cursor = 0; s.search_begin(true, false); // literal type_query(&mut s, "a.b"); assert!(!s.search_is_regex()); assert_eq!( s.search_match_summary().1, 1, "literal '.' matches only a.b" ); s.search_toggle_regex(); // → regex assert!(s.search_is_regex()); assert_eq!(s.search_match_summary().1, 2, "regex '.' also matches axb"); s.search_toggle_regex(); // back to literal assert!(!s.search_is_regex()); assert_eq!(s.search_match_summary().1, 1); } // ---- in-buffer completion popup (Arc 1a) -------------------------------- fn text_of(s: &EditorCore) -> String { let id = s.active_buffer_id(); let reg = s.registry.borrow(); let buf = reg.get(id).expect("active buffer present"); let mut bytes = vec![0u8; buf.len() as usize]; if !bytes.is_empty() { buf.snapshot_rope().slice(0, buf.len(), &mut bytes); } String::from_utf8(bytes).expect("test buffers are UTF-8") } fn open_popup(s: &mut EditorCore, anchor: u64, prefix: &str, insert_text: &str) { let state = crate::completion::CompletionPopupState::new( s.active_buffer_id(), anchor, prefix.to_owned(), vec![crate::completion::PopupCandidate { label: insert_text.to_owned(), kind: crate::completion::CompletionItemKind::Text, detail: None, insert_text: insert_text.to_owned(), }], 1, ) .expect("non-empty candidate list"); s.completion_popup_open(state); } #[test] fn completion_popup_open_attaches_self_suppressing_overlay() { let mut s = from_bytes(b"he\n"); s.active_window_mut().cursor = 2; open_popup(&mut s, 0, "he", "hello"); assert!(s.completion_popup_is_open()); assert!( s.active_window() .overlay_kinds() .contains(&"completion-popup") ); // Re-opening dedups the overlay by kind. open_popup(&mut s, 0, "he", "hello"); let kinds = s.active_window().overlay_kinds(); assert_eq!( kinds.iter().filter(|k| **k == "completion-popup").count(), 1 ); } #[test] fn completion_popup_validate_survives_word_growth_and_empty_prefix() { let mut s = from_bytes(b"he world\n"); s.active_window_mut().cursor = 2; open_popup(&mut s, 0, "he", "hello"); s.completion_popup_validate(); assert!(s.completion_popup_is_open(), "prefix `he` holds"); // Typing extends the word: still valid. s.insert_char('l'); s.completion_popup_validate(); assert!(s.completion_popup_is_open(), "prefix `hel` holds"); // Trigger-char shape (cursor == anchor, empty prefix) holds too. s.completion_popup_close(); s.active_window_mut().cursor = 2; open_popup(&mut s, 2, "", "llo"); s.completion_popup_validate(); assert!(s.completion_popup_is_open(), "empty prefix at anchor holds"); } #[test] fn completion_popup_validate_closes_when_invariant_breaks() { let mut s = from_bytes(b"he world\n"); s.active_window_mut().cursor = 2; open_popup(&mut s, 0, "he", "hello"); // Cursor moved past the word: `[anchor..cursor]` spans a space. s.active_window_mut().cursor = 4; s.completion_popup_validate(); assert!(!s.completion_popup_is_open(), "non-word bytes close it"); // Cursor moved before the anchor. s.active_window_mut().cursor = 2; open_popup(&mut s, 2, "", "x"); s.active_window_mut().cursor = 1; s.completion_popup_validate(); assert!(!s.completion_popup_is_open(), "cursor < anchor closes it"); // Session bound to a buffer that is not the active one. let other = s.registry.borrow_mut().create("*other*"); let state = crate::completion::CompletionPopupState::new( other, 0, String::new(), vec![crate::completion::PopupCandidate { label: "x".into(), kind: crate::completion::CompletionItemKind::Text, detail: None, insert_text: "x".into(), }], 1, ) .unwrap(); s.completion_popup_open(state); s.completion_popup_validate(); assert!(!s.completion_popup_is_open(), "wrong buffer closes it"); } #[test] fn completion_popup_accept_replaces_prefix_as_one_undo_step() { let mut s = from_bytes(b"he and more\n"); s.active_window_mut().cursor = 2; open_popup(&mut s, 0, "he", "hello_world"); assert!(s.completion_popup_accept()); assert_eq!(text_of(&s), "hello_world and more\n"); assert_eq!(s.active_window().cursor, 11); assert!(!s.completion_popup_is_open(), "accept closes the popup"); // Q#C7: the replace is a single edit — one undo restores the // original text (not an intermediate delete-then-insert state). s.undo(); assert_eq!(text_of(&s), "he and more\n"); } #[test] fn completion_popup_accept_empty_prefix_inserts_at_anchor() { let mut s = from_bytes(b"x.\n"); s.active_window_mut().cursor = 2; open_popup(&mut s, 2, "", "method"); assert!(s.completion_popup_accept()); assert_eq!(text_of(&s), "x.method\n"); assert_eq!(s.active_window().cursor, 8); } #[test] fn completion_popup_accept_is_noop_when_session_stale() { let mut s = from_bytes(b"he world\n"); s.active_window_mut().cursor = 2; open_popup(&mut s, 0, "he", "hello"); // Simulate a race: the cursor left the word before accept ran. s.active_window_mut().cursor = 5; assert!(!s.completion_popup_accept()); assert_eq!(text_of(&s), "he world\n", "buffer untouched"); assert!(!s.completion_popup_is_open(), "stale accept still closes"); } #[test] fn completion_popup_validate_closes_on_window_focus_change() { // Two splits on the SAME buffer: the session is window-scoped, // so moving focus (buffer unchanged!) must invalidate it --- // this is also what keeps the persistent overlay in the other // split from painting a popup it doesn't own. let mut s = from_bytes(b"he world\n"); s.split_active(Orientation::Horizontal, true); s.active_window_mut().cursor = 2; open_popup(&mut s, 0, "he", "hello"); s.completion_popup_validate(); assert!(s.completion_popup_is_open(), "session holds in its window"); s.focus_next(); s.completion_popup_validate(); assert!( !s.completion_popup_is_open(), "focus change closes the session even with the same buffer" ); } // ---- query-replace core (Arc 2) ---------------------------------------- #[test] fn query_replace_all_replaces_and_counts() { let mut s = from_bytes(b"foo foo foo\n"); s.query_replace_begin("foo".into(), "bar".into(), false); assert!(s.query_replace_active(), "session opens on the first match"); s.query_replace_all(); assert_eq!(text_of(&s), "bar bar bar\n"); assert!(!s.query_replace_active(), "! finishes the session"); assert_eq!(s.status, "Replaced 3 occurrences"); } #[test] fn query_replace_growing_replacement_does_not_loop() { // The a→aa shape: replacing must not re-match the inserted text. let mut s = from_bytes(b"a a a\n"); s.query_replace_begin("a".into(), "aa".into(), false); s.query_replace_all(); assert_eq!(text_of(&s), "aa aa aa\n", "each 'a' replaced exactly once"); assert_eq!(s.status, "Replaced 3 occurrences"); } #[test] fn query_replace_empty_to_deletes() { let mut s = from_bytes(b"a-b-c\n"); s.query_replace_begin("-".into(), String::new(), false); s.query_replace_all(); assert_eq!(text_of(&s), "abc\n", "empty replacement deletes matches"); } #[test] fn query_replace_skip_then_replace_is_selective() { let mut s = from_bytes(b"x x x\n"); s.query_replace_begin("x".into(), "y".into(), false); s.query_replace_skip(); // leave the first x s.query_replace_replace(); // replace the second x, advance to third s.query_replace_replace_and_quit(); // replace the third, quit assert_eq!(text_of(&s), "x y y\n", "first skipped, rest replaced"); assert!(!s.query_replace_active()); } #[test] fn query_replace_nothing_matched_restores_origin() { let mut s = from_bytes(b"hello world\n"); s.active_window_mut().cursor = 6; // on "world" s.query_replace_begin("zzz".into(), "q".into(), false); assert!( !s.query_replace_active(), "no match → session never stays open" ); assert_eq!(text_of(&s), "hello world\n", "buffer untouched"); assert_eq!(s.active_window().cursor, 6, "origin cursor restored"); assert_eq!(s.status, "No matches for 'zzz'"); } #[test] fn query_replace_starts_from_cursor_forward() { let mut s = from_bytes(b"k _ k\n"); s.active_window_mut().cursor = 2; // between the two k's s.query_replace_begin("k".into(), "K".into(), false); s.query_replace_all(); assert_eq!(text_of(&s), "k _ K\n", "only the match at/after point"); } #[test] fn query_replace_aborts_when_active_buffer_changes() { // The wrong-buffer merge-blocker: a session started in buffer X // must never apply its match to a buffer that became active // mid-session. Focus drifts (a click / cross-frontend key), then // the next replace key aborts safely instead of corrupting. let mut s = from_bytes(b"foo foo\n"); let x = s.active_buffer_id(); s.query_replace_begin("foo".into(), "bar".into(), false); assert!(s.query_replace_active()); // Switch the active buffer to an unrelated one (focus drift). let y = s.registry.borrow_mut().create("*other*"); { let reg = s.registry.borrow(); let buf = reg.get(y).unwrap(); let tv = crate::text_view::TextView::new(buf); drop(reg); let win = s.active_window_mut(); win.buffer_id = y; win.text_view = tv; win.cursor = 0; } assert_eq!(s.active_buffer_id(), y); s.query_replace_replace(); // the y/replace key while drifted assert!(!s.query_replace_active(), "drift aborts the session"); assert_eq!(s.status, "query-replace aborted: active buffer changed"); // Neither buffer was mutated by the aborted replace. { let reg = s.registry.borrow(); let bx = reg.get(x).unwrap(); let mut xb = vec![0u8; bx.len() as usize]; bx.snapshot_rope().slice(0, bx.len(), &mut xb); assert_eq!(&xb, b"foo foo\n", "origin buffer X untouched"); let by = reg.get(y).unwrap(); assert_eq!(by.len(), 0, "unrelated buffer Y untouched"); } } #[test] fn query_replace_regex_replaces_and_invalid_refuses() { let mut s = from_bytes(b"a1 b2 c3\n"); s.query_replace_begin("[0-9]".into(), "#".into(), true); s.query_replace_all(); assert_eq!(text_of(&s), "a# b# c#\n", "regex matches digits"); // Invalid regex refuses to start and leaves a status. let mut s2 = from_bytes(b"abc\n"); s2.query_replace_begin("(unclosed".into(), "x".into(), true); assert!( !s2.query_replace_active(), "invalid regex never opens a session" ); assert!(s2.status.starts_with("Invalid regex")); } }