From d45b064ab2400c57faf285802a801261da428150 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 10:33:40 -0400 Subject: [PATCH 1/7] framing: incremental in-buffer search (smart-case isearch) Decided: incremental isearch (live highlight, same key steps next, Enter accepts, Esc restores origin) + smart-case substring. Q#SR1 per-buffer SearchStore mirroring DiagnosticStore; Q#SR2 smart-case find_all over a rope snapshot (regex deferred); Q#SR3 producer + TUI SearchView mirror the diagnostics path; Q#SR4 yellow/amber palette; Q#SR5 minibuffer-hosted query + new on_changed hook + next/prev interception; Q#SR6 navigation mirrors diag.next/prev. Four bets (stale-after-edit linger the headline). Three-commit arc. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/incremental-search-framing.md | 123 +++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/incremental-search-framing.md 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). From b167b9a6dfff4d10ccd1b2b37eecb5c6a5e52f45 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 10:38:22 -0400 Subject: [PATCH 2/7] search: core SearchStore + smart-case find_all (Q#SR1/SR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-buffer in-buffer-search store, mirroring DiagnosticStore: keyed Arc, sorted match ranges + active index, next/prev stepping with wrap, focus-from-cursor, and stale-on-edit tracking (M11.8 model — an edit suppresses matches at pre-edit byte positions until re-search). find_all is smart-case substring (case-insensitive unless the query has an uppercase char), ASCII case-folded to keep byte offsets exact, non-overlapping matches. Pure core, no wiring yet. 8 unit tests (case folding, overlap, wrap, focus, stale, active-clamp-on-reset). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib.rs | 1 + src/search.rs | 325 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 src/search.rs 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/search.rs b/src/search.rs new file mode 100644 index 0000000..0035eaa --- /dev/null +++ b/src/search.rs @@ -0,0 +1,325 @@ +// 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 +} + +#[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 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)); + } +} From 7c46288ef0bba4c8523e32b5ef5b6f1db6cbcffe Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 10:48:06 -0400 Subject: [PATCH 3/7] search: render matches in both frontends (Q#SR3/SR4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search store now hangs off EditorCore (reachable by the producer, the Lua commands, and the TUI view). The decorations producer emits SearchMatch for every visible match and SearchMatchActive for the active one, byte-range-direct (no line/col conversion), viewport-clipped, and stale-skipped on the M11.8 model. pmacs-gpu wires the two decoration_kind_to_bg_color arms (translucent yellow / stronger amber). The TUI gets a SearchView overlay mirroring DiagnosticView (black-on-yellow wash, brighter for the active match), reusing diag.rs's now-pub(crate) line/col helpers. Nothing populates the store yet (commit 3 wires the input), so the paths are dormant until then — verified by populating the store directly: producer emits the right kinds + stale-suppresses, the TUI view washes the cells. Co-Authored-By: Claude Opus 4.8 (1M context) --- pmacs-gpu/src/main.rs | 27 +++--- src/diag.rs | 10 ++- src/editor_core.rs | 7 ++ src/search.rs | 198 +++++++++++++++++++++++++++++++++++++++++ src/semantic_render.rs | 98 ++++++++++++++++++++ 5 files changed, 322 insertions(+), 18 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index cddab83..ccd3b8b 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -4874,8 +4874,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 @@ -5566,14 +5570,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 +5609,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/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_core.rs b/src/editor_core.rs index 5ad0bd9..b993951 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -127,6 +127,12 @@ 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, } impl EditorCore { @@ -161,6 +167,7 @@ impl EditorCore { active_frontend: FrontendId::LOCAL, pending_crdt_ops: Vec::new(), jump_ring: Vec::new(), + search_store: crate::search::make_shared_store(), } } diff --git a/src/search.rs b/src/search.rs index 0035eaa..88e936d 100644 --- a/src/search.rs +++ b/src/search.rs @@ -228,6 +228,135 @@ pub fn find_all(haystack: &[u8], query: &str) -> Vec { 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 { + buffer_id: BufferId, + store: SharedSearchStore, +} + +impl SearchView { + /// Construct a view reading `store` for `buffer_id`. + #[must_use] + pub fn new(buffer_id: BufferId, store: SharedSearchStore) -> Self { + Self { buffer_id, store } + } +} + +impl View for SearchView { + fn kind(&self) -> &'static str { + "search" + } + + fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) { + // 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(self.buffer_id) { + return; + } + match guard.for_buffer(self.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::*; @@ -306,6 +435,75 @@ mod tests { 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(bid, 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(); diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 01360fc..cbb0961 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -715,6 +715,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 } } @@ -1518,6 +1546,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(); From 58b68f6ac07d26c93df19292c97d0adfc5989fbc Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 11:15:26 -0400 Subject: [PATCH 4/7] search: TUI incremental isearch input (Q#SR5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the live-typing half of in-buffer search for the terminal frontend, on a frontend-agnostic core so the GPU (next commit) can share it. EditorCore gains a `search: Option` (query + origin cursor + direction) and the `search_*` methods that drive it: begin records the origin, input_char/backspace re-run `find_all` against the origin buffer and refocus the match nearest the origin (failing searches anchor the cursor back at the origin), step walks the store's active match (wrapping, also usable post-accept), and finish either keeps the cursor + matches (accept) or restores the origin and clears them (cancel). The matches live in the shared `search_store`, so the decorations producer and the TUI SearchView light up live as you type. Input routing is intercepted in `EditorState::dispatch_key`: while a search runs, every key flows through `dispatch_search_key` (SearchKey::from_chord) instead of the global keymap — printable chars extend the query, C-s/C-r (and Down/Up) step, RET accepts, C-g/Esc cancel, BS shortens. This is the same dispatch path the daemon's `FrontendEvent::Key` round-trip uses, so the daemon-side search already works; the GPU just needs to route keys + show the prompt (commit 4). The TUI paints an `I-search: (n/m)` prompt on the bottom row while keeping the terminal cursor in the buffer at the active match. C-s / C-r start the search (search.forward / search.backward Lua commands → ed.search_start). Both keys were free in the default map (save is C-x C-s, redo is C-x r), so isearch lands without disturbing the CUA / Emacs editing keys — no cursor.right rebind needed (the framing doc had flagged C-f for veto; C-s is cleaner and Emacs-faithful). Any edit now marks the buffer's matches stale in apply_active_edit (M11.8), closing the headline "stale-after-edit linger" bet: accepted highlights vanish the moment the text they described changes, rather than painting at wrong offsets. Tests: EditorCore-level (begin/type/step/wrap/focus-from-origin/ cancel/accept/backspace/smart-case/stale-on-edit) and dispatch- level acceptance (C-s drives the whole loop; Esc restores; query keys never self-insert). Co-Authored-By: Claude Opus 4.8 (1M context) --- builtin/commands/default.lua | 15 ++ builtin/keymaps/default.lua | 10 + src/editor.rs | 226 +++++++++++++++++++++- src/editor_core.rs | 352 +++++++++++++++++++++++++++++++++++ src/lua_bindings.rs | 43 +++++ 5 files changed, 645 insertions(+), 1 deletion(-) diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index 8b4c992..cb71f68 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -117,6 +117,21 @@ cmd { name = "buffer.self-insert", description = "Insert the codepoint argument at the cursor, replacing the active region.", fn = function(codepoint) ed.insert_char_over_region(codepoint) end } +-- Incremental search --------------------------------------------------------- +-- +-- C-s / C-r begin a live in-buffer isearch: the match under the cursor +-- highlights as you type, the same key steps to the next/previous +-- match, RET accepts (keeping the highlights until the next edit), and +-- C-g / Esc restore the pre-search cursor. While a search is running +-- every keystroke is intercepted in Rust (dispatch_search_key), so +-- these commands only run to *start* a search from an idle keymap. +cmd { name = "search.forward", + description = "Start an incremental search forward from the cursor.", + fn = function() ed.search_start(true) end } +cmd { name = "search.backward", + description = "Start an incremental search backward from the cursor.", + fn = function() ed.search_start(false) end } + -- History -------------------------------------------------------------------- cmd { name = "buffer.undo", description = "Undo the most recent edit.", diff --git a/builtin/keymaps/default.lua b/builtin/keymaps/default.lua index 16adab5..46fe6c4 100644 --- a/builtin/keymaps/default.lua +++ b/builtin/keymaps/default.lua @@ -52,6 +52,16 @@ bind("C-d", "buffer.delete-forward") bind("RET", "buffer.newline") bind("TAB", "buffer.tab") +-- Incremental search --------------------------------------------------------- +-- +-- C-s / C-r start a live isearch (forward / backward). Both keys are +-- free in the default map (save is C-x C-s, redo is C-x r), so this +-- adds isearch without colliding with the CUA / Emacs editing keys. +-- Once a search is running, C-s / C-r step to the next / previous +-- match; that interception happens in Rust, so it needs no binding. +bind("C-s", "search.forward") +bind("C-r", "search.backward") + -- CUA-style word-level deletion (the same shortcuts users expect from -- IDEs, browsers, terminals on Linux/Windows). C-BS deletes back to -- the start of the previous word; C-DEL deletes forward through the diff --git a/src/editor.rs b/src/editor.rs index 5016b76..204325f 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -484,6 +484,17 @@ impl EditorState { core.active_frontend = frontend_id; } + // Incremental-search interception: while an isearch is running, + // every key routes through the search handler (the global keymap + // is shadowed, like the minibuffer). Printable chars extend the + // query; C-s / C-r step; RET accepts; C-g / Esc cancel. This is + // the shared input path for both frontends — the daemon's + // `FrontendEvent::Key` round-trip lands here too. + if self.core.borrow().search_active() { + self.dispatch_search_key(chord); + return; + } + // Minibuffer interception: when a prompt is active, every key // routes through the minibuffer's hardcoded handler. The main // editor's keymap is bypassed; the user can still cancel with @@ -615,6 +626,33 @@ impl EditorState { } } + /// Hardcoded handler for keys delivered while an incremental search + /// is active. The global keymap is shadowed (like the minibuffer), + /// so these chords are fixed: + /// + /// * `C-s` / `Down` --- step to the next match (wraps). + /// * `C-r` / `Up` --- step to the previous match (wraps). + /// * `RET` --- accept (keep cursor + highlights). + /// * `C-g` / `Esc` --- cancel (restore origin cursor). + /// * `BS` --- shorten the query by one char. + /// * a printable char --- extend the query. + /// + /// Unrecognized chords are swallowed (an active isearch eats every + /// keystroke, matching Emacs). The next/prev chords mirror the + /// entry bindings (`search.forward` / `search.backward`) so the + /// same key that started the search repeats it. + fn dispatch_search_key(&mut self, chord: Chord) { + match SearchKey::from_chord(chord) { + SearchKey::Next => self.core.borrow_mut().search_step(true), + SearchKey::Prev => self.core.borrow_mut().search_step(false), + SearchKey::Accept => self.core.borrow_mut().search_finish(true), + SearchKey::Cancel => self.core.borrow_mut().search_finish(false), + SearchKey::Backspace => self.core.borrow_mut().search_backspace(), + SearchKey::Insert(ch) => self.core.borrow_mut().search_input_char(ch), + SearchKey::Ignore => {} + } + } + fn with_minibuffer(&mut self, f: F) { f(&mut self.core.borrow_mut().minibuffer); } @@ -1161,6 +1199,65 @@ fn process_event(state: &mut EditorState, ev: Event, term_size: crate::cell::Cel } } +/// Decoded action for a key delivered while an incremental search is +/// active. Mirrors [`crate::minibuffer::MinibufferAction`]: the +/// bindings are hardcoded (not user-configurable) because isearch +/// shadows the global keymap; changes happen by extending this enum. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum SearchKey { + /// Step to the next match (C-s / Down). + Next, + /// Step to the previous match (C-r / Up). + Prev, + /// Accept: keep cursor + highlights (RET). + Accept, + /// Cancel: restore the origin cursor (C-g / Esc). + Cancel, + /// Shorten the query by one character (BS). + Backspace, + /// Extend the query with a printable character. + Insert(char), + /// Unhandled --- swallowed without complaint. + Ignore, +} + +impl SearchKey { + /// Decode `chord` into an isearch action. The next/prev chords + /// match the entry bindings (`C-s` forward, `C-r` backward) so the + /// search-starting key repeats the search; arrow keys offer a + /// modifier-free alternative. + fn from_chord(chord: Chord) -> Self { + let ctrl = chord.modifiers.contains(KeyModifiers::CONTROL); + let alt = chord.modifiers.contains(KeyModifiers::ALT); + + if !ctrl && !alt { + match chord.code { + KeyCode::Enter => return Self::Accept, + KeyCode::Esc => return Self::Cancel, + KeyCode::Backspace => return Self::Backspace, + KeyCode::Down => return Self::Next, + KeyCode::Up => return Self::Prev, + KeyCode::Char(ch) => return Self::Insert(ch), + _ => return Self::Ignore, + } + } + if ctrl + && !alt + && let KeyCode::Char(c) = chord.code + { + return match c { + 's' => Self::Next, + 'r' => Self::Prev, + 'm' => Self::Accept, + 'g' => Self::Cancel, + 'h' => Self::Backspace, + _ => Self::Ignore, + }; + } + Self::Ignore + } +} + /// Paint one full frame into `grid` and return the desired terminal /// cursor position. /// @@ -1292,7 +1389,14 @@ pub fn paint_frame( paint_status_line(grid, core, &state.lua_host, &state.dispatcher, term_size); - let mb_cursor_col = if core.minibuffer.is_active() { + // An active isearch owns the bottom row (its prompt + match + // readout), but the terminal cursor stays in the buffer at the + // active match so the eye follows the search — so paint the prompt + // and fall through to the buffer-cursor placement below. + let mb_cursor_col = if core.search_active() { + paint_search_prompt(grid, core, term_size); + None + } else if core.minibuffer.is_active() { Some(paint_minibuffer(grid, core, term_size)) } else { None @@ -1614,6 +1718,61 @@ fn paint_minibuffer( cursor_col.min(max.saturating_sub(1)) } +/// Paint the incremental-search prompt on the bottom row: +/// `I-search: (n/m)`. Backward searches read `I-search +/// backward:`; a non-empty query with no matches reads `[no match]`. +/// Overwrites the status line painted just before it. The terminal +/// cursor is *not* returned here — it stays in the buffer at the +/// active match (see [`paint_frame`]). +fn paint_search_prompt( + grid: &mut crate::cell::CellGrid<'_>, + core: &EditorCore, + term_size: crate::cell::CellSize, +) { + let prompt = if core.search_forward() { + "I-search: " + } else { + "I-search backward: " + }; + let query = core.search_query(); + let (active, total) = core.search_match_summary(); + let suffix = if query.is_empty() { + String::new() + } else if total == 0 { + " [no match]".to_string() + } else { + format!(" ({}/{})", active.map_or(0, |a| a + 1), total) + }; + + let row = term_size.rows - 1; + let max = term_size.cols; + let mut col: u32 = 0; + let put = |grid: &mut crate::cell::CellGrid<'_>, col: &mut u32, ch: char| { + if *col < max { + let cell = grid.at(CellCoord::new(row, *col)); + cell.glyph = crate::cell::Glyph::Char(ch); + cell.style = crate::cell::Style::default(); + *col += 1; + } + }; + for ch in prompt.chars() { + put(grid, &mut col, ch); + } + for ch in query.chars() { + put(grid, &mut col, ch); + } + for ch in suffix.chars() { + put(grid, &mut col, ch); + } + // Clear the remainder of the row (the status line underneath used + // reverse video; blank it with the default style). + for c in col..max { + let cell = grid.at(CellCoord::new(row, c)); + cell.glyph = crate::cell::Glyph::Char(' '); + cell.style = crate::cell::Style::default(); + } +} + /// Build the global status (echo area) row: pure ephemeral state. /// /// Per-window facts (buffer name, modified marker, cursor coord, @@ -1876,6 +2035,71 @@ mod tests { assert!(s.core.borrow().status.contains("no file")); } + // ---- incremental search via dispatch (Q#SR5) --------------------------- + + fn type_chars(s: &mut EditorState, text: &str) { + for c in text.chars() { + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::NONE)); + } + } + + #[test] + fn isearch_dispatch_highlights_steps_and_accepts() { + let mut s = fresh_with(b"foo bar foo baz foo"); + s.core.borrow_mut().active_window_mut().cursor = 0; + // C-s begins the search (via the search.forward command). + s.dispatch_key(FrontendId::LOCAL, ctrl('s')); + assert!(s.core.borrow().search_active()); + // Typing extends the query; the first match is focused. + type_chars(&mut s, "foo"); + assert_eq!(s.core.borrow().search_match_summary(), (Some(0), 3)); + assert_eq!(s.core.borrow().cursor(), 0); + // C-s now steps (intercepted) rather than re-running the command. + s.dispatch_key(FrontendId::LOCAL, ctrl('s')); + assert_eq!(s.core.borrow().cursor(), 8); + // RET accepts: search ends, cursor holds, matches persist. + s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Enter)); + assert!(!s.core.borrow().search_active()); + assert_eq!(s.core.borrow().cursor(), 8); + let bid = s.core.borrow().active_buffer_id(); + assert!( + s.core + .borrow() + .search_store + .lock() + .expect("store") + .for_buffer(bid) + .is_some(), + "accepted matches stay for highlight + navigation" + ); + } + + #[test] + fn isearch_dispatch_esc_restores_origin() { + let mut s = fresh_with(b"foo bar foo"); + s.core.borrow_mut().active_window_mut().cursor = 5; + s.dispatch_key(FrontendId::LOCAL, ctrl('s')); + type_chars(&mut s, "foo"); + assert_eq!(s.core.borrow().cursor(), 8); + // Esc cancels: the pre-search cursor is restored, no edit happened. + s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Esc)); + assert!(!s.core.borrow().search_active()); + assert_eq!(s.core.borrow().cursor(), 5); + assert_eq!(s.core.borrow().active_buffer_len(), 11); + } + + #[test] + fn isearch_dispatch_keys_do_not_self_insert() { + let mut s = fresh_with(b"foo"); + s.core.borrow_mut().active_window_mut().cursor = 3; + s.dispatch_key(FrontendId::LOCAL, ctrl('s')); + type_chars(&mut s, "foo"); + // While searching, printable keys feed the query — the buffer is + // untouched (no self-insert). + assert_eq!(s.core.borrow().active_buffer_len(), 3); + assert_eq!(s.core.borrow().search_query(), "foo"); + } + // ---- T M11.6 — DispatchIdle --------------------------------------------- #[test] diff --git a/src/editor_core.rs b/src/editor_core.rs index b993951..7cf4c9b 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -57,6 +57,29 @@ pub enum CrdtOpOrigin { DaemonKey, } +/// Live state of an in-progress incremental search (Q#SR5). +/// +/// Present only while an isearch is running (`EditorCore::search`); +/// `None` otherwise. Holds the query as typed so far plus the cursor +/// origin to restore on cancel. The *matches* themselves live in +/// [`crate::search::SearchStore`] (shared with the decorations +/// producer and the TUI overlay); this struct is the per-session +/// input state that drives `find_all`. +#[derive(Clone, Debug)] +pub struct SearchSession { + /// The query as typed so far. Each edit re-runs `find_all`. + query: String, + /// Buffer + cursor position when the search began. `C-g` / `Esc` + /// restores this; `RET` keeps the current (match) cursor. The + /// buffer id also anchors `find_all` to the buffer the search + /// started in. + origin: (BufferId, Position), + /// Direction of the most recent step/begin. `true` = forward. + /// Drives the prompt label ("I-search" vs "I-search backward") + /// and the wrap direction of an empty-query repeat. + forward: bool, +} + /// The world state mutated by editor commands. pub struct EditorCore { /// Shared buffer registry. The registry is the canonical owner @@ -133,6 +156,13 @@ pub struct EditorCore { /// ([`crate::semantic_render`]) and the TUI search overlay. /// Cheaply cloneable (`Arc`); shared with both readers. pub search_store: crate::search::SharedSearchStore, + /// Live incremental-search session (Q#SR5), or `None` when no + /// search is running. Frontend-agnostic: the TUI run loop and the + /// daemon's `FrontendEvent::Key` path both drive it through the + /// same `search_*` methods, so isearch behaves identically in the + /// terminal and GPU frontends. Only the *prompt surface* differs + /// (TUI bottom row vs GPU status band). + pub search: Option, } impl EditorCore { @@ -168,6 +198,7 @@ impl EditorCore { pending_crdt_ops: Vec::new(), jump_ring: Vec::new(), search_store: crate::search::make_shared_store(), + search: None, } } @@ -476,6 +507,179 @@ impl EditorCore { false } + // ---- incremental search (Q#SR5) ---------------------------------------- + // + // Frontend-agnostic isearch driven entirely through these methods. + // The TUI's `dispatch_search_key` and (later) the GPU's round-tripped + // keystrokes both call into here, so search behaves identically in + // both frontends. Matches live in `search_store` (shared with the + // decorations producer and the TUI overlay); `search` holds the + // live query + origin. + + /// `true` iff an incremental search is in progress. + #[must_use] + pub fn search_active(&self) -> bool { + self.search.is_some() + } + + /// The current isearch query (empty when no search is running). + #[must_use] + pub fn search_query(&self) -> &str { + self.search.as_ref().map_or("", |s| s.query.as_str()) + } + + /// Direction of the active search (`true` = forward). Defaults to + /// forward when no search is running — callers should gate on + /// [`Self::search_active`] first. + #[must_use] + pub fn search_forward(&self) -> bool { + self.search.as_ref().is_none_or(|s| s.forward) + } + + /// `(active_index, total)` for the active buffer's matches, for the + /// prompt's "n/m" readout. `active_index` is 0-based and `None` + /// when there are no matches. + #[must_use] + pub fn search_match_summary(&self) -> (Option, usize) { + let bid = self.active_buffer_id(); + let guard = self + .search_store + .lock() + .expect("search store mutex poisoned"); + guard + .for_buffer(bid) + .map_or((None, 0), |s| (s.active_index(), s.len())) + } + + /// Begin an incremental search anchored at the active buffer + + /// cursor. `forward` sets the initial step direction. A no-op if a + /// search is already running (the entry chord is intercepted while + /// active, so this is only reached from an inactive state — the + /// guard is belt-and-suspenders). + pub fn search_begin(&mut self, forward: bool) { + if self.search.is_some() { + return; + } + let origin = (self.active_buffer_id(), self.cursor()); + self.search = Some(SearchSession { + query: String::new(), + origin, + forward, + }); + } + + /// Append a character to the query and re-search. + pub fn search_input_char(&mut self, ch: char) { + let Some(session) = self.search.as_mut() else { + return; + }; + session.query.push(ch); + self.search_recompute(); + } + + /// Drop the last character of the query and re-search. With an + /// empty query this is a no-op (the search stays open, empty). + pub fn search_backspace(&mut self) { + let Some(session) = self.search.as_mut() else { + return; + }; + session.query.pop(); + self.search_recompute(); + } + + /// Re-run `find_all` for the current query against the origin + /// buffer, refresh the store, and move the cursor to the match + /// nearest the origin (first match at/after the origin cursor, + /// wrapping). An empty query or no match anchors the cursor back + /// at the origin so a failing search never drifts the view. + fn search_recompute(&mut self) { + let Some(session) = self.search.as_ref() else { + return; + }; + let bid = session.origin.0; + let origin_byte = session.origin.1; + let query = session.query.clone(); + let bytes = self.buffer_bytes(bid); + let matches = crate::search::find_all(&bytes, &query); + let focus = { + let mut guard = self + .search_store + .lock() + .expect("search store mutex poisoned"); + guard.set(bid, query, matches); + guard.focus_from(bid, origin_byte) + }; + let target = focus.map_or(origin_byte, |range| range.start); + self.search_place_cursor(target); + } + + /// Step the active buffer's match focus forward/backward (wrapping) + /// and move the cursor to it. Operates on [`Self::search_store`] + /// directly, so it works both during a live session (C-s / C-r) + /// and after accept (a `search.next` navigation command). A no-op + /// when the active buffer has no matches. + pub fn search_step(&mut self, forward: bool) { + if let Some(session) = self.search.as_mut() { + session.forward = forward; + } + let bid = self.active_buffer_id(); + let stepped = { + let mut guard = self + .search_store + .lock() + .expect("search store mutex poisoned"); + guard.step(bid, forward) + }; + if let Some(range) = stepped { + self.search_place_cursor(range.start); + } + } + + /// End the active search. `accept` keeps the cursor at the current + /// match and leaves the matches in the store (so they stay + /// highlighted, and `search.next` can resume, until the next edit + /// marks them stale). Cancel restores the origin cursor and clears + /// the matches. A no-op when no search is running. + pub fn search_finish(&mut self, accept: bool) { + let Some(session) = self.search.take() else { + return; + }; + if accept { + return; + } + let (bid, origin_byte) = session.origin; + if self.active_buffer_id() == bid { + self.search_place_cursor(origin_byte); + } + self.search_store + .lock() + .expect("search store mutex poisoned") + .clear(bid); + } + + /// Move the active window's cursor to a byte offset (clamped to the + /// buffer extent), resetting the goal column. Shared by the search + /// motions. + fn search_place_cursor(&mut self, byte: u64) { + let clamped = byte.min(self.active_buffer_len()); + let aw = self.active_window_mut(); + aw.cursor = clamped; + aw.goal_col = None; + } + + /// Snapshot a buffer's full byte content (empty if the id is + /// stale). O(1) rope snapshot + one copy; used to feed `find_all`. + fn buffer_bytes(&self, buffer_id: BufferId) -> Vec { + let reg = self.registry.borrow(); + let Ok(buf) = reg.get(buffer_id) else { + return Vec::new(); + }; + let len = buf.len(); + let mut out = vec![0u8; len as usize]; + buf.snapshot_rope().slice(0, len, &mut out); + out + } + // ---- editing primitives ------------------------------------------------ /// Apply `op` to the active buffer; notify every window @@ -511,6 +715,16 @@ impl EditorCore { self.pending_crdt_ops .push((CrdtOpOrigin::DaemonKey, buffer_id, (**crdt_op).clone())); } + // Search matches were computed against the pre-edit text, so + // their byte positions are now wrong. Mark the buffer's matches + // stale (M11.8): the producer / TUI overlay suppress them until + // a fresh search re-runs against the current content. No-op for + // a buffer with no search state. The headline isearch bet — + // "stale-after-edit linger" — is closed here. + self.search_store + .lock() + .expect("search store mutex poisoned") + .mark_stale(buffer_id); Ok(edit.new_rope.len()) } @@ -2176,4 +2390,142 @@ mod tests { // Next pop would be the stale `doomed` entry — skipped, ring empties. assert!(!s.jump_back()); } + + // ---- incremental search (Q#SR5) ------------------------------------ + + fn type_query(s: &mut EditorCore, q: &str) { + for ch in q.chars() { + s.search_input_char(ch); + } + } + + #[test] + fn search_begin_then_type_highlights_from_origin() { + let mut s = from_bytes(b"foo bar foo baz foo"); + let bid = s.active_buffer_id(); + s.active_window_mut().cursor = 0; + s.search_begin(true); + assert!(s.search_active()); + type_query(&mut s, "foo"); + // Three matches: 0..3, 8..11, 16..19; first (at/after origin 0) + // is active and the cursor sits on it. + assert_eq!(s.search_match_summary(), (Some(0), 3)); + assert_eq!(s.cursor(), 0); + let guard = s.search_store.lock().expect("store"); + assert!(!guard.is_stale(bid)); + assert_eq!(guard.for_buffer(bid).expect("entry").len(), 3); + } + + #[test] + fn search_step_walks_matches_and_wraps() { + let mut s = from_bytes(b"foo bar foo baz foo"); + s.active_window_mut().cursor = 0; + s.search_begin(true); + type_query(&mut s, "foo"); + assert_eq!(s.cursor(), 0); + s.search_step(true); + assert_eq!((s.search_match_summary(), s.cursor()), ((Some(1), 3), 8)); + s.search_step(true); + assert_eq!(s.cursor(), 16); + s.search_step(true); // wraps to the first match + assert_eq!(s.cursor(), 0); + s.search_step(false); // backward wraps to the last + assert_eq!(s.cursor(), 16); + } + + #[test] + fn search_focuses_first_match_at_or_after_origin() { + let mut s = from_bytes(b"foo bar foo"); + s.active_window_mut().cursor = 5; // inside "bar" + s.search_begin(true); + type_query(&mut s, "foo"); + // First match with start >= 5 is the one at byte 8. + assert_eq!(s.cursor(), 8); + assert_eq!(s.search_match_summary(), (Some(1), 2)); + } + + #[test] + fn search_cancel_restores_origin_and_clears_store() { + let mut s = from_bytes(b"foo bar foo"); + let bid = s.active_buffer_id(); + s.active_window_mut().cursor = 5; + s.search_begin(true); + type_query(&mut s, "foo"); + assert_eq!(s.cursor(), 8); + s.search_finish(false); // cancel + assert!(!s.search_active()); + assert_eq!(s.cursor(), 5, "cancel restores the pre-search cursor"); + assert!( + s.search_store + .lock() + .expect("store") + .for_buffer(bid) + .is_none(), + "cancel clears the matches" + ); + } + + #[test] + fn search_accept_keeps_cursor_and_matches() { + let mut s = from_bytes(b"foo bar foo"); + let bid = s.active_buffer_id(); + s.active_window_mut().cursor = 0; + s.search_begin(true); + type_query(&mut s, "foo"); + s.search_step(true); // focus the match at byte 8 + assert_eq!(s.cursor(), 8); + s.search_finish(true); // accept + assert!(!s.search_active()); + assert_eq!(s.cursor(), 8, "accept keeps the cursor on the match"); + assert!( + s.search_store + .lock() + .expect("store") + .for_buffer(bid) + .is_some(), + "accept keeps matches for highlight + navigation" + ); + } + + #[test] + fn search_backspace_widens_the_match_set() { + let mut s = from_bytes(b"fo foo food"); + s.active_window_mut().cursor = 0; + s.search_begin(true); + type_query(&mut s, "foo"); // matches "foo" at 3..6, 7..10 + assert_eq!(s.search_match_summary().1, 2); + s.search_backspace(); // query "fo" + assert_eq!(s.search_query(), "fo"); + assert_eq!(s.search_match_summary().1, 3); + } + + #[test] + fn search_smart_case_is_case_sensitive_with_uppercase() { + let mut s = from_bytes(b"Foo foo FOO"); + s.active_window_mut().cursor = 0; + s.search_begin(true); + type_query(&mut s, "Foo"); // uppercase => case-sensitive + assert_eq!(s.search_match_summary().1, 1); + s.search_backspace(); + s.search_backspace(); + s.search_backspace(); + type_query(&mut s, "foo"); // lowercase => smart-case folds all + assert_eq!(s.search_match_summary().1, 3); + } + + #[test] + fn edit_marks_accepted_matches_stale() { + let mut s = from_bytes(b"foo foo"); + let bid = s.active_buffer_id(); + s.active_window_mut().cursor = 0; + s.search_begin(true); + type_query(&mut s, "foo"); + s.search_finish(true); // matches persist after accept + assert!(!s.search_store.lock().expect("store").is_stale(bid)); + s.insert_char('x'); // any edit invalidates the match offsets + assert!( + s.search_store.lock().expect("store").is_stale(bid), + "an edit marks the buffer's matches stale (linger fix)" + ); + } } diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index 16910dd..a834a23 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -5126,6 +5126,7 @@ pub fn install_editor(lua: &Lua, core: &SharedCore) -> mlua::Result<()> { install_editing(&editor, lua, core)?; install_history(&editor, lua, core)?; install_session(&editor, lua, core)?; + install_search(&editor, lua, core)?; pmacs.set("editor", editor)?; pmacs.set("frontend", install_frontend_module(lua, core)?)?; @@ -11431,6 +11432,48 @@ fn install_history(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result Ok(()) } +/// Install the `pmacs.editor.search_*` primitives that drive +/// incremental search (Q#SR5). The live-typing keys are intercepted in +/// Rust (`EditorState::dispatch_search_key`); these bindings exist so +/// the *entry* commands (`search.forward` / `search.backward`) and any +/// post-accept navigation commands can begin / step a search from Lua. +fn install_search(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result<()> { + { + // search_start(forward): begin an isearch in the given + // direction, anchored at the active buffer + cursor. + let cc = core.clone(); + editor.set( + "search_start", + lua.create_function(move |_, forward: bool| { + cc.borrow_mut().search_begin(forward); + Ok(()) + })?, + )?; + } + { + // search_step(forward): move the active buffer's match focus + // (works during a live search and after accept, for navigation + // commands). No-op when the buffer has no matches. + let cc = core.clone(); + editor.set( + "search_step", + lua.create_function(move |_, forward: bool| { + cc.borrow_mut().search_step(forward); + Ok(()) + })?, + )?; + } + { + // search_active(): true while an isearch session is running. + let cc = core.clone(); + editor.set( + "search_active", + lua.create_function(move |_, ()| Ok(cc.borrow().search_active()))?, + )?; + } + Ok(()) +} + #[allow( clippy::too_many_lines, reason = "linear list of session bindings; the surface is coherent and split would fragment review" From 5111ae82e7fd2758e560902576da68a96575ed4a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 11:26:25 -0400 Subject: [PATCH 5/7] search: GPU isearch surface (Q#SR5, protocol v9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings incremental search to pmacs-gpu, which has no minibuffer, by reusing the shared daemon-side search core from the previous commit. Key routing needs no new mechanism: `dispatch_idle` now also reports false while a search is running, so the GPU's existing M11.6 optimistic-apply gate round-trips every keystroke to the daemon — where `dispatch_search_key` extends the query / steps — instead of self-inserting it. The match highlights were already wired (commit 2's SearchMatch / SearchMatchActive decoration colors), so they light up live the moment keys round-trip. The one thing a semantic frontend can't derive locally is the query text, so a new additive `InstanceMessage::SearchPrompt { buffer_id, query, active, total }` carries it (protocol v9, SUPPORTED grows to [6,7,8,9]). The producer emits it cached-compare-suppressed like StatusFacts — `query: Some` while searching, `None` to clear on accept/cancel (matches keep highlighting via decorations), and stays silent on a fresh buffer that never searched. The daemon's per-session filter keeps the variant off wires negotiated < 9. The GPU mirrors it into the status band: while searching, the band's left side shows `I-search: (n/m)` (or `[no match]`) in place of the buffer name, returning to the name when the search ends. Tests: protocol version pin + SearchPrompt postcard round-trip (active / failing / cleared shapes); producer emit-on-change + suppress + clear-on-accept + first-sight silence; dispatch_idle flips false during search (the GPU round-trip contract). Co-Authored-By: Claude Opus 4.8 (1M context) --- pmacs-gpu/src/main.rs | 57 ++++++++++++- pmacs-protocol/src/message.rs | 35 +++++++- src/daemon.rs | 10 +++ src/editor.rs | 27 ++++++- src/frontend.rs | 4 + src/protocol.rs | 54 +++++++++++-- src/semantic_render.rs | 145 +++++++++++++++++++++++++++++++++- 7 files changed, 315 insertions(+), 17 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index ccd3b8b..637ac51 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 { @@ -1417,6 +1433,7 @@ impl State { status_left_buffer, status_left_text: String::new(), status_facts: None, + search_prompt: None, minimap_cache: None, } } @@ -2010,6 +2027,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 +2548,23 @@ 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.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 +3937,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", 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/editor.rs b/src/editor.rs index 204325f..5adfade 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 @@ -2100,6 +2106,21 @@ mod tests { assert_eq!(s.core.borrow().search_query(), "foo"); } + #[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/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/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/semantic_render.rs b/src/semantic_render.rs index cbb0961..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 @@ -1462,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 { @@ -1475,6 +1544,7 @@ mod tests { | InstanceMessage::InlineAdornments { .. } | InstanceMessage::FileStyleSummary { .. } | InstanceMessage::StatusFacts { .. } + | InstanceMessage::SearchPrompt { .. } ), "semantic projection emitted an unexpected variant: {m:?}" ); @@ -2985,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(); From 22773737e978e5e5dcc8b79460ef1059b60c59f7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 12:47:12 -0400 Subject: [PATCH 6/7] search: attach the TUI match-wash overlay so isearch is visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix for "seems to only search for the first character." The TUI's `SearchView` overlay was written (commit 2) but never attached to a window, so the terminal frontend painted no match highlights — the only feedback was the cursor jumping to the first match, which made refining the query past the first character look like a no-op even though the search was working (verified: the query accumulates correctly through the full run-loop path). `search_begin` now attaches a `SearchView` to the active window (deduped by overlay kind, so repeat searches don't stack it). The view self-suppresses when the store has no matches or is stale, so a persistent attach is safe — it paints only while a search has live matches and stops the moment an edit invalidates them. `SearchView` now keys on the *rendered* buffer (`Buffer::id`) instead of a fixed id captured at construction, so one attached instance keeps highlighting correctly even if the window later switches buffers (the store is per-buffer; a buffer with no entry paints nothing). Tests: a render-level test that paints a real frame mid-search and asserts both the match wash (bright `Indexed(11)` on the active match) and the full `I-search: foo` prompt land on the grid — the coverage that was missing, which would have caught the unattached overlay. Plus a run-loop-fidelity test (renders interleaved with keystrokes) pinning that the query accumulates rather than sticking at the first character. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/editor.rs | 74 ++++++++++++++++++++++++++++++++++++++++++++++ src/editor_core.rs | 18 +++++++++++ src/search.rs | 19 +++++++----- 3 files changed, 104 insertions(+), 7 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index 5adfade..42be2cb 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -2106,6 +2106,80 @@ mod tests { 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. diff --git a/src/editor_core.rs b/src/editor_core.rs index 7cf4c9b..9c0feb2 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -561,6 +561,12 @@ impl EditorCore { 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, @@ -568,6 +574,18 @@ impl EditorCore { }); } + /// 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 { diff --git a/src/search.rs b/src/search.rs index 88e936d..eacb5be 100644 --- a/src/search.rs +++ b/src/search.rs @@ -264,15 +264,19 @@ fn active_match_style() -> Style { /// are single-line (the minibuffer query carries no newline), so each /// maps to one row. pub struct SearchView { - buffer_id: BufferId, store: SharedSearchStore, } impl SearchView { - /// Construct a view reading `store` for `buffer_id`. + /// 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(buffer_id: BufferId, store: SharedSearchStore) -> Self { - Self { buffer_id, store } + pub fn new(store: SharedSearchStore) -> Self { + Self { store } } } @@ -282,14 +286,15 @@ impl View for SearchView { } 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(self.buffer_id) { + if guard.is_stale(buffer_id) { return; } - match guard.for_buffer(self.buffer_id) { + match guard.for_buffer(buffer_id) { Some(s) => (s.matches().to_vec(), s.active_match()), None => return, } @@ -453,7 +458,7 @@ mod tests { .unwrap() .set(bid, "lo", find_all(b"lo lo lo\n", "lo")); - let mut view = SearchView::new(bid, store.clone()); + let mut view = SearchView::new(store.clone()); let mut backing = vec![Cell::default(); 10]; let mut grid = CellGrid { cells: &mut backing, From 2326fa6e327a1816bc14ed2e2312ce80237e647b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 13:02:53 -0400 Subject: [PATCH 7/7] search: route GUI keystrokes into the daemon's search (Q#SR5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix for "C-s highlights but typing still edits the buffer" in the GPU frontend. The GUI withholds Ctrl chords (`should_forward_key`) and treated Escape as a hard quit, so it could neither *start* a search (C-s never reached the daemon) nor route the query keys into one — they fell through to the optimistic-apply path and edited the document. The GUI now mirrors the daemon's input-interception state. A new `daemon_intercepts_keys` (true while a `SearchPrompt` is live or the daemon reports `DispatchIdle { idle: false }`) gates the key path: - While intercepting, every key round-trips to the daemon — no optimistic apply — so chars extend the query, C-s/C-r step, BS shortens, RET accepts, C-g cancels. This reuses the M11.6 gate the optimistic path already honored; the new part is round-tripping the *command* chords that `should_forward_key` would otherwise drop. - While idle, C-s / C-r are forwarded as search-entry chords (still withheld for every other Ctrl chord) so a search can begin. No optimistic local idle-flip: the daemon's `DispatchIdle` / `SearchPrompt` flip the gate one round-trip later, so a rebound C-s that doesn't start a search can never wedge the gate. - Escape cancels an active search (round-tripped to the daemon's `SearchKey::Cancel`) instead of quitting the window; it stays the local quit when nothing is intercepting. Also aligns the GUI status band's empty-query prompt with the TUI (`I-search: ` with no `[no match]` until a non-empty query misses). Tests: `is_search_entry_chord` (C-s/C-r + Ctrl only; and the fact that `should_forward_key` withholds it, which is what the entry path exists to override). The end-to-end routing is GUI-window behavior, validated manually. Co-Authored-By: Claude Opus 4.8 (1M context) --- pmacs-gpu/src/main.rs | 201 ++++++++++++++++++++++++++++++++---------- 1 file changed, 156 insertions(+), 45 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 637ac51..35abe0a 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -659,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) => { @@ -1447,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 @@ -2558,7 +2628,9 @@ impl State { .as_ref() .filter(|s| Some(s.buffer_id) == self.current_buffer_id) { - let count = if sp.total == 0 { + 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) @@ -4054,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) @@ -5094,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};