From 58b68f6ac07d26c93df19292c97d0adfc5989fbc Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 11:15:26 -0400 Subject: [PATCH] search: TUI incremental isearch input (Q#SR5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the live-typing half of in-buffer search for the terminal frontend, on a frontend-agnostic core so the GPU (next commit) can share it. EditorCore gains a `search: Option` (query + origin cursor + direction) and the `search_*` methods that drive it: begin records the origin, input_char/backspace re-run `find_all` against the origin buffer and refocus the match nearest the origin (failing searches anchor the cursor back at the origin), step walks the store's active match (wrapping, also usable post-accept), and finish either keeps the cursor + matches (accept) or restores the origin and clears them (cancel). The matches live in the shared `search_store`, so the decorations producer and the TUI SearchView light up live as you type. Input routing is intercepted in `EditorState::dispatch_key`: while a search runs, every key flows through `dispatch_search_key` (SearchKey::from_chord) instead of the global keymap — printable chars extend the query, C-s/C-r (and Down/Up) step, RET accepts, C-g/Esc cancel, BS shortens. This is the same dispatch path the daemon's `FrontendEvent::Key` round-trip uses, so the daemon-side search already works; the GPU just needs to route keys + show the prompt (commit 4). The TUI paints an `I-search: (n/m)` prompt on the bottom row while keeping the terminal cursor in the buffer at the active match. C-s / C-r start the search (search.forward / search.backward Lua commands → ed.search_start). Both keys were free in the default map (save is C-x C-s, redo is C-x r), so isearch lands without disturbing the CUA / Emacs editing keys — no cursor.right rebind needed (the framing doc had flagged C-f for veto; C-s is cleaner and Emacs-faithful). Any edit now marks the buffer's matches stale in apply_active_edit (M11.8), closing the headline "stale-after-edit linger" bet: accepted highlights vanish the moment the text they described changes, rather than painting at wrong offsets. Tests: EditorCore-level (begin/type/step/wrap/focus-from-origin/ cancel/accept/backspace/smart-case/stale-on-edit) and dispatch- level acceptance (C-s drives the whole loop; Esc restores; query keys never self-insert). Co-Authored-By: Claude Opus 4.8 (1M context) --- builtin/commands/default.lua | 15 ++ builtin/keymaps/default.lua | 10 + src/editor.rs | 226 +++++++++++++++++++++- src/editor_core.rs | 352 +++++++++++++++++++++++++++++++++++ src/lua_bindings.rs | 43 +++++ 5 files changed, 645 insertions(+), 1 deletion(-) diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index 8b4c992..cb71f68 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -117,6 +117,21 @@ cmd { name = "buffer.self-insert", description = "Insert the codepoint argument at the cursor, replacing the active region.", fn = function(codepoint) ed.insert_char_over_region(codepoint) end } +-- Incremental search --------------------------------------------------------- +-- +-- C-s / C-r begin a live in-buffer isearch: the match under the cursor +-- highlights as you type, the same key steps to the next/previous +-- match, RET accepts (keeping the highlights until the next edit), and +-- C-g / Esc restore the pre-search cursor. While a search is running +-- every keystroke is intercepted in Rust (dispatch_search_key), so +-- these commands only run to *start* a search from an idle keymap. +cmd { name = "search.forward", + description = "Start an incremental search forward from the cursor.", + fn = function() ed.search_start(true) end } +cmd { name = "search.backward", + description = "Start an incremental search backward from the cursor.", + fn = function() ed.search_start(false) end } + -- History -------------------------------------------------------------------- cmd { name = "buffer.undo", description = "Undo the most recent edit.", diff --git a/builtin/keymaps/default.lua b/builtin/keymaps/default.lua index 16adab5..46fe6c4 100644 --- a/builtin/keymaps/default.lua +++ b/builtin/keymaps/default.lua @@ -52,6 +52,16 @@ bind("C-d", "buffer.delete-forward") bind("RET", "buffer.newline") bind("TAB", "buffer.tab") +-- Incremental search --------------------------------------------------------- +-- +-- C-s / C-r start a live isearch (forward / backward). Both keys are +-- free in the default map (save is C-x C-s, redo is C-x r), so this +-- adds isearch without colliding with the CUA / Emacs editing keys. +-- Once a search is running, C-s / C-r step to the next / previous +-- match; that interception happens in Rust, so it needs no binding. +bind("C-s", "search.forward") +bind("C-r", "search.backward") + -- CUA-style word-level deletion (the same shortcuts users expect from -- IDEs, browsers, terminals on Linux/Windows). C-BS deletes back to -- the start of the previous word; C-DEL deletes forward through the diff --git a/src/editor.rs b/src/editor.rs index 5016b76..204325f 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -484,6 +484,17 @@ impl EditorState { core.active_frontend = frontend_id; } + // Incremental-search interception: while an isearch is running, + // every key routes through the search handler (the global keymap + // is shadowed, like the minibuffer). Printable chars extend the + // query; C-s / C-r step; RET accepts; C-g / Esc cancel. This is + // the shared input path for both frontends — the daemon's + // `FrontendEvent::Key` round-trip lands here too. + if self.core.borrow().search_active() { + self.dispatch_search_key(chord); + return; + } + // Minibuffer interception: when a prompt is active, every key // routes through the minibuffer's hardcoded handler. The main // editor's keymap is bypassed; the user can still cancel with @@ -615,6 +626,33 @@ impl EditorState { } } + /// Hardcoded handler for keys delivered while an incremental search + /// is active. The global keymap is shadowed (like the minibuffer), + /// so these chords are fixed: + /// + /// * `C-s` / `Down` --- step to the next match (wraps). + /// * `C-r` / `Up` --- step to the previous match (wraps). + /// * `RET` --- accept (keep cursor + highlights). + /// * `C-g` / `Esc` --- cancel (restore origin cursor). + /// * `BS` --- shorten the query by one char. + /// * a printable char --- extend the query. + /// + /// Unrecognized chords are swallowed (an active isearch eats every + /// keystroke, matching Emacs). The next/prev chords mirror the + /// entry bindings (`search.forward` / `search.backward`) so the + /// same key that started the search repeats it. + fn dispatch_search_key(&mut self, chord: Chord) { + match SearchKey::from_chord(chord) { + SearchKey::Next => self.core.borrow_mut().search_step(true), + SearchKey::Prev => self.core.borrow_mut().search_step(false), + SearchKey::Accept => self.core.borrow_mut().search_finish(true), + SearchKey::Cancel => self.core.borrow_mut().search_finish(false), + SearchKey::Backspace => self.core.borrow_mut().search_backspace(), + SearchKey::Insert(ch) => self.core.borrow_mut().search_input_char(ch), + SearchKey::Ignore => {} + } + } + fn with_minibuffer(&mut self, f: F) { f(&mut self.core.borrow_mut().minibuffer); } @@ -1161,6 +1199,65 @@ fn process_event(state: &mut EditorState, ev: Event, term_size: crate::cell::Cel } } +/// Decoded action for a key delivered while an incremental search is +/// active. Mirrors [`crate::minibuffer::MinibufferAction`]: the +/// bindings are hardcoded (not user-configurable) because isearch +/// shadows the global keymap; changes happen by extending this enum. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum SearchKey { + /// Step to the next match (C-s / Down). + Next, + /// Step to the previous match (C-r / Up). + Prev, + /// Accept: keep cursor + highlights (RET). + Accept, + /// Cancel: restore the origin cursor (C-g / Esc). + Cancel, + /// Shorten the query by one character (BS). + Backspace, + /// Extend the query with a printable character. + Insert(char), + /// Unhandled --- swallowed without complaint. + Ignore, +} + +impl SearchKey { + /// Decode `chord` into an isearch action. The next/prev chords + /// match the entry bindings (`C-s` forward, `C-r` backward) so the + /// search-starting key repeats the search; arrow keys offer a + /// modifier-free alternative. + fn from_chord(chord: Chord) -> Self { + let ctrl = chord.modifiers.contains(KeyModifiers::CONTROL); + let alt = chord.modifiers.contains(KeyModifiers::ALT); + + if !ctrl && !alt { + match chord.code { + KeyCode::Enter => return Self::Accept, + KeyCode::Esc => return Self::Cancel, + KeyCode::Backspace => return Self::Backspace, + KeyCode::Down => return Self::Next, + KeyCode::Up => return Self::Prev, + KeyCode::Char(ch) => return Self::Insert(ch), + _ => return Self::Ignore, + } + } + if ctrl + && !alt + && let KeyCode::Char(c) = chord.code + { + return match c { + 's' => Self::Next, + 'r' => Self::Prev, + 'm' => Self::Accept, + 'g' => Self::Cancel, + 'h' => Self::Backspace, + _ => Self::Ignore, + }; + } + Self::Ignore + } +} + /// Paint one full frame into `grid` and return the desired terminal /// cursor position. /// @@ -1292,7 +1389,14 @@ pub fn paint_frame( paint_status_line(grid, core, &state.lua_host, &state.dispatcher, term_size); - let mb_cursor_col = if core.minibuffer.is_active() { + // An active isearch owns the bottom row (its prompt + match + // readout), but the terminal cursor stays in the buffer at the + // active match so the eye follows the search — so paint the prompt + // and fall through to the buffer-cursor placement below. + let mb_cursor_col = if core.search_active() { + paint_search_prompt(grid, core, term_size); + None + } else if core.minibuffer.is_active() { Some(paint_minibuffer(grid, core, term_size)) } else { None @@ -1614,6 +1718,61 @@ fn paint_minibuffer( cursor_col.min(max.saturating_sub(1)) } +/// Paint the incremental-search prompt on the bottom row: +/// `I-search: (n/m)`. Backward searches read `I-search +/// backward:`; a non-empty query with no matches reads `[no match]`. +/// Overwrites the status line painted just before it. The terminal +/// cursor is *not* returned here — it stays in the buffer at the +/// active match (see [`paint_frame`]). +fn paint_search_prompt( + grid: &mut crate::cell::CellGrid<'_>, + core: &EditorCore, + term_size: crate::cell::CellSize, +) { + let prompt = if core.search_forward() { + "I-search: " + } else { + "I-search backward: " + }; + let query = core.search_query(); + let (active, total) = core.search_match_summary(); + let suffix = if query.is_empty() { + String::new() + } else if total == 0 { + " [no match]".to_string() + } else { + format!(" ({}/{})", active.map_or(0, |a| a + 1), total) + }; + + let row = term_size.rows - 1; + let max = term_size.cols; + let mut col: u32 = 0; + let put = |grid: &mut crate::cell::CellGrid<'_>, col: &mut u32, ch: char| { + if *col < max { + let cell = grid.at(CellCoord::new(row, *col)); + cell.glyph = crate::cell::Glyph::Char(ch); + cell.style = crate::cell::Style::default(); + *col += 1; + } + }; + for ch in prompt.chars() { + put(grid, &mut col, ch); + } + for ch in query.chars() { + put(grid, &mut col, ch); + } + for ch in suffix.chars() { + put(grid, &mut col, ch); + } + // Clear the remainder of the row (the status line underneath used + // reverse video; blank it with the default style). + for c in col..max { + let cell = grid.at(CellCoord::new(row, c)); + cell.glyph = crate::cell::Glyph::Char(' '); + cell.style = crate::cell::Style::default(); + } +} + /// Build the global status (echo area) row: pure ephemeral state. /// /// Per-window facts (buffer name, modified marker, cursor coord, @@ -1876,6 +2035,71 @@ mod tests { assert!(s.core.borrow().status.contains("no file")); } + // ---- incremental search via dispatch (Q#SR5) --------------------------- + + fn type_chars(s: &mut EditorState, text: &str) { + for c in text.chars() { + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::NONE)); + } + } + + #[test] + fn isearch_dispatch_highlights_steps_and_accepts() { + let mut s = fresh_with(b"foo bar foo baz foo"); + s.core.borrow_mut().active_window_mut().cursor = 0; + // C-s begins the search (via the search.forward command). + s.dispatch_key(FrontendId::LOCAL, ctrl('s')); + assert!(s.core.borrow().search_active()); + // Typing extends the query; the first match is focused. + type_chars(&mut s, "foo"); + assert_eq!(s.core.borrow().search_match_summary(), (Some(0), 3)); + assert_eq!(s.core.borrow().cursor(), 0); + // C-s now steps (intercepted) rather than re-running the command. + s.dispatch_key(FrontendId::LOCAL, ctrl('s')); + assert_eq!(s.core.borrow().cursor(), 8); + // RET accepts: search ends, cursor holds, matches persist. + s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Enter)); + assert!(!s.core.borrow().search_active()); + assert_eq!(s.core.borrow().cursor(), 8); + let bid = s.core.borrow().active_buffer_id(); + assert!( + s.core + .borrow() + .search_store + .lock() + .expect("store") + .for_buffer(bid) + .is_some(), + "accepted matches stay for highlight + navigation" + ); + } + + #[test] + fn isearch_dispatch_esc_restores_origin() { + let mut s = fresh_with(b"foo bar foo"); + s.core.borrow_mut().active_window_mut().cursor = 5; + s.dispatch_key(FrontendId::LOCAL, ctrl('s')); + type_chars(&mut s, "foo"); + assert_eq!(s.core.borrow().cursor(), 8); + // Esc cancels: the pre-search cursor is restored, no edit happened. + s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Esc)); + assert!(!s.core.borrow().search_active()); + assert_eq!(s.core.borrow().cursor(), 5); + assert_eq!(s.core.borrow().active_buffer_len(), 11); + } + + #[test] + fn isearch_dispatch_keys_do_not_self_insert() { + let mut s = fresh_with(b"foo"); + s.core.borrow_mut().active_window_mut().cursor = 3; + s.dispatch_key(FrontendId::LOCAL, ctrl('s')); + type_chars(&mut s, "foo"); + // While searching, printable keys feed the query — the buffer is + // untouched (no self-insert). + assert_eq!(s.core.borrow().active_buffer_len(), 3); + assert_eq!(s.core.borrow().search_query(), "foo"); + } + // ---- T M11.6 — DispatchIdle --------------------------------------------- #[test] diff --git a/src/editor_core.rs b/src/editor_core.rs index b993951..7cf4c9b 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -57,6 +57,29 @@ pub enum CrdtOpOrigin { DaemonKey, } +/// Live state of an in-progress incremental search (Q#SR5). +/// +/// Present only while an isearch is running (`EditorCore::search`); +/// `None` otherwise. Holds the query as typed so far plus the cursor +/// origin to restore on cancel. The *matches* themselves live in +/// [`crate::search::SearchStore`] (shared with the decorations +/// producer and the TUI overlay); this struct is the per-session +/// input state that drives `find_all`. +#[derive(Clone, Debug)] +pub struct SearchSession { + /// The query as typed so far. Each edit re-runs `find_all`. + query: String, + /// Buffer + cursor position when the search began. `C-g` / `Esc` + /// restores this; `RET` keeps the current (match) cursor. The + /// buffer id also anchors `find_all` to the buffer the search + /// started in. + origin: (BufferId, Position), + /// Direction of the most recent step/begin. `true` = forward. + /// Drives the prompt label ("I-search" vs "I-search backward") + /// and the wrap direction of an empty-query repeat. + forward: bool, +} + /// The world state mutated by editor commands. pub struct EditorCore { /// Shared buffer registry. The registry is the canonical owner @@ -133,6 +156,13 @@ pub struct EditorCore { /// ([`crate::semantic_render`]) and the TUI search overlay. /// Cheaply cloneable (`Arc`); shared with both readers. pub search_store: crate::search::SharedSearchStore, + /// Live incremental-search session (Q#SR5), or `None` when no + /// search is running. Frontend-agnostic: the TUI run loop and the + /// daemon's `FrontendEvent::Key` path both drive it through the + /// same `search_*` methods, so isearch behaves identically in the + /// terminal and GPU frontends. Only the *prompt surface* differs + /// (TUI bottom row vs GPU status band). + pub search: Option, } impl EditorCore { @@ -168,6 +198,7 @@ impl EditorCore { pending_crdt_ops: Vec::new(), jump_ring: Vec::new(), search_store: crate::search::make_shared_store(), + search: None, } } @@ -476,6 +507,179 @@ impl EditorCore { false } + // ---- incremental search (Q#SR5) ---------------------------------------- + // + // Frontend-agnostic isearch driven entirely through these methods. + // The TUI's `dispatch_search_key` and (later) the GPU's round-tripped + // keystrokes both call into here, so search behaves identically in + // both frontends. Matches live in `search_store` (shared with the + // decorations producer and the TUI overlay); `search` holds the + // live query + origin. + + /// `true` iff an incremental search is in progress. + #[must_use] + pub fn search_active(&self) -> bool { + self.search.is_some() + } + + /// The current isearch query (empty when no search is running). + #[must_use] + pub fn search_query(&self) -> &str { + self.search.as_ref().map_or("", |s| s.query.as_str()) + } + + /// Direction of the active search (`true` = forward). Defaults to + /// forward when no search is running — callers should gate on + /// [`Self::search_active`] first. + #[must_use] + pub fn search_forward(&self) -> bool { + self.search.as_ref().is_none_or(|s| s.forward) + } + + /// `(active_index, total)` for the active buffer's matches, for the + /// prompt's "n/m" readout. `active_index` is 0-based and `None` + /// when there are no matches. + #[must_use] + pub fn search_match_summary(&self) -> (Option, usize) { + let bid = self.active_buffer_id(); + let guard = self + .search_store + .lock() + .expect("search store mutex poisoned"); + guard + .for_buffer(bid) + .map_or((None, 0), |s| (s.active_index(), s.len())) + } + + /// Begin an incremental search anchored at the active buffer + + /// cursor. `forward` sets the initial step direction. 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) { + if self.search.is_some() { + return; + } + let origin = (self.active_buffer_id(), self.cursor()); + self.search = Some(SearchSession { + query: String::new(), + origin, + forward, + }); + } + + /// 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 bytes = self.buffer_bytes(bid); + let matches = crate::search::find_all(&bytes, &query); + let focus = { + let mut guard = self + .search_store + .lock() + .expect("search store mutex poisoned"); + guard.set(bid, query, matches); + guard.focus_from(bid, origin_byte) + }; + let target = focus.map_or(origin_byte, |range| range.start); + self.search_place_cursor(target); + } + + /// Step the active buffer's match focus forward/backward (wrapping) + /// and move the cursor to it. Operates on [`Self::search_store`] + /// directly, so it works both during a live session (C-s / C-r) + /// and after accept (a `search.next` navigation command). A no-op + /// when the active buffer has no matches. + pub fn search_step(&mut self, forward: bool) { + if let Some(session) = self.search.as_mut() { + session.forward = forward; + } + let bid = self.active_buffer_id(); + let stepped = { + let mut guard = self + .search_store + .lock() + .expect("search store mutex poisoned"); + guard.step(bid, forward) + }; + if let Some(range) = stepped { + self.search_place_cursor(range.start); + } + } + + /// End the active search. `accept` keeps the cursor at the current + /// match and leaves the matches in the store (so they stay + /// highlighted, and `search.next` can resume, until the next edit + /// marks them stale). Cancel restores the origin cursor and clears + /// the matches. A no-op when no search is running. + pub fn search_finish(&mut self, accept: bool) { + let Some(session) = self.search.take() else { + return; + }; + if accept { + return; + } + let (bid, origin_byte) = session.origin; + if self.active_buffer_id() == bid { + self.search_place_cursor(origin_byte); + } + self.search_store + .lock() + .expect("search store mutex poisoned") + .clear(bid); + } + + /// Move the active window's cursor to a byte offset (clamped to the + /// buffer extent), resetting the goal column. Shared by the search + /// motions. + fn search_place_cursor(&mut self, byte: u64) { + let clamped = byte.min(self.active_buffer_len()); + let aw = self.active_window_mut(); + aw.cursor = clamped; + aw.goal_col = None; + } + + /// Snapshot a buffer's full byte content (empty if the id is + /// stale). O(1) rope snapshot + one copy; used to feed `find_all`. + fn buffer_bytes(&self, buffer_id: BufferId) -> Vec { + let reg = self.registry.borrow(); + let Ok(buf) = reg.get(buffer_id) else { + return Vec::new(); + }; + let len = buf.len(); + let mut out = vec![0u8; len as usize]; + buf.snapshot_rope().slice(0, len, &mut out); + out + } + // ---- editing primitives ------------------------------------------------ /// Apply `op` to the active buffer; notify every window @@ -511,6 +715,16 @@ impl EditorCore { self.pending_crdt_ops .push((CrdtOpOrigin::DaemonKey, buffer_id, (**crdt_op).clone())); } + // Search matches were computed against the pre-edit text, so + // their byte positions are now wrong. Mark the buffer's matches + // stale (M11.8): the producer / TUI overlay suppress them until + // a fresh search re-runs against the current content. No-op for + // a buffer with no search state. The headline isearch bet — + // "stale-after-edit linger" — is closed here. + self.search_store + .lock() + .expect("search store mutex poisoned") + .mark_stale(buffer_id); Ok(edit.new_rope.len()) } @@ -2176,4 +2390,142 @@ mod tests { // 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); + 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); + 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); + 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); + 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); + type_query(&mut s, "foo"); + s.search_step(true); // focus the match at byte 8 + assert_eq!(s.cursor(), 8); + s.search_finish(true); // accept + assert!(!s.search_active()); + assert_eq!(s.cursor(), 8, "accept keeps the cursor on the match"); + assert!( + s.search_store + .lock() + .expect("store") + .for_buffer(bid) + .is_some(), + "accept keeps matches for highlight + navigation" + ); + } + + #[test] + fn search_backspace_widens_the_match_set() { + let mut s = from_bytes(b"fo foo food"); + s.active_window_mut().cursor = 0; + s.search_begin(true); + 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); + 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); + 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)" + ); + } } diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index 16910dd..a834a23 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -5126,6 +5126,7 @@ pub fn install_editor(lua: &Lua, core: &SharedCore) -> mlua::Result<()> { install_editing(&editor, lua, core)?; install_history(&editor, lua, core)?; install_session(&editor, lua, core)?; + install_search(&editor, lua, core)?; pmacs.set("editor", editor)?; pmacs.set("frontend", install_frontend_module(lua, core)?)?; @@ -11431,6 +11432,48 @@ fn install_history(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result Ok(()) } +/// Install the `pmacs.editor.search_*` primitives that drive +/// incremental search (Q#SR5). The live-typing keys are intercepted in +/// Rust (`EditorState::dispatch_search_key`); these bindings exist so +/// the *entry* commands (`search.forward` / `search.backward`) and any +/// post-accept navigation commands can begin / step a search from Lua. +fn install_search(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result<()> { + { + // search_start(forward): begin an isearch in the given + // direction, anchored at the active buffer + cursor. + let cc = core.clone(); + editor.set( + "search_start", + lua.create_function(move |_, forward: bool| { + cc.borrow_mut().search_begin(forward); + Ok(()) + })?, + )?; + } + { + // search_step(forward): move the active buffer's match focus + // (works during a live search and after accept, for navigation + // commands). No-op when the buffer has no matches. + let cc = core.clone(); + editor.set( + "search_step", + lua.create_function(move |_, forward: bool| { + cc.borrow_mut().search_step(forward); + Ok(()) + })?, + )?; + } + { + // search_active(): true while an isearch session is running. + let cc = core.clone(); + editor.set( + "search_active", + lua.create_function(move |_, ()| Ok(cc.borrow().search_active()))?, + )?; + } + Ok(()) +} + #[allow( clippy::too_many_lines, reason = "linear list of session bindings; the surface is coherent and split would fragment review"