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:
parent
7c46288ef0
commit
58b68f6ac0
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
226
src/editor.rs
226
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<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]
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue