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/docs/incremental-search-framing.md b/docs/incremental-search-framing.md new file mode 100644 index 0000000..58b0561 --- /dev/null +++ b/docs/incremental-search-framing.md @@ -0,0 +1,123 @@ +# Incremental in-buffer search — framing pass + +Date: 2026-06-15. The last deferred GUI item: `SearchMatch` / +`SearchMatchActive` decorations exist on the wire (message.rs) and are +gated to `None` in both frontends "waiting on a search feature." This +builds that feature. Decided up front (user): **incremental isearch** +(highlight live as you type, same key steps to next, Enter accepts, +Esc/C-g restores origin) with **smart-case substring** matching +(case-insensitive unless the query has an uppercase letter). + +## Survey facts (anchors) + +- Greenfield in-buffer search; only `project.search` (cross-file grep) + exists. No `Buffer::find` / rope search. +- `DiagnosticStore` (diag.rs) is a near-exact template: keyed + `Arc` store, sorted entries, `next_after`/`previous_before`, + stale tracking, Lua nav bindings, overlay `_attach_view`. +- Producer `scoped_decorations` (semantic_render.rs) + TUI + `DiagnosticView::render` are the emit/paint templates (viewport clip, + line-start cache, stale-skip). +- GPU `decoration_kind_to_bg_color` already draws bg decorations + through the quad pipeline; SearchMatch/Active just need their arms. +- The minibuffer (minibuffer.rs) is keystroke-driven and pseudo-modal + (dispatch_minibuffer_key intercepts all keys while active) but has + **no live-preview/on_changed hook** — the one missing piece for + incremental highlight. + +## Q#SR1 — store shape & ownership + +**Stance: a per-buffer `SearchStore` mirroring `DiagnosticStore`.** +`by_buffer: HashMap` where `SearchState` holds +the resolved query, the sorted `Vec` matches, and the +active index. Shared `Arc`. The active index lives on the store +(navigation state), not per-window — v1 accepts that two windows on +the same buffer share the active highlight (note it; selection is the +per-window concept, search mirrors diagnostics). Edits mark the +buffer's entry stale (M11.8 model) so matches at pre-edit byte +positions aren't painted until re-search. + +## Q#SR2 — search primitive + +**Stance: smart-case substring over a rope snapshot, regex deferred.** +`find_all(haystack, query) -> Vec`: case-insensitive unless +`query` contains an uppercase char (then exact). Built on +`snapshot_rope().slice` bytes (the diagnostics path's cheap snapshot). +Recomputed on query change; invalidated on edit. No `regex` crate in +v1 (literal-text is the 95% case; regex is a later toggle). Overlapping +matches: advance past each match's start+1 (standard non-overlapping). + +## Q#SR3 — decoration emission + +**Stance: mirror the diagnostics producer.** In `scoped_decorations`, +read the search store for the viewport buffer, emit `SearchMatch` for +every visible match and `SearchMatchActive` for the active one +(emitted last / higher z so it wins the overlap). Reuse the line cache ++ `clip_to_viewport`. Stale-skip exactly like diagnostics. TUI gets a +`SearchView` overlay (mirrors `DiagnosticView`) painting bg, attached +via `pmacs.search._attach_view`. + +## Q#SR4 — colors + +**Stance: a single search palette.** SearchMatch = translucent yellow +wash; SearchMatchActive = stronger amber/orange. GPU: the two +`decoration_kind_to_bg_color` arms. TUI: reverse-ish colored bg in the +`SearchView`. Distinct from selection (blue) and diagnostics +(severity). + +## Q#SR5 — input & modality (incremental) + +**Stance: host the query in the minibuffer + a small `on_changed` +hook + targeted next/prev interception.** Entry opens a minibuffer +search session (prompt `I-search: `); `on_changed` (new optional +session callback, fired after each content mutation in +dispatch_minibuffer_key) recomputes matches → updates the store → +re-decorate. While that session is active, the entry chord again = +`search.next`, its shift/`C-r` variant = `search.prev` (control keys, +not self-insert, so safe to intercept in the search branch of +dispatch_minibuffer_key). `Enter` accepts (close, leave cursor at the +active match); `Esc`/`C-g` cancels (close, restore the origin cursor +saved at entry, clear the store). Reusing the minibuffer's input +editing + prompt avoids reimplementing a modal query line. + +## Q#SR6 — navigation + +**Stance: `search.next`/`search.prev` mirror `diag.next/prev`.** +Advance the active index with wrap, move the active window's cursor to +the active match start, scroll it into view. The active index drives +which match is `SearchMatchActive`. Usable both during the live +session and afterward (matches persist until cleared / next search). + +## Binding (proposal, flagged for veto) + +`C-f` → search (CUA "Find"), rebound from `cursor.right`. Consistent +with the editor's CUA direction (arrows move; Ctrl+F finds); the +Emacs-holdover `C-f = forward-char` is the inconsistent one. Easy to +change — call out in validation. + +## Predicted findings (categorical bets) + +1. **Stale-after-edit linger** (the squiggle lesson again): matches at + pre-edit byte positions paint over shifted text until re-search — + the store's stale gate + re-search-on-change must be right, or + highlights drift during typing. +2. **Minibuffer `on_changed` × completion**: the hook interacts with + the existing per-keystroke candidate recompute; the search session + must opt out of completion cleanly (a session "kind" seam). +3. **Per-buffer active match across windows** surfaces as navigating + in one window moving the active highlight in another — accepted for + v1, but worth eyeballing. +4. **Empty / all-match queries**: empty query → no matches (not all); + a 1-char common letter → many matches → viewport-clipped emission + must stay cheap (line cache + only-visible). + +## Session plan + +Three green commits: +1. Core `SearchStore` + `find_all` smart-case primitive + unit tests. +2. Producer emission + GPU bg colors + TUI `SearchView` + attach. +3. Incremental UX: minibuffer `on_changed`, search session, + next/prev interception + commands, cancel-restores-origin, binding. + +Manual validation gate as usual (type to highlight live, step matches, +edit mid-search, Esc restores). diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index cddab83..35abe0a 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -529,6 +529,12 @@ struct State { status_left_text: String, /// Q#S1 — the wire-authoritative status facts (protocol v8). status_facts: Option, + /// Q#SR5 — the live incremental-search prompt (protocol v9), or + /// `None` when no search is running. While `Some`, the status + /// band's left side shows `I-search: (n/m)` in place of + /// the buffer name; the matches highlight via `SearchMatch` + /// decorations. + search_prompt: Option, /// Minimap vertex bytes cached by [`MinimapCacheKey`] — /// rebuilding rescanned every line shape per frame. minimap_cache: Option<(MinimapCacheKey, Vec)>, @@ -545,6 +551,16 @@ struct StatusFactsLocal { diag_warnings: u32, } +/// The live incremental-search prompt (Q#SR5, protocol v9), mirrored +/// from a `SearchPrompt` message whose `query` was `Some`. +#[derive(Clone, Debug, PartialEq, Eq)] +struct SearchPromptLocal { + buffer_id: BufferId, + query: String, + active: Option, + total: u32, +} + /// pmacs-gpu's own cursor position, mirrored from `CursorByte`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct OwnCursor { @@ -643,64 +659,124 @@ impl ApplicationHandler for App { if key.state != ElementState::Pressed { return; } - // Escape stays a local quit (no daemon round trip). + // While the daemon is intercepting keystrokes — an active + // incremental search (Q#SR5), or a minibuffer / pending + // prefix — every key belongs to its handler, not the + // buffer. The GUI round-trips them all and never + // optimistic-applies (that would edit the document + // mid-search). + let intercept = self + .state + .as_ref() + .is_some_and(State::daemon_intercepts_keys); + + // Escape cancels an active intercept (e.g. a running + // search); otherwise it stays the local quit. if matches!(key.logical_key, Key::Named(NamedKey::Escape)) { - event_loop.exit(); + if intercept { + if let Some(client) = self.attach_client.as_ref() + && let Err(e) = client.send_key(ProtocolKey::Escape, Modifiers::NONE) + { + eprintln!("pmacs-gpu: send Escape (cancel) failed: {e}"); + } + } else { + event_loop.exit(); + } return; } + + let Some((pkey, pmods)) = translate_key(&key.logical_key, self.modifiers) else { + return; + }; + let Some(client) = self.attach_client.as_ref() else { + return; + }; + + // Intercept path: round-trip every key into the daemon's + // active handler (search query / step / accept / cancel). + if intercept { + if let Some(state) = self.state.as_mut() { + state.mark_cursor_stale_after_round_trip(); + } + if debug_input() { + eprintln!("pmacs-gpu send_key (intercepted): {pkey:?} mods={pmods:?}"); + } + if let Err(e) = client.send_key(pkey, pmods) { + eprintln!("pmacs-gpu: send_key (intercepted) failed: {e}"); + } + return; + } + + // Idle: C-s / C-r begin an incremental search. They are + // otherwise withheld as command chords; forward them so + // the search can start. The daemon then flips the + // intercept gate (DispatchIdle / SearchPrompt) one + // round-trip later, after which every key routes into the + // search — no optimistic local flip, so a C-s that (via + // rebinding) doesn't start a search can never wedge the + // gate against the daemon's authoritative state. + if is_search_entry_chord(pkey, pmods) { + if let Some(state) = self.state.as_mut() { + state.mark_cursor_stale_after_round_trip(); + } + if let Err(e) = client.send_key(pkey, pmods) { + eprintln!("pmacs-gpu: send_key (search entry) failed: {e}"); + } + return; + } + // Session B2 forwards cursor motion + plain text editing // (Char / Backspace / Enter / Delete / Tab). Ctrl/Alt/ // Meta chords are withheld — they drive commands and // minibuffer flows the GUI can't render or interact with // yet (a later session adds GUI minibuffer + chords). - if let Some((pkey, pmods)) = translate_key(&key.logical_key, self.modifiers) - && should_forward_key(pkey, pmods) - && let Some(client) = self.attach_client.as_ref() - { - if let Some(op) = self.state.as_mut().and_then(|state| { - state - .optimistic_crdt_insert(pkey, pmods) - .or_else(|| state.optimistic_crdt_delete(pkey, pmods)) - }) { + if !should_forward_key(pkey, pmods) { + return; + } + + if let Some(op) = self.state.as_mut().and_then(|state| { + state + .optimistic_crdt_insert(pkey, pmods) + .or_else(|| state.optimistic_crdt_delete(pkey, pmods)) + }) { + if debug_input() { + eprintln!( + "pmacs-gpu send_crdt: key={pkey:?} buf={:?} bytes={}B", + op.buffer_id, + op.op.bytes.len() + ); + } + if let Err(e) = client.send_crdt_op(op.buffer_id, op.op) { + eprintln!("pmacs-gpu: send_crdt_op failed: {e}"); + } + // An optimistic Enter near the bottom edge can + // scroll; re-declare the scoped viewport so + // the producer styles the newly visible lines. + if let Some(vp) = op.viewport + && let Err(e) = + client.send_viewport(vp.buffer_id, vp.visible, vp.generation) + { + eprintln!("pmacs-gpu: send Viewport failed: {e}"); + } + return; + } + if let Some(state) = self.state.as_mut() { + if state.defer_round_trip_key_if_needed(pkey, pmods) { if debug_input() { eprintln!( - "pmacs-gpu send_crdt: key={pkey:?} buf={:?} bytes={}B", - op.buffer_id, - op.op.bytes.len() + "pmacs-gpu defer_key: {pkey:?} mods={pmods:?} \ + pending optimistic cursor" ); } - if let Err(e) = client.send_crdt_op(op.buffer_id, op.op) { - eprintln!("pmacs-gpu: send_crdt_op failed: {e}"); - } - // An optimistic Enter near the bottom edge can - // scroll; re-declare the scoped viewport so - // the producer styles the newly visible lines. - if let Some(vp) = op.viewport - && let Err(e) = - client.send_viewport(vp.buffer_id, vp.visible, vp.generation) - { - eprintln!("pmacs-gpu: send Viewport failed: {e}"); - } return; } - if let Some(state) = self.state.as_mut() { - if state.defer_round_trip_key_if_needed(pkey, pmods) { - if debug_input() { - eprintln!( - "pmacs-gpu defer_key: {pkey:?} mods={pmods:?} \ - pending optimistic cursor" - ); - } - return; - } - state.mark_cursor_stale_after_round_trip(); - } - if debug_input() { - eprintln!("pmacs-gpu send_key: {pkey:?} mods={pmods:?}"); - } - if let Err(e) = client.send_key(pkey, pmods) { - eprintln!("pmacs-gpu: send_key failed: {e}"); - } + state.mark_cursor_stale_after_round_trip(); + } + if debug_input() { + eprintln!("pmacs-gpu send_key: {pkey:?} mods={pmods:?}"); + } + if let Err(e) = client.send_key(pkey, pmods) { + eprintln!("pmacs-gpu: send_key failed: {e}"); } } WindowEvent::Resized(size) => { @@ -1417,6 +1493,7 @@ impl State { status_left_buffer, status_left_text: String::new(), status_facts: None, + search_prompt: None, minimap_cache: None, } } @@ -1430,6 +1507,16 @@ impl State { } } + /// `true` while the daemon is intercepting keystrokes — an active + /// incremental search (Q#SR5), surfaced by a live `SearchPrompt`, or + /// the daemon reporting non-idle (`DispatchIdle { idle: false }` for + /// a minibuffer / pending prefix). In this state the GUI round-trips + /// every key to the daemon's handler instead of optimistically + /// applying it to the buffer. + fn daemon_intercepts_keys(&self) -> bool { + self.search_prompt.is_some() || !self.dispatch_idle + } + /// Shared eligibility gates for the optimistic edit paths /// (insert + delete). `None` ⇒ the key must round-trip: /// - dispatcher busy (minibuffer/prefix flows own the keys), or @@ -2010,6 +2097,27 @@ impl State { self.window.request_redraw(); None } + // Q#SR5 — the live isearch prompt (protocol v9). `query: + // None` clears the band (search ended); `Some` shows + // `I-search: (n/m)` on the band's left side. The + // matches themselves arrive as SearchMatch decorations and + // the keys round-trip via the DispatchIdle gate, so this + // handler only drives the prompt text. + InstanceMessage::SearchPrompt { + buffer_id, + query, + active, + total, + } => { + self.search_prompt = query.map(|q| SearchPromptLocal { + buffer_id, + query: q, + active, + total, + }); + self.window.request_redraw(); + None + } // Session 9.3 — peer presence. The editing frontend's // cursor + selection drive the `CurrentLine` / `Selection` // washes for this read-only mirror (finding QB1). Store @@ -2510,9 +2618,25 @@ impl State { spans } - /// The band's left side: buffer name + modified dot, from the - /// v8 `StatusFacts` (empty until the daemon ships them). + /// The band's left side. While an incremental search is running + /// (Q#SR5) it shows `I-search: (n/m)` — the prompt takes + /// over the band like Emacs's echo area, returning to the buffer + /// name + modified dot (v8 `StatusFacts`) when the search ends. fn compose_status_left(&self) -> String { + if let Some(sp) = self + .search_prompt + .as_ref() + .filter(|s| Some(s.buffer_id) == self.current_buffer_id) + { + let count = if sp.query.is_empty() { + String::new() + } else if sp.total == 0 { + " [no match]".to_string() + } else { + format!(" ({}/{})", sp.active.map_or(0, |a| a + 1), sp.total) + }; + return format!("I-search: {}{}", sp.query, count); + } match self .status_facts .as_ref() @@ -3885,6 +4009,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str { InstanceMessage::InlineAdornments { .. } => "InlineAdornments", InstanceMessage::FileStyleSummary { .. } => "FileStyleSummary", InstanceMessage::StatusFacts { .. } => "StatusFacts", + InstanceMessage::SearchPrompt { .. } => "SearchPrompt", InstanceMessage::BlockAdornments { .. } => "BlockAdornments", InstanceMessage::FoldState { .. } => "FoldState", InstanceMessage::ResourceOffer { .. } => "ResourceOffer", @@ -4001,6 +4126,14 @@ fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool { ) } +/// `C-s` / `C-r` — the chords that begin an incremental search +/// (Q#SR5). Forwarded even when idle (they are otherwise withheld as +/// command chords by [`should_forward_key`]) so a search can start; +/// once it is running every key round-trips via the intercept path. +fn is_search_entry_chord(key: ProtocolKey, mods: Modifiers) -> bool { + mods == Modifiers::CTRL && matches!(key, ProtocolKey::Char('s' | 'r')) +} + fn is_plain_text_modifiers(mods: Modifiers) -> bool { !mods.contains(Modifiers::CTRL) && !mods.contains(Modifiers::ALT) @@ -4874,8 +5007,12 @@ fn decoration_kind_to_bg_color(kind: DecorationKind) -> Option<[f32; 4]> { // 0.22 keeps it subtle vs Selection's 0.30 while actually // reading as a current-line band. DecorationKind::CurrentLine => Some([0.55, 0.60, 0.75, 0.22]), - // Deferred to the search-feature arc. - DecorationKind::SearchMatch | DecorationKind::SearchMatchActive => None, + // In-buffer search (Q#SR4): a translucent yellow wash under + // every match, a stronger amber under the active one so it + // stands out as you step through. Both let the glyph color + // show through (text renders after this pass). + DecorationKind::SearchMatch => Some([0.85, 0.78, 0.20, 0.30]), + DecorationKind::SearchMatchActive => Some([0.95, 0.55, 0.12, 0.48]), // Underline-only — handled by // [`decoration_kind_to_underline_color`]. DecorationKind::DiagnosticError @@ -5037,6 +5174,37 @@ mod tests { assert!(should_forward_key(ProtocolKey::Backspace, Modifiers::ALT)); } + #[test] + fn search_entry_chord_is_ctrl_s_or_ctrl_r_only() { + // C-s / C-r start a search (Q#SR5) — forwarded even though + // `should_forward_key` withholds them as command chords. + assert!(is_search_entry_chord( + ProtocolKey::Char('s'), + Modifiers::CTRL + )); + assert!(is_search_entry_chord( + ProtocolKey::Char('r'), + Modifiers::CTRL + )); + assert!( + !should_forward_key(ProtocolKey::Char('s'), Modifiers::CTRL), + "C-s is otherwise a withheld chord; the search-entry path is what forwards it" + ); + // Other Ctrl chords, and C-s without Ctrl, are not entry chords. + assert!(!is_search_entry_chord( + ProtocolKey::Char('x'), + Modifiers::CTRL + )); + assert!(!is_search_entry_chord( + ProtocolKey::Char('s'), + Modifiers::NONE + )); + assert!(!is_search_entry_chord( + ProtocolKey::Char('s'), + Modifiers::CTRL | Modifiers::ALT + )); + } + #[test] fn translate_key_carries_modifiers() { use winit::keyboard::{Key as WKey, ModifiersState, NamedKey}; @@ -5566,14 +5734,14 @@ mod tests { } #[test] - fn bg_color_helper_covers_selection_and_current_line() { + fn bg_color_helper_covers_selection_current_line_and_search() { // Sessions 9.1 + 9.2: Selection and CurrentLine paint. assert!(decoration_kind_to_bg_color(DecorationKind::Selection).is_some()); assert!(decoration_kind_to_bg_color(DecorationKind::CurrentLine).is_some()); - // Search-feature arc — still deferred. - assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatch).is_none()); - assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatchActive).is_none()); + // In-buffer search (Q#SR4): both match kinds wash a bg. + assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatch).is_some()); + assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatchActive).is_some()); // Underline-only kinds belong to the underline helper (T M4.6 // parity: squiggle bars, not text recoloring). @@ -5605,16 +5773,9 @@ mod tests { ] { let ul = decoration_kind_to_underline_color(kind).is_some(); let bg = decoration_kind_to_bg_color(kind).is_some(); - // Both helpers return None for the search pair — deferred - // to the search-feature arc. That is the "neither yet" - // state — the exclusive-or test exempts it. - let deferred = matches!( - kind, - DecorationKind::SearchMatch | DecorationKind::SearchMatchActive - ); assert!( - deferred || (ul ^ bg), - "{kind:?}: underline={ul} bg={bg} — should be exactly one (unless deferred)" + ul ^ bg, + "{kind:?}: underline={ul} bg={bg} — should be exactly one" ); } } diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index a041ce0..1504f54 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -814,6 +814,30 @@ pub enum InstanceMessage { /// frontend must round-trip via [`FrontendEvent::Key`]. idle: bool, }, + /// Q#SR5 (incremental search, protocol v9) — the live isearch + /// prompt for a semantic frontend that cannot host a minibuffer. + /// Carries the query as typed and the match readout so the + /// frontend can render an `I-search: (n/m)` band; the + /// matches themselves arrive as [`DecorationKind::SearchMatch`] / + /// [`DecorationKind::SearchMatchActive`] decorations. A `query` of + /// `None` means no search is running — the frontend hides the band. + /// + /// Emitted by the semantic producer when the search state changes + /// (cached-compare suppressed, like [`Self::StatusFacts`]). Kept + /// off wires negotiated `< 9` by the daemon's per-session filter + /// (additive variant — an older peer would hard-error decoding it). + SearchPrompt { + /// Buffer the search is anchored in (the active buffer). + buffer_id: crate::BufferId, + /// The query as typed so far, or `None` when no search runs. + /// `Some("")` is a freshly-started search with an empty query. + query: Option, + /// 0-based index of the active match, or `None` when the query + /// has no matches (a failing search). + active: Option, + /// Total number of matches for the current query. + total: u32, + }, } /// Flat selection state for the wire. @@ -1070,7 +1094,11 @@ pub enum ResourceBody { /// *daemon* this time (the variant travels instance→frontend): the /// per-session filter keeps it off wires negotiated `< 8`, the same /// shape as the `DispatchIdle` (v4) gate. -pub const PROTOCOL_VERSION: u32 = 8; +/// +/// Q#SR5 (incremental search): bumped from 8 to 9 for +/// [`InstanceMessage::SearchPrompt`]. Additive and daemon-gated per +/// session, identical shape to the `StatusFacts` (v8) bump. +pub const PROTOCOL_VERSION: u32 = 9; /// T M10.5: the set of protocol versions a v1.0 binary accepts on /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept @@ -1110,7 +1138,10 @@ pub const PROTOCOL_VERSION: u32 = 8; /// /// Q#S1: extended to `[6, 7, 8]`. `InstanceMessage::StatusFacts` is /// additive and daemon-gated per session. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8]; +/// +/// Q#SR5: extended to `[6, 7, 8, 9]`. `InstanceMessage::SearchPrompt` +/// is additive and daemon-gated per session. +pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9]; /// T M10.5: predicate for the handshake check. Returns `true` if /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. diff --git a/src/daemon.rs b/src/daemon.rs index 2365a36..84b2c2a 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1021,12 +1021,22 @@ fn dispatcher_loop( let peer_knows_status_facts = session_registry .session_state(*fid) .is_some_and(|s| s.negotiated_protocol_version >= 8); + // Q#SR5 — `SearchPrompt` is a v9 variant; gate it the + // same way so an < 9 peer never sees the new shape. + let peer_knows_search_prompt = session_registry + .session_state(*fid) + .is_some_and(|s| s.negotiated_protocol_version >= 9); for msg in &messages { if !peer_knows_status_facts && matches!(msg, InstanceMessage::StatusFacts { .. }) { continue; } + if !peer_knows_search_prompt + && matches!(msg, InstanceMessage::SearchPrompt { .. }) + { + continue; + } // T M10.10 Day 4 / M10.11 F2 — the criterion-1 // jitter site: render-write latency. // diff --git a/src/diag.rs b/src/diag.rs index 97b30c7..cc2ea7e 100644 --- a/src/diag.rs +++ b/src/diag.rs @@ -592,7 +592,7 @@ impl View for DiagnosticView { // cross-module coupling on internal helpers) // --------------------------------------------------------------------------- -fn compute_line_offsets(source: &[u8]) -> Vec { +pub(crate) fn compute_line_offsets(source: &[u8]) -> Vec { let mut out = Vec::with_capacity(source.len() / 32 + 1); out.push(0); for (i, b) in source.iter().enumerate() { @@ -603,7 +603,7 @@ fn compute_line_offsets(source: &[u8]) -> Vec { out } -fn line_at_offset(line_offsets: &[u32], offset: u32) -> u32 { +pub(crate) fn line_at_offset(line_offsets: &[u32], offset: u32) -> u32 { match line_offsets.binary_search(&offset) { Ok(i) => i as u32, Err(i) => i.saturating_sub(1) as u32, @@ -629,7 +629,11 @@ fn underline_cols_for_line(line_bytes: &[u8], byte_start: u32, byte_end: u32) -> } } -fn byte_range_to_display_cols(line_bytes: &[u8], byte_start: usize, byte_end: usize) -> (u32, u32) { +pub(crate) fn byte_range_to_display_cols( + line_bytes: &[u8], + byte_start: usize, + byte_end: usize, +) -> (u32, u32) { let bs = byte_start.min(line_bytes.len()); let be = byte_end.min(line_bytes.len()); let display_to = |upto: usize| -> u32 { diff --git a/src/editor.rs b/src/editor.rs index 5016b76..42be2cb 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -450,7 +450,8 @@ impl EditorState { /// - the dispatcher holds a pending multi-key prefix (e.g. the /// user has typed `C-x` and the daemon is waiting for the next /// chord), or - /// - a minibuffer prompt is active and absorbing keys. + /// - a minibuffer prompt is active and absorbing keys, or + /// - an incremental search is running and absorbing keys (Q#SR5). /// /// Used by the daemon to drive the `InstanceMessage::DispatchIdle` /// wire signal that gates `crdt_replica` frontends' optimistic-apply @@ -458,13 +459,18 @@ impl EditorState { /// plain-char keystroke into the active document while the /// daemon's actual intent is to route the keystroke into the /// minibuffer prompt — the M10.10 "documented limitation" that - /// surfaced during session-5 manual validation. + /// surfaced during session-5 manual validation. Isearch reuses the + /// exact same gate: while a search runs every keystroke must + /// round-trip so the daemon's `dispatch_search_key` receives it + /// (extend the query / step) instead of the frontend self-inserting + /// it into the buffer. #[must_use] pub fn dispatch_idle(&self) -> bool { if !self.dispatcher.pending().is_empty() { return false; } - !self.core.borrow().minibuffer.is_active() + let core = self.core.borrow(); + !core.minibuffer.is_active() && !core.search_active() } /// `frontend_id` records which frontend produced the event. v0.1 @@ -484,6 +490,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 +632,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 +1205,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 +1395,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 +1724,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 +2041,160 @@ 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"); + } + + #[test] + fn isearch_accumulates_across_renders_like_run_loop() { + // Reproduce the real run loop: a render between every keystroke + // (the in-process TUI renders once per burst, but paint_frame + // borrows the core mutably and reads the search state, so a + // render must not corrupt mid-search input). + use crate::frontend::Event; + let mut s = fresh_with(b"foo bar foo baz foo"); + s.core.borrow_mut().active_window_mut().cursor = 0; + let size = crate::cell::CellSize::new(24, 80); + let mut rs = crate::instance_render::RenderState::new(size); + + let _ = rs.render_frame(&s, &[]); + process_event(&mut s, Event::Key(ctrl('s')), size); + assert!(s.core.borrow().search_active(), "C-s starts the search"); + let _ = rs.render_frame(&s, &[]); + + for c in "foo".chars() { + process_event( + &mut s, + Event::Key(key(KeyCode::Char(c), KeyModifiers::NONE)), + size, + ); + let _ = rs.render_frame(&s, &[]); + } + assert_eq!( + s.core.borrow().search_query(), + "foo", + "query must accumulate across renders, not stick at the first char" + ); + } + + #[test] + fn isearch_tui_washes_matches_and_shows_full_query() { + // The regression behind "only searches for the first character": + // the TUI had no match-wash overlay, so the only feedback was the + // cursor jump. Paint a real frame and assert both the wash and + // the full-query prompt land on the grid. + use crate::cell::{Cell, CellCoord, CellGrid, CellSize, Color, Glyph}; + let mut s = fresh_with(b"foo bar foo"); + s.core.borrow_mut().active_window_mut().cursor = 0; + s.dispatch_key(FrontendId::LOCAL, ctrl('s')); + type_chars(&mut s, "foo"); + + let size = CellSize::new(24, 80); + let mut backing = vec![Cell::default(); (size.rows * size.cols) as usize]; + let mut grid = CellGrid { + cells: &mut backing, + stride: size.cols, + size, + }; + let _ = paint_frame(&s, &mut grid, size); + + // The active match [0,3) washes row 0's first cells (bright + // Indexed(11); lazy matches would be Indexed(3)). + let bg0 = grid.get(CellCoord::new(0, 0)).style.bg; + assert!( + matches!(bg0, Color::Indexed(11) | Color::Indexed(3)), + "first match cell should carry the search wash, got {bg0:?}" + ); + // The bottom row shows the full live query, not just "f". + let row = size.rows - 1; + let prompt: String = (0..size.cols) + .filter_map(|c| match grid.get(CellCoord::new(row, c)).glyph { + Glyph::Char(ch) => Some(ch), + _ => None, + }) + .collect(); + assert!( + prompt.contains("I-search: foo"), + "bottom row should show the accumulated query, got {prompt:?}" + ); + } + + #[test] + fn isearch_flips_dispatch_idle_so_gpu_round_trips() { + // The GPU's optimistic-apply gate (M11.6) keys off dispatch_idle. + // An active isearch must drive it false so the GPU round-trips + // keystrokes to the daemon's dispatch_search_key instead of + // self-inserting them — the shared-core contract for Q#SR5. + let mut s = fresh_with(b"foo foo"); + assert!(s.dispatch_idle(), "idle before any search"); + s.dispatch_key(FrontendId::LOCAL, ctrl('s')); + assert!(s.core.borrow().search_active()); + assert!(!s.dispatch_idle(), "search active ⇒ keys must round-trip"); + s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Enter)); // accept + assert!(s.dispatch_idle(), "search ended ⇒ optimistic apply resumes"); + } + // ---- T M11.6 — DispatchIdle --------------------------------------------- #[test] diff --git a/src/editor_core.rs b/src/editor_core.rs index 5ad0bd9..9c0feb2 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 @@ -127,6 +150,19 @@ pub struct EditorCore { /// skipped on pop (stale-handle safe, mirrors the registry's /// `Missing` contract). pub jump_ring: Vec<(BufferId, Position)>, + /// In-buffer incremental search store (Q#SR1). Per-buffer query + + /// matches + active index, written by the search session / + /// `search.*` commands and read by the decorations producer + /// ([`crate::semantic_render`]) and the TUI search overlay. + /// Cheaply cloneable (`Arc`); shared with both readers. + pub search_store: crate::search::SharedSearchStore, + /// Live incremental-search session (Q#SR5), or `None` when no + /// search is running. Frontend-agnostic: the TUI run loop and the + /// daemon's `FrontendEvent::Key` path both drive it through the + /// same `search_*` methods, so isearch behaves identically in the + /// terminal and GPU frontends. Only the *prompt surface* differs + /// (TUI bottom row vs GPU status band). + pub search: Option, } impl EditorCore { @@ -161,6 +197,8 @@ impl EditorCore { active_frontend: FrontendId::LOCAL, pending_crdt_ops: Vec::new(), jump_ring: Vec::new(), + search_store: crate::search::make_shared_store(), + search: None, } } @@ -469,6 +507,197 @@ 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()); + // 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, + }); + } + + /// Ensure the active window carries a [`crate::search::SearchView`] + /// overlay, attaching one if absent (deduped by overlay kind). The + /// view reads the per-buffer [`Self::search_store`] keyed on the + /// rendered buffer, so one instance suffices per window. + fn ensure_search_overlay(&mut self) { + let store = self.search_store.clone(); + let win = self.active_window_mut(); + if !win.overlay_kinds().contains(&"search") { + win.push_overlay(Box::new(crate::search::SearchView::new(store))); + } + } + + /// Append a character to the query and re-search. + pub fn search_input_char(&mut self, ch: char) { + let Some(session) = self.search.as_mut() else { + return; + }; + session.query.push(ch); + self.search_recompute(); + } + + /// Drop the last character of the query and re-search. With an + /// empty query this is a no-op (the search stays open, empty). + pub fn search_backspace(&mut self) { + let Some(session) = self.search.as_mut() else { + return; + }; + session.query.pop(); + self.search_recompute(); + } + + /// Re-run `find_all` for the current query against the origin + /// buffer, refresh the store, and move the cursor to the match + /// nearest the origin (first match at/after the origin cursor, + /// wrapping). An empty query or no match anchors the cursor back + /// at the origin so a failing search never drifts the view. + fn search_recompute(&mut self) { + let Some(session) = self.search.as_ref() else { + return; + }; + let bid = session.origin.0; + let origin_byte = session.origin.1; + let query = session.query.clone(); + let 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 @@ -504,6 +733,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()) } @@ -2169,4 +2408,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/frontend.rs b/src/frontend.rs index ef40299..b5ceb1b 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -384,6 +384,10 @@ impl Frontend { | InstanceMessage::FoldState { .. } | InstanceMessage::FileStyleSummary { .. } | InstanceMessage::StatusFacts { .. } + // Q#SR5 — SearchPrompt is a semantic-frontend status-band + // family member; the cell-grid TUI never negotiates it and + // drops it silently if one arrives. + | InstanceMessage::SearchPrompt { .. } | InstanceMessage::ResourceOffer { .. } // T M11.6 — DispatchIdle is consumed by `attach.rs`'s // optimistic-apply gate; if any reaches this render path diff --git a/src/lib.rs b/src/lib.rs index bd6be35..522e4e7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -96,6 +96,7 @@ pub mod project_index; pub mod protocol; pub mod rename; pub mod rope; +pub mod search; // T M11.5 — the headless semantic consumer composes BufferMirror + // optimistic (both `crdt`-gated) and is only meaningful on a // `semantic_render` session, which the negotiation dependency rule 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" diff --git a/src/protocol.rs b/src/protocol.rs index 5bb172e..7d1a653 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1683,7 +1683,7 @@ mod tests { // --- M5.5a handshake & postcard round-trips --- #[test] - fn protocol_version_is_eight_for_status_facts() { + fn protocol_version_is_nine_for_search_prompt() { // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp / // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // SemanticFrame family + FrontendEvent::Viewport). T M11.6 @@ -1694,8 +1694,9 @@ mod tests { // making v6 the ladder's encoding floor. Q#M4 bumped 6→7 // (`PointerKind::TripleDown`, additive + frontend-gated). // Q#S1 bumped 7→8 (`InstanceMessage::StatusFacts`, additive - // + daemon-gated per session). - assert_eq!(PROTOCOL_VERSION, 8); + // + daemon-gated per session). Q#SR5 bumped 8→9 + // (`InstanceMessage::SearchPrompt`, additive + daemon-gated). + assert_eq!(PROTOCOL_VERSION, 9); } #[test] @@ -1704,16 +1705,18 @@ mod tests { // every cell-carrying message, ending the v1–v5 ladder — // pre-v6 peers are refused at the handshake (a clean // VersionMismatch) rather than garbling postcard mid-session. - // Q#M4 / Q#S1: the ladder resumes above that floor — v7 - // (`TripleDown`, frontend-gated) and v8 (`StatusFacts`, - // daemon-gated) are additive, so v6 through v8 interoperate. + // Q#M4 / Q#S1 / Q#SR5: the ladder resumes above that floor — v7 + // (`TripleDown`, frontend-gated), v8 (`StatusFacts`) and v9 + // (`SearchPrompt`, both daemon-gated) are additive, so v6 + // through v9 interoperate. assert!(is_supported_protocol_version(6)); assert!(is_supported_protocol_version(7)); assert!(is_supported_protocol_version(8)); - for rejected in [0, 1, 2, 3, 4, 5, 9, u32::MAX] { + assert!(is_supported_protocol_version(9)); + for rejected in [0, 1, 2, 3, 4, 5, 10, u32::MAX] { assert!( !is_supported_protocol_version(rejected), - "v{rejected} must be rejected by a v8 binary" + "v{rejected} must be rejected by a v9 binary" ); } } @@ -1877,6 +1880,41 @@ mod tests { } } + #[test] + fn search_prompt_round_trips_through_postcard() { + // Q#SR5 — the v9 wire variant. Cover the three shapes the + // producer emits: an active search with matches, a failing + // search (active=None), and a cleared band (query=None). + let cases = [ + (Some("foo".to_owned()), Some(2u32), 5u32), + (Some("zzz".to_owned()), None, 0u32), + (None, None, 0u32), + ]; + for (query, active, total) in cases { + let msg = InstanceMessage::SearchPrompt { + buffer_id: crate::buffer::BufferId::next(), + query: query.clone(), + active, + total, + }; + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + match decoded { + InstanceMessage::SearchPrompt { + query: q, + active: a, + total: t, + .. + } => { + assert_eq!(q, query); + assert_eq!(a, active); + assert_eq!(t, total); + } + other => panic!("expected SearchPrompt, got {other:?}"), + } + } + } + #[test] fn key_event_to_crossterm_round_trips() { // Build a protocol KeyEvent, translate to crossterm, translate diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 0000000..eacb5be --- /dev/null +++ b/src/search.rs @@ -0,0 +1,528 @@ +// search.rs --- in-buffer incremental search store + match primitive. + +//! Per-buffer in-buffer search state and the smart-case substring +//! matcher that fills it. Mirrors [`crate::diag::DiagnosticStore`]: +//! a cheaply-cloneable shared store written by the search session / +//! navigation commands and read by the decorations producer +//! ([`crate::semantic_render`]) and the TUI [`SearchView`] +//! (`crate::search_view`-equivalent — lives here for v1). +//! +//! # Why per-buffer (not per-window) +//! +//! Matches are a function of buffer *content*, so they live per +//! buffer, like diagnostics — not per window like the selection. The +//! active-match index is navigation state kept on the store; v1 +//! accepts that two windows showing the same buffer share the active +//! highlight (the same tradeoff diagnostics make). + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use pmacs_protocol::ByteRange; + +use crate::buffer::BufferId; + +/// One buffer's search state: the resolved query, its matches (byte +/// ranges, ascending and non-overlapping), and the active index. +#[derive(Clone, Debug, Default)] +pub struct SearchState { + query: String, + matches: Vec, + active: usize, +} + +impl SearchState { + /// The query these matches were computed for. + #[must_use] + pub fn query(&self) -> &str { + &self.query + } + + /// All matches, ascending by start. + #[must_use] + pub fn matches(&self) -> &[ByteRange] { + &self.matches + } + + /// The active match's range, or `None` when there are no matches. + #[must_use] + pub fn active_match(&self) -> Option { + self.matches.get(self.active).copied() + } + + /// The active match's index, or `None` when there are no matches. + #[must_use] + pub fn active_index(&self) -> Option { + (!self.matches.is_empty()).then_some(self.active) + } + + /// Number of matches. + #[must_use] + pub fn len(&self) -> usize { + self.matches.len() + } + + /// True when there are no matches. + #[must_use] + pub fn is_empty(&self) -> bool { + self.matches.is_empty() + } +} + +/// Per-buffer in-buffer-search store. Mirrors +/// [`crate::diag::DiagnosticStore`]: the search session and the +/// `search.next` / `search.previous` commands write it; the +/// decorations producer and the TUI search overlay read it. +/// +/// **Staleness (M11.8 model).** An edit marks the buffer's matches +/// stale: their byte positions describe pre-edit text, so the +/// producer / overlay suppress them until the next [`Self::set`] +/// re-runs the search against the current content. +#[derive(Default)] +pub struct SearchStore { + by_buffer: HashMap, + stale: HashSet, +} + +impl SearchStore { + /// Empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Replace `buffer_id`'s query + matches, clearing the stale flag. + /// An empty query or no matches drops the entry entirely. The + /// active index is preserved across re-search (clamped into the + /// new match set) so live typing doesn't reset the focused match. + pub fn set(&mut self, buffer_id: BufferId, query: impl Into, matches: Vec) { + let query = query.into(); + self.stale.remove(&buffer_id); + if query.is_empty() || matches.is_empty() { + self.by_buffer.remove(&buffer_id); + return; + } + let active = self + .by_buffer + .get(&buffer_id) + .map_or(0, |s| s.active) + .min(matches.len() - 1); + self.by_buffer.insert( + buffer_id, + SearchState { + query, + matches, + active, + }, + ); + } + + /// Drop a buffer's search state (e.g. on cancel / accept-and-end). + pub fn clear(&mut self, buffer_id: BufferId) { + self.by_buffer.remove(&buffer_id); + self.stale.remove(&buffer_id); + } + + /// The buffer's search state, or `None` if it has none. + #[must_use] + pub fn for_buffer(&self, buffer_id: BufferId) -> Option<&SearchState> { + self.by_buffer.get(&buffer_id) + } + + /// Mark a buffer's matches stale (document edited since the search + /// ran). No-op for a buffer with no search state. + pub fn mark_stale(&mut self, buffer_id: BufferId) { + if self.by_buffer.contains_key(&buffer_id) { + self.stale.insert(buffer_id); + } + } + + /// `true` iff the buffer's matches are stale. + #[must_use] + pub fn is_stale(&self, buffer_id: BufferId) -> bool { + self.stale.contains(&buffer_id) + } + + /// Step the active match forward or backward, wrapping. Returns + /// the new active match's range, or `None` when the buffer has no + /// matches. + pub fn step(&mut self, buffer_id: BufferId, forward: bool) -> Option { + let s = self.by_buffer.get_mut(&buffer_id)?; + let n = s.matches.len(); + if n == 0 { + return None; + } + s.active = if forward { + (s.active + 1) % n + } else { + (s.active + n - 1) % n + }; + s.matches.get(s.active).copied() + } + + /// Focus the first match at or after `byte` (wrapping to the first + /// match when all matches precede `byte`). Used on entry to focus + /// the match nearest the cursor. Returns the focused range. + pub fn focus_from(&mut self, buffer_id: BufferId, byte: u64) -> Option { + let s = self.by_buffer.get_mut(&buffer_id)?; + if s.matches.is_empty() { + return None; + } + let idx = s.matches.iter().position(|m| m.start >= byte).unwrap_or(0); + s.active = idx; + s.matches.get(idx).copied() + } +} + +/// Cheaply-cloneable shared handle, mirroring +/// [`crate::diag::SharedDiagStore`]. +pub type SharedSearchStore = Arc>; + +/// Build a fresh shared store. +#[must_use] +pub fn make_shared_store() -> SharedSearchStore { + Arc::new(Mutex::new(SearchStore::new())) +} + +/// Smart-case substring search over `haystack` bytes for `query`: +/// case-insensitive unless `query` contains an uppercase character, +/// in which case it is case-sensitive. Returns non-overlapping +/// matches as byte ranges, ascending. An empty query yields no +/// matches. +/// +/// **ASCII case folding.** Case-insensitivity folds ASCII letters +/// only (`eq_ignore_ascii_case`), which keeps byte offsets exact +/// (ASCII upper/lower are the same byte length). Non-ASCII bytes +/// compare exactly, so a query with non-ASCII letters matches those +/// case-sensitively — acceptable for v1 (code search is +/// overwhelmingly ASCII); a full-Unicode fold is a later refinement. +#[must_use] +pub fn find_all(haystack: &[u8], query: &str) -> Vec { + let q = query.as_bytes(); + if q.is_empty() || haystack.len() < q.len() { + return Vec::new(); + } + let case_sensitive = query.chars().any(char::is_uppercase); + let matches_at = |i: usize| { + haystack[i..i + q.len()].iter().zip(q).all(|(&h, &needle)| { + if case_sensitive { + h == needle + } else { + h.eq_ignore_ascii_case(&needle) + } + }) + }; + let mut out = Vec::new(); + let mut i = 0; + while i + q.len() <= haystack.len() { + if matches_at(i) { + out.push(ByteRange { + start: i as u64, + end: (i + q.len()) as u64, + }); + i += q.len(); // non-overlapping + } else { + i += 1; + } + } + out +} + +// --------------------------------------------------------------------------- +// TUI view +// --------------------------------------------------------------------------- + +use crate::buffer::Buffer; +use crate::cell::{CellCoord, CellGrid, Color, Style}; +use crate::overlay::merge_styles; +use crate::view::{View, Viewport}; + +/// Background style applied to a non-active search match (Q#SR4) — +/// black-on-yellow so the highlighted text reads on any theme. +fn match_style() -> Style { + Style { + bg: Color::Indexed(3), // yellow + fg: Color::Indexed(0), // black + ..Style::default() + } +} + +/// Background style for the active match — brighter yellow so it +/// stands out from the lazy matches as you step through. +fn active_match_style() -> Style { + Style { + bg: Color::Indexed(11), // bright yellow + fg: Color::Indexed(0), + ..Style::default() + } +} + +/// TUI overlay that washes search matches in the visible region, +/// mirroring [`crate::diag::DiagnosticView`]: snapshot the store under +/// the lock, skip while stale, map each match's byte range to display +/// columns, and merge the highlight style into those cells. Matches +/// are single-line (the minibuffer query carries no newline), so each +/// maps to one row. +pub struct SearchView { + store: SharedSearchStore, +} + +impl SearchView { + /// Construct a view reading `store` for whichever buffer the host + /// window is showing. The view keys on the *rendered* buffer + /// ([`Buffer::id`]) rather than a fixed id, so a single attached + /// instance keeps highlighting correctly even if the window + /// switches buffers (the store is per-buffer; a buffer with no + /// search entry simply paints nothing). + #[must_use] + pub fn new(store: SharedSearchStore) -> Self { + Self { store } + } +} + +impl View for SearchView { + fn kind(&self) -> &'static str { + "search" + } + + fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) { + let buffer_id = buf.id(); + // Snapshot the matches under the lock, release immediately + // (same discipline as DiagnosticView). + let (matches, active): (Vec, Option) = { + let guard = self.store.lock().expect("search store mutex poisoned"); + if guard.is_stale(buffer_id) { + return; + } + match guard.for_buffer(buffer_id) { + Some(s) => (s.matches().to_vec(), s.active_match()), + None => return, + } + }; + if matches.is_empty() { + return; + } + + let source: Vec = { + let mut bytes = vec![0u8; buf.len() as usize]; + if !bytes.is_empty() { + buf.snapshot_rope().slice(0, buf.len(), &mut bytes); + } + bytes + }; + let line_offsets = crate::diag::compute_line_offsets(&source); + let start_line_buf = + crate::diag::line_at_offset(&line_offsets, viewport.buffer_start as u32); + let max_rows = viewport.cell_size.rows; + let max_cols = viewport.cell_size.cols; + let cell_origin = viewport.cell_origin; + + for m in &matches { + let line = crate::diag::line_at_offset(&line_offsets, m.start as u32); + if line < start_line_buf { + continue; + } + let row_offset = line - start_line_buf; + if row_offset >= max_rows { + break; + } + let line_start = line_offsets[line as usize]; + let line_end = line_offsets + .get(line as usize + 1) + .copied() + .unwrap_or(source.len() as u32); + let line_end_no_nl = if line_end > line_start + && source.get(line_end as usize - 1).copied() == Some(b'\n') + { + line_end - 1 + } else { + line_end + }; + let line_bytes = &source[line_start as usize..line_end_no_nl as usize]; + let within_start = (m.start as u32).saturating_sub(line_start) as usize; + let within_end = (m.end as u32).saturating_sub(line_start) as usize; + let (start_col, end_col) = + crate::diag::byte_range_to_display_cols(line_bytes, within_start, within_end); + if end_col <= start_col { + continue; + } + let style = if Some(*m) == active { + active_match_style() + } else { + match_style() + }; + let cell_row = cell_origin.row + row_offset; + let clamped_start = start_col.min(max_cols); + let clamped_end = end_col.min(max_cols); + for col in clamped_start..clamped_end { + let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col)); + cell.style = merge_styles(cell.style, style); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(start: u64, end: u64) -> ByteRange { + ByteRange { start, end } + } + + #[test] + fn find_all_is_case_insensitive_for_lowercase_queries() { + // "fn" matches "fn" and "Fn" and "FN" when the query is all + // lowercase (smart-case). + let hay = b"fn Fn FN fnord"; + assert_eq!( + find_all(hay, "fn"), + vec![r(0, 2), r(3, 5), r(6, 8), r(9, 11)] + ); + } + + #[test] + fn find_all_is_case_sensitive_when_query_has_uppercase() { + // "Fn" (has uppercase) matches only "Fn". + let hay = b"fn Fn FN"; + assert_eq!(find_all(hay, "Fn"), vec![r(3, 5)]); + } + + #[test] + fn find_all_matches_are_non_overlapping() { + // "aa" in "aaaa": [0,2) and [2,4), not [1,3). + assert_eq!(find_all(b"aaaa", "aa"), vec![r(0, 2), r(2, 4)]); + } + + #[test] + fn find_all_empty_query_and_too_short_haystack() { + assert!(find_all(b"hello", "").is_empty()); + assert!(find_all(b"hi", "hello").is_empty()); + assert!(find_all(b"", "x").is_empty()); + } + + #[test] + fn store_set_clamps_active_and_clears_on_empty() { + let mut s = SearchStore::new(); + let bid = BufferId::next(); + s.set(bid, "x", vec![r(0, 1), r(4, 5), r(8, 9)]); + // Step to the last match, then a shorter re-search clamps the + // active index instead of pointing past the end. + s.step(bid, true); + s.step(bid, true); + assert_eq!(s.for_buffer(bid).unwrap().active_index(), Some(2)); + s.set(bid, "x", vec![r(0, 1)]); + assert_eq!(s.for_buffer(bid).unwrap().active_index(), Some(0)); + // Empty query drops the entry. + s.set(bid, "", vec![]); + assert!(s.for_buffer(bid).is_none()); + } + + #[test] + fn store_step_wraps_both_directions() { + let mut s = SearchStore::new(); + let bid = BufferId::next(); + s.set(bid, "x", vec![r(0, 1), r(4, 5), r(8, 9)]); + assert_eq!(s.step(bid, true), Some(r(4, 5))); + assert_eq!(s.step(bid, true), Some(r(8, 9))); + assert_eq!(s.step(bid, true), Some(r(0, 1)), "wraps to first"); + assert_eq!(s.step(bid, false), Some(r(8, 9)), "wraps back to last"); + } + + #[test] + fn store_focus_from_picks_match_at_or_after_cursor() { + let mut s = SearchStore::new(); + let bid = BufferId::next(); + s.set(bid, "x", vec![r(2, 3), r(10, 11), r(20, 21)]); + assert_eq!(s.focus_from(bid, 5), Some(r(10, 11))); + assert_eq!(s.for_buffer(bid).unwrap().active_index(), Some(1)); + // Past the last match → wrap to the first. + assert_eq!(s.focus_from(bid, 99), Some(r(2, 3))); + } + + #[test] + fn search_view_washes_matches_and_distinguishes_active() { + use crate::cell::{Cell, CellGrid, CellSize}; + use crate::view::Viewport; + + let store = make_shared_store(); + let bid = BufferId::next(); + let mut buf = Buffer::new(bid, "test.txt"); + buf.apply_edit(crate::buffer::EditOp::Insert { + pos: 0, + bytes: b"lo lo lo\n", + }) + .expect("seed"); + store + .lock() + .unwrap() + .set(bid, "lo", find_all(b"lo lo lo\n", "lo")); + + let mut view = SearchView::new(store.clone()); + let mut backing = vec![Cell::default(); 10]; + let mut grid = CellGrid { + cells: &mut backing, + stride: 10, + size: CellSize::new(1, 10), + }; + view.render( + &buf, + Viewport { + buffer_start: 0, + buffer_end: buf.len(), + cell_origin: CellCoord::new(0, 0), + cell_size: CellSize::new(1, 10), + }, + &mut grid, + ); + + // Match 0 [0,2) is active (bright yellow), matches at [3,5) and + // [6,8) are lazy (yellow); the spaces between carry no bg. + assert_eq!(grid.get(CellCoord::new(0, 0)).style.bg, Color::Indexed(11)); + assert_eq!(grid.get(CellCoord::new(0, 1)).style.bg, Color::Indexed(11)); + assert_eq!(grid.get(CellCoord::new(0, 2)).style.bg, Color::Default); + assert_eq!(grid.get(CellCoord::new(0, 3)).style.bg, Color::Indexed(3)); + assert_eq!(grid.get(CellCoord::new(0, 6)).style.bg, Color::Indexed(3)); + + // Stale store paints nothing. + store.lock().unwrap().mark_stale(bid); + let mut backing2 = vec![Cell::default(); 10]; + let mut grid2 = CellGrid { + cells: &mut backing2, + stride: 10, + size: CellSize::new(1, 10), + }; + view.render( + &buf, + Viewport { + buffer_start: 0, + buffer_end: buf.len(), + cell_origin: CellCoord::new(0, 0), + cell_size: CellSize::new(1, 10), + }, + &mut grid2, + ); + assert_eq!( + grid2.get(CellCoord::new(0, 0)).style.bg, + Color::Default, + "stale store washes nothing" + ); + } + + #[test] + fn store_staleness_tracks_per_buffer() { + let mut s = SearchStore::new(); + let bid = BufferId::next(); + s.set(bid, "x", vec![r(0, 1)]); + assert!(!s.is_stale(bid)); + s.mark_stale(bid); + assert!(s.is_stale(bid)); + // A fresh set clears stale. + s.set(bid, "x", vec![r(0, 1)]); + assert!(!s.is_stale(bid)); + // mark_stale is a no-op for a buffer with no search state. + let other = BufferId::next(); + s.mark_stale(other); + assert!(!s.is_stale(other)); + } +} diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 01360fc..990baa5 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -121,6 +121,10 @@ pub struct SemanticRenderState { /// `(name, modified, diag_errors, diag_warnings)` last emitted as /// `StatusFacts` (Q#S1) — cached-compare suppression. last_status: HashMap, + /// `(query, active, total)` last emitted as `SearchPrompt` + /// (Q#SR5) — cached-compare suppression. A `None` query means the + /// last emission cleared the band (no active search). + last_search_prompt: HashMap, Option, u32)>, /// `StyleSpans` recompute gate (perf). `scoped_style_spans` runs /// the tree-sitter highlights query over the *whole declared /// viewport* (which the GPU frontend sets to the entire buffer) @@ -189,6 +193,7 @@ impl SemanticRenderState { last_sent: HashMap::new(), last_decorations: HashMap::new(), last_adornments: HashMap::new(), + last_search_prompt: HashMap::new(), last_summary: HashMap::new(), last_status: HashMap::new(), last_style_gate: HashMap::new(), @@ -389,9 +394,73 @@ impl SemanticRenderState { out.extend(self.file_style_summary_msg(state, vp.buffer_id, generation)); // --- StatusFacts (status band; Q#S1, protocol v8) --- out.extend(self.status_facts_msg(state, vp.buffer_id)); + // --- SearchPrompt (isearch band; Q#SR5, protocol v9) --- + out.extend(self.search_prompt_msg(state, vp.buffer_id)); out } + /// The `SearchPrompt` message for this frame, or `None` when the + /// search state for `buffer_id` is unchanged. Only the active + /// buffer carries a live prompt: a search shadows dispatch, so it + /// always runs in the active buffer, and emitting for that buffer's + /// viewport keeps the per-buffer cached-compare honest. When no + /// search runs the active buffer emits `query: None` once (to clear + /// the frontend's band), then stays silent. The daemon's write loop + /// keeps the variant off wires negotiated `< 9`. + fn search_prompt_msg( + &mut self, + state: &EditorState, + buffer_id: BufferId, + ) -> Option { + // Off-active-buffer viewports never touch the search band — the + // active buffer owns it. (Without this, switching buffers mid- + // session would let an inactive viewport clobber the cache.) + let facts = { + let core = state.core.borrow(); + if buffer_id != core.active_buffer_id() { + return None; + } + if core.search_active() { + let (active_idx, total) = core.search_match_summary(); + ( + Some(core.search_query().to_owned()), + active_idx.and_then(|i| u32::try_from(i).ok()), + u32::try_from(total).unwrap_or(u32::MAX), + ) + } else { + // No search → a cleared band. active/total are zeroed so + // the inactive state is one canonical tuple (the GPU only + // reads them when `query` is `Some`). The accepted matches + // keep highlighting via Decorations regardless. + (None, None, 0) + } + }; + if self.last_search_prompt.get(&buffer_id) == Some(&facts) { + return None; + } + let cached = self.last_search_prompt.get(&buffer_id); + if cached == Some(&facts) { + return None; + } + // First sight of this buffer with no active search: there is + // nothing to clear, so stay silent rather than ship an empty + // band on every fresh buffer. Record the baseline so a *later* + // search→clear transition still diffs. (Mirrors the inline- + // adornments "speak only if there's something to show" rule.) + if cached.is_none() && facts.0.is_none() { + self.last_search_prompt.insert(buffer_id, facts); + return None; + } + let msg = InstanceMessage::SearchPrompt { + buffer_id, + query: facts.0.clone(), + active: facts.1, + total: facts.2, + }; + self.last_search_prompt.insert(buffer_id, facts); + Some(msg) + } + /// The `StatusFacts` message for this frame, or `None` when /// nothing changed. Carries the facts a semantic frontend cannot /// derive locally: buffer name, modified flag, whole-file @@ -715,6 +784,34 @@ impl SemanticRenderState { } } + // In-buffer search matches (Q#SR3). Already byte ranges — no + // line/col conversion. Skipped while stale (an edit leaves the + // matches at pre-edit positions until the next re-search, the + // M11.8 model). The active match emits `SearchMatchActive`, + // the rest `SearchMatch`; matches are non-overlapping so each + // range carries exactly one kind. + { + let store = core.search_store.clone(); + let guard = store.lock().expect("search store mutex poisoned"); + if !guard.is_stale(vp.buffer_id) + && let Some(search) = guard.for_buffer(vp.buffer_id) + { + let active = search.active_match(); + for m in search.matches() { + if let Some(range) = clip_to_viewport(m.start, m.end, vp) { + out.push(Decoration { + range, + kind: if Some(*m) == active { + DecorationKind::SearchMatchActive + } else { + DecorationKind::SearchMatch + }, + }); + } + } + } + } + out } } @@ -1434,8 +1531,8 @@ mod tests { /// All `InstanceMessage` variants the semantic projection may /// emit are `StyleSpans`, `Decorations`, `InlineAdornments`, - /// `FileStyleSummary`, or `StatusFacts` (Q#S1) — never - /// `CellDelta`, grid `Cursor`, or the still-unwired + /// `FileStyleSummary`, `StatusFacts` (Q#S1), or `SearchPrompt` + /// (Q#SR5) — never `CellDelta`, grid `Cursor`, or the still-unwired /// `BlockAdornments` / `FoldState` families. fn assert_semantic_only(msgs: &[InstanceMessage]) { for m in msgs { @@ -1447,6 +1544,7 @@ mod tests { | InstanceMessage::InlineAdornments { .. } | InstanceMessage::FileStyleSummary { .. } | InstanceMessage::StatusFacts { .. } + | InstanceMessage::SearchPrompt { .. } ), "semantic projection emitted an unexpected variant: {m:?}" ); @@ -1518,6 +1616,76 @@ mod tests { ); } + #[test] + fn search_matches_emit_as_decorations_with_active_distinguished() { + let state = empty_state(); + let mut s = local(); + let bid = active_buffer(&state); + // "lo lo lo" — three "lo" matches at 0..2, 3..5, 6..8. + { + let core = state.core.borrow(); + core.registry + .clone() + .borrow_mut() + .get_mut(bid) + .expect("active buffer") + .apply_edit(crate::buffer::EditOp::Insert { + pos: 0, + bytes: b"lo lo lo", + }) + .expect("seed"); + } + { + let store = state.core.borrow().search_store.clone(); + let matches = crate::search::find_all(b"lo lo lo", "lo"); + store.lock().expect("search store").set(bid, "lo", matches); + } + s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0); + + let (_full, decos) = + decorations_of(&s.render_frame(&state)).expect("search frame ships decorations"); + let search: Vec<_> = decos + .iter() + .filter(|d| { + matches!( + d.kind, + DecorationKind::SearchMatch | DecorationKind::SearchMatchActive + ) + }) + .collect(); + assert_eq!(search.len(), 3, "three matches highlighted; got {decos:?}"); + let active: Vec<_> = decos + .iter() + .filter(|d| d.kind == DecorationKind::SearchMatchActive) + .collect(); + assert_eq!(active.len(), 1, "exactly one active match"); + assert_eq!( + active[0].range, + ByteRange { start: 0, end: 2 }, + "the first match is active by default" + ); + + // Marking the store stale suppresses search emission (M11.8): + // the next frame ships a clearing diff, never a search kind. + state + .core + .borrow() + .search_store + .clone() + .lock() + .expect("search store") + .mark_stale(bid); + if let Some((_full, decos)) = decorations_of(&s.render_frame(&state)) { + assert!( + decos.iter().all(|d| !matches!( + d.kind, + DecorationKind::SearchMatch | DecorationKind::SearchMatchActive + )), + "stale search store paints no matches; got {decos:?}" + ); + } + } + #[test] fn emits_nothing_before_viewport_declared() { let mut s = local(); @@ -2887,6 +3055,77 @@ mod tests { }) } + fn search_prompt_of(msgs: &[InstanceMessage]) -> Option<(Option, Option, u32)> { + msgs.iter().find_map(|m| match m { + InstanceMessage::SearchPrompt { + query, + active, + total, + .. + } => Some((query.clone(), *active, *total)), + _ => None, + }) + } + + #[test] + fn search_prompt_emits_on_change_and_clears_on_finish() { + let state = empty_state(); + let mut s = local(); + let bid = active_buffer(&state); + // Three "foo" matches. + { + let core = state.core.borrow(); + core.registry + .clone() + .borrow_mut() + .get_mut(bid) + .expect("active buffer") + .apply_edit(crate::buffer::EditOp::Insert { + pos: 0, + bytes: b"foo foo foo", + }) + .expect("seed"); + } + s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0); + + // No search yet: any prompt that ships carries a cleared query. + if let Some((q, _, _)) = search_prompt_of(&s.render_frame(&state)) { + assert!(q.is_none(), "no search ⇒ no live query"); + } + + // Begin + type "foo": the live query + active/total ship. + { + let mut core = state.core.borrow_mut(); + core.search_begin(true); + for ch in "foo".chars() { + core.search_input_char(ch); + } + } + assert_eq!( + search_prompt_of(&s.render_frame(&state)), + Some((Some("foo".to_owned()), Some(0), 3)), + "live isearch ships query + (active, total)" + ); + // Unchanged → suppressed (cached-compare). + assert!(search_prompt_of(&s.render_frame(&state)).is_none()); + + // Step: active index advances and re-emits. + state.core.borrow_mut().search_step(true); + assert_eq!( + search_prompt_of(&s.render_frame(&state)), + Some((Some("foo".to_owned()), Some(1), 3)) + ); + + // Accept: the prompt band clears (query None) even though the + // matches stay in the store for navigation + highlight. + state.core.borrow_mut().search_finish(true); + assert_eq!( + search_prompt_of(&s.render_frame(&state)), + Some((None, None, 0)), + "accept clears the prompt band" + ); + } + #[test] fn status_facts_emit_on_change_and_freeze_counts_while_stale() { let state = empty_state();