search: TUI incremental isearch input (Q#SR5)

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<SearchSession>` (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: <query> (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) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-27 11:15:26 -04:00
parent 7c46288ef0
commit 58b68f6ac0
5 changed files with 645 additions and 1 deletions

View File

@ -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.",

View File

@ -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

View File

@ -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<F: FnOnce(&mut 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: <query> (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]

View File

@ -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<Mutex>`); shared with both readers.
pub search_store: crate::search::SharedSearchStore,
/// Live incremental-search session (Q#SR5), or `None` when no
/// search is running. Frontend-agnostic: the TUI run loop and the
/// daemon's `FrontendEvent::Key` path both drive it through the
/// same `search_*` methods, so isearch behaves identically in the
/// terminal and GPU frontends. Only the *prompt surface* differs
/// (TUI bottom row vs GPU status band).
pub search: Option<SearchSession>,
}
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>, 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<u8> {
let reg = self.registry.borrow();
let Ok(buf) = reg.get(buffer_id) else {
return Vec::new();
};
let len = buf.len();
let mut out = vec![0u8; len as usize];
buf.snapshot_rope().slice(0, len, &mut out);
out
}
// ---- editing primitives ------------------------------------------------
/// Apply `op` to the active buffer; notify every window
@ -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)"
);
}
}

View File

@ -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"