Merge pull request #70 from levineuwirth/session-incremental-search

Incremental in-buffer search (smart-case isearch), both frontends
This commit is contained in:
Levi Neuwirth 2026-06-27 14:16:01 -04:00 committed by GitHub
commit f359d4a634
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1983 additions and 80 deletions

View File

@ -117,6 +117,21 @@ cmd { name = "buffer.self-insert",
description = "Insert the codepoint argument at the cursor, replacing the active region.",
fn = function(codepoint) ed.insert_char_over_region(codepoint) end }
-- Incremental search ---------------------------------------------------------
--
-- C-s / C-r begin a live in-buffer isearch: the match under the cursor
-- highlights as you type, the same key steps to the next/previous
-- match, RET accepts (keeping the highlights until the next edit), and
-- C-g / Esc restore the pre-search cursor. While a search is running
-- every keystroke is intercepted in Rust (dispatch_search_key), so
-- these commands only run to *start* a search from an idle keymap.
cmd { name = "search.forward",
description = "Start an incremental search forward from the cursor.",
fn = function() ed.search_start(true) end }
cmd { name = "search.backward",
description = "Start an incremental search backward from the cursor.",
fn = function() ed.search_start(false) end }
-- History --------------------------------------------------------------------
cmd { name = "buffer.undo", description = "Undo the most recent edit.",

View File

@ -52,6 +52,16 @@ bind("C-d", "buffer.delete-forward")
bind("RET", "buffer.newline")
bind("TAB", "buffer.tab")
-- Incremental search ---------------------------------------------------------
--
-- C-s / C-r start a live isearch (forward / backward). Both keys are
-- free in the default map (save is C-x C-s, redo is C-x r), so this
-- adds isearch without colliding with the CUA / Emacs editing keys.
-- Once a search is running, C-s / C-r step to the next / previous
-- match; that interception happens in Rust, so it needs no binding.
bind("C-s", "search.forward")
bind("C-r", "search.backward")
-- CUA-style word-level deletion (the same shortcuts users expect from
-- IDEs, browsers, terminals on Linux/Windows). C-BS deletes back to
-- the start of the previous word; C-DEL deletes forward through the

View File

@ -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<Mutex>` 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<BufferId, SearchState>` where `SearchState` holds
the resolved query, the sorted `Vec<ByteRange>` matches, and the
active index. Shared `Arc<Mutex>`. 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<ByteRange>`: 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).

View File

@ -529,6 +529,12 @@ struct State {
status_left_text: String,
/// Q#S1 — the wire-authoritative status facts (protocol v8).
status_facts: Option<StatusFactsLocal>,
/// 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: <query> (n/m)` in place of
/// the buffer name; the matches highlight via `SearchMatch`
/// decorations.
search_prompt: Option<SearchPromptLocal>,
/// Minimap vertex bytes cached by [`MinimapCacheKey`] —
/// rebuilding rescanned every line shape per frame.
minimap_cache: Option<(MinimapCacheKey, Vec<u8>)>,
@ -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<u32>,
total: u32,
}
/// pmacs-gpu's own cursor position, mirrored from `CursorByte`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct OwnCursor {
@ -643,64 +659,124 @@ impl ApplicationHandler<AppEvent> for App {
if key.state != ElementState::Pressed {
return;
}
// Escape stays a local quit (no daemon round trip).
// While the daemon is intercepting keystrokes — an active
// incremental search (Q#SR5), or a minibuffer / pending
// prefix — every key belongs to its handler, not the
// buffer. The GUI round-trips them all and never
// optimistic-applies (that would edit the document
// mid-search).
let intercept = self
.state
.as_ref()
.is_some_and(State::daemon_intercepts_keys);
// Escape cancels an active intercept (e.g. a running
// search); otherwise it stays the local quit.
if matches!(key.logical_key, Key::Named(NamedKey::Escape)) {
event_loop.exit();
if intercept {
if let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_key(ProtocolKey::Escape, Modifiers::NONE)
{
eprintln!("pmacs-gpu: send Escape (cancel) failed: {e}");
}
} else {
event_loop.exit();
}
return;
}
let Some((pkey, pmods)) = translate_key(&key.logical_key, self.modifiers) else {
return;
};
let Some(client) = self.attach_client.as_ref() else {
return;
};
// Intercept path: round-trip every key into the daemon's
// active handler (search query / step / accept / cancel).
if intercept {
if let Some(state) = self.state.as_mut() {
state.mark_cursor_stale_after_round_trip();
}
if debug_input() {
eprintln!("pmacs-gpu send_key (intercepted): {pkey:?} mods={pmods:?}");
}
if let Err(e) = client.send_key(pkey, pmods) {
eprintln!("pmacs-gpu: send_key (intercepted) failed: {e}");
}
return;
}
// Idle: C-s / C-r begin an incremental search. They are
// otherwise withheld as command chords; forward them so
// the search can start. The daemon then flips the
// intercept gate (DispatchIdle / SearchPrompt) one
// round-trip later, after which every key routes into the
// search — no optimistic local flip, so a C-s that (via
// rebinding) doesn't start a search can never wedge the
// gate against the daemon's authoritative state.
if is_search_entry_chord(pkey, pmods) {
if let Some(state) = self.state.as_mut() {
state.mark_cursor_stale_after_round_trip();
}
if let Err(e) = client.send_key(pkey, pmods) {
eprintln!("pmacs-gpu: send_key (search entry) failed: {e}");
}
return;
}
// Session B2 forwards cursor motion + plain text editing
// (Char / Backspace / Enter / Delete / Tab). Ctrl/Alt/
// Meta chords are withheld — they drive commands and
// minibuffer flows the GUI can't render or interact with
// yet (a later session adds GUI minibuffer + chords).
if let Some((pkey, pmods)) = translate_key(&key.logical_key, self.modifiers)
&& should_forward_key(pkey, pmods)
&& let Some(client) = self.attach_client.as_ref()
{
if let Some(op) = self.state.as_mut().and_then(|state| {
state
.optimistic_crdt_insert(pkey, pmods)
.or_else(|| state.optimistic_crdt_delete(pkey, pmods))
}) {
if !should_forward_key(pkey, pmods) {
return;
}
if let Some(op) = self.state.as_mut().and_then(|state| {
state
.optimistic_crdt_insert(pkey, pmods)
.or_else(|| state.optimistic_crdt_delete(pkey, pmods))
}) {
if debug_input() {
eprintln!(
"pmacs-gpu send_crdt: key={pkey:?} buf={:?} bytes={}B",
op.buffer_id,
op.op.bytes.len()
);
}
if let Err(e) = client.send_crdt_op(op.buffer_id, op.op) {
eprintln!("pmacs-gpu: send_crdt_op failed: {e}");
}
// An optimistic Enter near the bottom edge can
// scroll; re-declare the scoped viewport so
// the producer styles the newly visible lines.
if let Some(vp) = op.viewport
&& let Err(e) =
client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: send Viewport failed: {e}");
}
return;
}
if let Some(state) = self.state.as_mut() {
if state.defer_round_trip_key_if_needed(pkey, pmods) {
if debug_input() {
eprintln!(
"pmacs-gpu send_crdt: key={pkey:?} buf={:?} bytes={}B",
op.buffer_id,
op.op.bytes.len()
"pmacs-gpu defer_key: {pkey:?} mods={pmods:?} \
pending optimistic cursor"
);
}
if let Err(e) = client.send_crdt_op(op.buffer_id, op.op) {
eprintln!("pmacs-gpu: send_crdt_op failed: {e}");
}
// An optimistic Enter near the bottom edge can
// scroll; re-declare the scoped viewport so
// the producer styles the newly visible lines.
if let Some(vp) = op.viewport
&& let Err(e) =
client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: send Viewport failed: {e}");
}
return;
}
if let Some(state) = self.state.as_mut() {
if state.defer_round_trip_key_if_needed(pkey, pmods) {
if debug_input() {
eprintln!(
"pmacs-gpu defer_key: {pkey:?} mods={pmods:?} \
pending optimistic cursor"
);
}
return;
}
state.mark_cursor_stale_after_round_trip();
}
if debug_input() {
eprintln!("pmacs-gpu send_key: {pkey:?} mods={pmods:?}");
}
if let Err(e) = client.send_key(pkey, pmods) {
eprintln!("pmacs-gpu: send_key failed: {e}");
}
state.mark_cursor_stale_after_round_trip();
}
if debug_input() {
eprintln!("pmacs-gpu send_key: {pkey:?} mods={pmods:?}");
}
if let Err(e) = client.send_key(pkey, pmods) {
eprintln!("pmacs-gpu: send_key failed: {e}");
}
}
WindowEvent::Resized(size) => {
@ -1417,6 +1493,7 @@ impl State {
status_left_buffer,
status_left_text: String::new(),
status_facts: None,
search_prompt: None,
minimap_cache: None,
}
}
@ -1430,6 +1507,16 @@ impl State {
}
}
/// `true` while the daemon is intercepting keystrokes — an active
/// incremental search (Q#SR5), surfaced by a live `SearchPrompt`, or
/// the daemon reporting non-idle (`DispatchIdle { idle: false }` for
/// a minibuffer / pending prefix). In this state the GUI round-trips
/// every key to the daemon's handler instead of optimistically
/// applying it to the buffer.
fn daemon_intercepts_keys(&self) -> bool {
self.search_prompt.is_some() || !self.dispatch_idle
}
/// Shared eligibility gates for the optimistic edit paths
/// (insert + delete). `None` ⇒ the key must round-trip:
/// - dispatcher busy (minibuffer/prefix flows own the keys), or
@ -2010,6 +2097,27 @@ impl State {
self.window.request_redraw();
None
}
// Q#SR5 — the live isearch prompt (protocol v9). `query:
// None` clears the band (search ended); `Some` shows
// `I-search: <query> (n/m)` on the band's left side. The
// matches themselves arrive as SearchMatch decorations and
// the keys round-trip via the DispatchIdle gate, so this
// handler only drives the prompt text.
InstanceMessage::SearchPrompt {
buffer_id,
query,
active,
total,
} => {
self.search_prompt = query.map(|q| SearchPromptLocal {
buffer_id,
query: q,
active,
total,
});
self.window.request_redraw();
None
}
// Session 9.3 — peer presence. The editing frontend's
// cursor + selection drive the `CurrentLine` / `Selection`
// washes for this read-only mirror (finding QB1). Store
@ -2510,9 +2618,25 @@ impl State {
spans
}
/// The band's left side: buffer name + modified dot, from the
/// v8 `StatusFacts` (empty until the daemon ships them).
/// The band's left side. While an incremental search is running
/// (Q#SR5) it shows `I-search: <query> (n/m)` — the prompt takes
/// over the band like Emacs's echo area, returning to the buffer
/// name + modified dot (v8 `StatusFacts`) when the search ends.
fn compose_status_left(&self) -> String {
if let Some(sp) = self
.search_prompt
.as_ref()
.filter(|s| Some(s.buffer_id) == self.current_buffer_id)
{
let count = if sp.query.is_empty() {
String::new()
} else if sp.total == 0 {
" [no match]".to_string()
} else {
format!(" ({}/{})", sp.active.map_or(0, |a| a + 1), sp.total)
};
return format!("I-search: {}{}", sp.query, count);
}
match self
.status_facts
.as_ref()
@ -3885,6 +4009,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
InstanceMessage::InlineAdornments { .. } => "InlineAdornments",
InstanceMessage::FileStyleSummary { .. } => "FileStyleSummary",
InstanceMessage::StatusFacts { .. } => "StatusFacts",
InstanceMessage::SearchPrompt { .. } => "SearchPrompt",
InstanceMessage::BlockAdornments { .. } => "BlockAdornments",
InstanceMessage::FoldState { .. } => "FoldState",
InstanceMessage::ResourceOffer { .. } => "ResourceOffer",
@ -4001,6 +4126,14 @@ fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool {
)
}
/// `C-s` / `C-r` — the chords that begin an incremental search
/// (Q#SR5). Forwarded even when idle (they are otherwise withheld as
/// command chords by [`should_forward_key`]) so a search can start;
/// once it is running every key round-trips via the intercept path.
fn is_search_entry_chord(key: ProtocolKey, mods: Modifiers) -> bool {
mods == Modifiers::CTRL && matches!(key, ProtocolKey::Char('s' | 'r'))
}
fn is_plain_text_modifiers(mods: Modifiers) -> bool {
!mods.contains(Modifiers::CTRL)
&& !mods.contains(Modifiers::ALT)
@ -4874,8 +5007,12 @@ fn decoration_kind_to_bg_color(kind: DecorationKind) -> Option<[f32; 4]> {
// 0.22 keeps it subtle vs Selection's 0.30 while actually
// reading as a current-line band.
DecorationKind::CurrentLine => Some([0.55, 0.60, 0.75, 0.22]),
// Deferred to the search-feature arc.
DecorationKind::SearchMatch | DecorationKind::SearchMatchActive => None,
// In-buffer search (Q#SR4): a translucent yellow wash under
// every match, a stronger amber under the active one so it
// stands out as you step through. Both let the glyph color
// show through (text renders after this pass).
DecorationKind::SearchMatch => Some([0.85, 0.78, 0.20, 0.30]),
DecorationKind::SearchMatchActive => Some([0.95, 0.55, 0.12, 0.48]),
// Underline-only — handled by
// [`decoration_kind_to_underline_color`].
DecorationKind::DiagnosticError
@ -5037,6 +5174,37 @@ mod tests {
assert!(should_forward_key(ProtocolKey::Backspace, Modifiers::ALT));
}
#[test]
fn search_entry_chord_is_ctrl_s_or_ctrl_r_only() {
// C-s / C-r start a search (Q#SR5) — forwarded even though
// `should_forward_key` withholds them as command chords.
assert!(is_search_entry_chord(
ProtocolKey::Char('s'),
Modifiers::CTRL
));
assert!(is_search_entry_chord(
ProtocolKey::Char('r'),
Modifiers::CTRL
));
assert!(
!should_forward_key(ProtocolKey::Char('s'), Modifiers::CTRL),
"C-s is otherwise a withheld chord; the search-entry path is what forwards it"
);
// Other Ctrl chords, and C-s without Ctrl, are not entry chords.
assert!(!is_search_entry_chord(
ProtocolKey::Char('x'),
Modifiers::CTRL
));
assert!(!is_search_entry_chord(
ProtocolKey::Char('s'),
Modifiers::NONE
));
assert!(!is_search_entry_chord(
ProtocolKey::Char('s'),
Modifiers::CTRL | Modifiers::ALT
));
}
#[test]
fn translate_key_carries_modifiers() {
use winit::keyboard::{Key as WKey, ModifiersState, NamedKey};
@ -5566,14 +5734,14 @@ mod tests {
}
#[test]
fn bg_color_helper_covers_selection_and_current_line() {
fn bg_color_helper_covers_selection_current_line_and_search() {
// Sessions 9.1 + 9.2: Selection and CurrentLine paint.
assert!(decoration_kind_to_bg_color(DecorationKind::Selection).is_some());
assert!(decoration_kind_to_bg_color(DecorationKind::CurrentLine).is_some());
// Search-feature arc — still deferred.
assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatch).is_none());
assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatchActive).is_none());
// In-buffer search (Q#SR4): both match kinds wash a bg.
assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatch).is_some());
assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatchActive).is_some());
// Underline-only kinds belong to the underline helper (T M4.6
// parity: squiggle bars, not text recoloring).
@ -5605,16 +5773,9 @@ mod tests {
] {
let ul = decoration_kind_to_underline_color(kind).is_some();
let bg = decoration_kind_to_bg_color(kind).is_some();
// Both helpers return None for the search pair — deferred
// to the search-feature arc. That is the "neither yet"
// state — the exclusive-or test exempts it.
let deferred = matches!(
kind,
DecorationKind::SearchMatch | DecorationKind::SearchMatchActive
);
assert!(
deferred || (ul ^ bg),
"{kind:?}: underline={ul} bg={bg} — should be exactly one (unless deferred)"
ul ^ bg,
"{kind:?}: underline={ul} bg={bg} — should be exactly one"
);
}
}

View File

@ -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: <query> (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<String>,
/// 0-based index of the active match, or `None` when the query
/// has no matches (a failing search).
active: Option<u32>,
/// 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`].

View File

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

View File

@ -592,7 +592,7 @@ impl View for DiagnosticView {
// cross-module coupling on internal helpers)
// ---------------------------------------------------------------------------
fn compute_line_offsets(source: &[u8]) -> Vec<u32> {
pub(crate) fn compute_line_offsets(source: &[u8]) -> Vec<u32> {
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<u32> {
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 {

View File

@ -450,7 +450,8 @@ impl EditorState {
/// - the dispatcher holds a pending multi-key prefix (e.g. the
/// user has typed `C-x` and the daemon is waiting for the next
/// chord), or
/// - a minibuffer prompt is active and absorbing keys.
/// - a minibuffer prompt is active and absorbing keys, or
/// - an incremental search is running and absorbing keys (Q#SR5).
///
/// Used by the daemon to drive the `InstanceMessage::DispatchIdle`
/// wire signal that gates `crdt_replica` frontends' optimistic-apply
@ -458,13 +459,18 @@ impl EditorState {
/// plain-char keystroke into the active document while the
/// daemon's actual intent is to route the keystroke into the
/// minibuffer prompt — the M10.10 "documented limitation" that
/// surfaced during session-5 manual validation.
/// surfaced during session-5 manual validation. Isearch reuses the
/// exact same gate: while a search runs every keystroke must
/// round-trip so the daemon's `dispatch_search_key` receives it
/// (extend the query / step) instead of the frontend self-inserting
/// it into the buffer.
#[must_use]
pub fn dispatch_idle(&self) -> bool {
if !self.dispatcher.pending().is_empty() {
return false;
}
!self.core.borrow().minibuffer.is_active()
let core = self.core.borrow();
!core.minibuffer.is_active() && !core.search_active()
}
/// `frontend_id` records which frontend produced the event. v0.1
@ -484,6 +490,17 @@ impl EditorState {
core.active_frontend = frontend_id;
}
// Incremental-search interception: while an isearch is running,
// every key routes through the search handler (the global keymap
// is shadowed, like the minibuffer). Printable chars extend the
// query; C-s / C-r step; RET accepts; C-g / Esc cancel. This is
// the shared input path for both frontends — the daemon's
// `FrontendEvent::Key` round-trip lands here too.
if self.core.borrow().search_active() {
self.dispatch_search_key(chord);
return;
}
// Minibuffer interception: when a prompt is active, every key
// routes through the minibuffer's hardcoded handler. The main
// editor's keymap is bypassed; the user can still cancel with
@ -615,6 +632,33 @@ impl EditorState {
}
}
/// Hardcoded handler for keys delivered while an incremental search
/// is active. The global keymap is shadowed (like the minibuffer),
/// so these chords are fixed:
///
/// * `C-s` / `Down` --- step to the next match (wraps).
/// * `C-r` / `Up` --- step to the previous match (wraps).
/// * `RET` --- accept (keep cursor + highlights).
/// * `C-g` / `Esc` --- cancel (restore origin cursor).
/// * `BS` --- shorten the query by one char.
/// * a printable char --- extend the query.
///
/// Unrecognized chords are swallowed (an active isearch eats every
/// keystroke, matching Emacs). The next/prev chords mirror the
/// entry bindings (`search.forward` / `search.backward`) so the
/// same key that started the search repeats it.
fn dispatch_search_key(&mut self, chord: Chord) {
match SearchKey::from_chord(chord) {
SearchKey::Next => self.core.borrow_mut().search_step(true),
SearchKey::Prev => self.core.borrow_mut().search_step(false),
SearchKey::Accept => self.core.borrow_mut().search_finish(true),
SearchKey::Cancel => self.core.borrow_mut().search_finish(false),
SearchKey::Backspace => self.core.borrow_mut().search_backspace(),
SearchKey::Insert(ch) => self.core.borrow_mut().search_input_char(ch),
SearchKey::Ignore => {}
}
}
fn with_minibuffer<F: FnOnce(&mut Minibuffer)>(&mut self, f: F) {
f(&mut self.core.borrow_mut().minibuffer);
}
@ -1161,6 +1205,65 @@ fn process_event(state: &mut EditorState, ev: Event, term_size: crate::cell::Cel
}
}
/// Decoded action for a key delivered while an incremental search is
/// active. Mirrors [`crate::minibuffer::MinibufferAction`]: the
/// bindings are hardcoded (not user-configurable) because isearch
/// shadows the global keymap; changes happen by extending this enum.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
enum SearchKey {
/// Step to the next match (C-s / Down).
Next,
/// Step to the previous match (C-r / Up).
Prev,
/// Accept: keep cursor + highlights (RET).
Accept,
/// Cancel: restore the origin cursor (C-g / Esc).
Cancel,
/// Shorten the query by one character (BS).
Backspace,
/// Extend the query with a printable character.
Insert(char),
/// Unhandled --- swallowed without complaint.
Ignore,
}
impl SearchKey {
/// Decode `chord` into an isearch action. The next/prev chords
/// match the entry bindings (`C-s` forward, `C-r` backward) so the
/// search-starting key repeats the search; arrow keys offer a
/// modifier-free alternative.
fn from_chord(chord: Chord) -> Self {
let ctrl = chord.modifiers.contains(KeyModifiers::CONTROL);
let alt = chord.modifiers.contains(KeyModifiers::ALT);
if !ctrl && !alt {
match chord.code {
KeyCode::Enter => return Self::Accept,
KeyCode::Esc => return Self::Cancel,
KeyCode::Backspace => return Self::Backspace,
KeyCode::Down => return Self::Next,
KeyCode::Up => return Self::Prev,
KeyCode::Char(ch) => return Self::Insert(ch),
_ => return Self::Ignore,
}
}
if ctrl
&& !alt
&& let KeyCode::Char(c) = chord.code
{
return match c {
's' => Self::Next,
'r' => Self::Prev,
'm' => Self::Accept,
'g' => Self::Cancel,
'h' => Self::Backspace,
_ => Self::Ignore,
};
}
Self::Ignore
}
}
/// Paint one full frame into `grid` and return the desired terminal
/// cursor position.
///
@ -1292,7 +1395,14 @@ pub fn paint_frame(
paint_status_line(grid, core, &state.lua_host, &state.dispatcher, term_size);
let mb_cursor_col = if core.minibuffer.is_active() {
// An active isearch owns the bottom row (its prompt + match
// readout), but the terminal cursor stays in the buffer at the
// active match so the eye follows the search — so paint the prompt
// and fall through to the buffer-cursor placement below.
let mb_cursor_col = if core.search_active() {
paint_search_prompt(grid, core, term_size);
None
} else if core.minibuffer.is_active() {
Some(paint_minibuffer(grid, core, term_size))
} else {
None
@ -1614,6 +1724,61 @@ fn paint_minibuffer(
cursor_col.min(max.saturating_sub(1))
}
/// Paint the incremental-search prompt on the bottom row:
/// `I-search: <query> (n/m)`. Backward searches read `I-search
/// backward:`; a non-empty query with no matches reads `[no match]`.
/// Overwrites the status line painted just before it. The terminal
/// cursor is *not* returned here — it stays in the buffer at the
/// active match (see [`paint_frame`]).
fn paint_search_prompt(
grid: &mut crate::cell::CellGrid<'_>,
core: &EditorCore,
term_size: crate::cell::CellSize,
) {
let prompt = if core.search_forward() {
"I-search: "
} else {
"I-search backward: "
};
let query = core.search_query();
let (active, total) = core.search_match_summary();
let suffix = if query.is_empty() {
String::new()
} else if total == 0 {
" [no match]".to_string()
} else {
format!(" ({}/{})", active.map_or(0, |a| a + 1), total)
};
let row = term_size.rows - 1;
let max = term_size.cols;
let mut col: u32 = 0;
let put = |grid: &mut crate::cell::CellGrid<'_>, col: &mut u32, ch: char| {
if *col < max {
let cell = grid.at(CellCoord::new(row, *col));
cell.glyph = crate::cell::Glyph::Char(ch);
cell.style = crate::cell::Style::default();
*col += 1;
}
};
for ch in prompt.chars() {
put(grid, &mut col, ch);
}
for ch in query.chars() {
put(grid, &mut col, ch);
}
for ch in suffix.chars() {
put(grid, &mut col, ch);
}
// Clear the remainder of the row (the status line underneath used
// reverse video; blank it with the default style).
for c in col..max {
let cell = grid.at(CellCoord::new(row, c));
cell.glyph = crate::cell::Glyph::Char(' ');
cell.style = crate::cell::Style::default();
}
}
/// Build the global status (echo area) row: pure ephemeral state.
///
/// Per-window facts (buffer name, modified marker, cursor coord,
@ -1876,6 +2041,160 @@ mod tests {
assert!(s.core.borrow().status.contains("no file"));
}
// ---- incremental search via dispatch (Q#SR5) ---------------------------
fn type_chars(s: &mut EditorState, text: &str) {
for c in text.chars() {
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::NONE));
}
}
#[test]
fn isearch_dispatch_highlights_steps_and_accepts() {
let mut s = fresh_with(b"foo bar foo baz foo");
s.core.borrow_mut().active_window_mut().cursor = 0;
// C-s begins the search (via the search.forward command).
s.dispatch_key(FrontendId::LOCAL, ctrl('s'));
assert!(s.core.borrow().search_active());
// Typing extends the query; the first match is focused.
type_chars(&mut s, "foo");
assert_eq!(s.core.borrow().search_match_summary(), (Some(0), 3));
assert_eq!(s.core.borrow().cursor(), 0);
// C-s now steps (intercepted) rather than re-running the command.
s.dispatch_key(FrontendId::LOCAL, ctrl('s'));
assert_eq!(s.core.borrow().cursor(), 8);
// RET accepts: search ends, cursor holds, matches persist.
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Enter));
assert!(!s.core.borrow().search_active());
assert_eq!(s.core.borrow().cursor(), 8);
let bid = s.core.borrow().active_buffer_id();
assert!(
s.core
.borrow()
.search_store
.lock()
.expect("store")
.for_buffer(bid)
.is_some(),
"accepted matches stay for highlight + navigation"
);
}
#[test]
fn isearch_dispatch_esc_restores_origin() {
let mut s = fresh_with(b"foo bar foo");
s.core.borrow_mut().active_window_mut().cursor = 5;
s.dispatch_key(FrontendId::LOCAL, ctrl('s'));
type_chars(&mut s, "foo");
assert_eq!(s.core.borrow().cursor(), 8);
// Esc cancels: the pre-search cursor is restored, no edit happened.
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Esc));
assert!(!s.core.borrow().search_active());
assert_eq!(s.core.borrow().cursor(), 5);
assert_eq!(s.core.borrow().active_buffer_len(), 11);
}
#[test]
fn isearch_dispatch_keys_do_not_self_insert() {
let mut s = fresh_with(b"foo");
s.core.borrow_mut().active_window_mut().cursor = 3;
s.dispatch_key(FrontendId::LOCAL, ctrl('s'));
type_chars(&mut s, "foo");
// While searching, printable keys feed the query — the buffer is
// untouched (no self-insert).
assert_eq!(s.core.borrow().active_buffer_len(), 3);
assert_eq!(s.core.borrow().search_query(), "foo");
}
#[test]
fn isearch_accumulates_across_renders_like_run_loop() {
// Reproduce the real run loop: a render between every keystroke
// (the in-process TUI renders once per burst, but paint_frame
// borrows the core mutably and reads the search state, so a
// render must not corrupt mid-search input).
use crate::frontend::Event;
let mut s = fresh_with(b"foo bar foo baz foo");
s.core.borrow_mut().active_window_mut().cursor = 0;
let size = crate::cell::CellSize::new(24, 80);
let mut rs = crate::instance_render::RenderState::new(size);
let _ = rs.render_frame(&s, &[]);
process_event(&mut s, Event::Key(ctrl('s')), size);
assert!(s.core.borrow().search_active(), "C-s starts the search");
let _ = rs.render_frame(&s, &[]);
for c in "foo".chars() {
process_event(
&mut s,
Event::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
size,
);
let _ = rs.render_frame(&s, &[]);
}
assert_eq!(
s.core.borrow().search_query(),
"foo",
"query must accumulate across renders, not stick at the first char"
);
}
#[test]
fn isearch_tui_washes_matches_and_shows_full_query() {
// The regression behind "only searches for the first character":
// the TUI had no match-wash overlay, so the only feedback was the
// cursor jump. Paint a real frame and assert both the wash and
// the full-query prompt land on the grid.
use crate::cell::{Cell, CellCoord, CellGrid, CellSize, Color, Glyph};
let mut s = fresh_with(b"foo bar foo");
s.core.borrow_mut().active_window_mut().cursor = 0;
s.dispatch_key(FrontendId::LOCAL, ctrl('s'));
type_chars(&mut s, "foo");
let size = CellSize::new(24, 80);
let mut backing = vec![Cell::default(); (size.rows * size.cols) as usize];
let mut grid = CellGrid {
cells: &mut backing,
stride: size.cols,
size,
};
let _ = paint_frame(&s, &mut grid, size);
// The active match [0,3) washes row 0's first cells (bright
// Indexed(11); lazy matches would be Indexed(3)).
let bg0 = grid.get(CellCoord::new(0, 0)).style.bg;
assert!(
matches!(bg0, Color::Indexed(11) | Color::Indexed(3)),
"first match cell should carry the search wash, got {bg0:?}"
);
// The bottom row shows the full live query, not just "f".
let row = size.rows - 1;
let prompt: String = (0..size.cols)
.filter_map(|c| match grid.get(CellCoord::new(row, c)).glyph {
Glyph::Char(ch) => Some(ch),
_ => None,
})
.collect();
assert!(
prompt.contains("I-search: foo"),
"bottom row should show the accumulated query, got {prompt:?}"
);
}
#[test]
fn isearch_flips_dispatch_idle_so_gpu_round_trips() {
// The GPU's optimistic-apply gate (M11.6) keys off dispatch_idle.
// An active isearch must drive it false so the GPU round-trips
// keystrokes to the daemon's dispatch_search_key instead of
// self-inserting them — the shared-core contract for Q#SR5.
let mut s = fresh_with(b"foo foo");
assert!(s.dispatch_idle(), "idle before any search");
s.dispatch_key(FrontendId::LOCAL, ctrl('s'));
assert!(s.core.borrow().search_active());
assert!(!s.dispatch_idle(), "search active ⇒ keys must round-trip");
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Enter)); // accept
assert!(s.dispatch_idle(), "search ended ⇒ optimistic apply resumes");
}
// ---- T M11.6 — DispatchIdle ---------------------------------------------
#[test]

View File

@ -57,6 +57,29 @@ pub enum CrdtOpOrigin {
DaemonKey,
}
/// Live state of an in-progress incremental search (Q#SR5).
///
/// Present only while an isearch is running (`EditorCore::search`);
/// `None` otherwise. Holds the query as typed so far plus the cursor
/// origin to restore on cancel. The *matches* themselves live in
/// [`crate::search::SearchStore`] (shared with the decorations
/// producer and the TUI overlay); this struct is the per-session
/// input state that drives `find_all`.
#[derive(Clone, Debug)]
pub struct SearchSession {
/// The query as typed so far. Each edit re-runs `find_all`.
query: String,
/// Buffer + cursor position when the search began. `C-g` / `Esc`
/// restores this; `RET` keeps the current (match) cursor. The
/// buffer id also anchors `find_all` to the buffer the search
/// started in.
origin: (BufferId, Position),
/// Direction of the most recent step/begin. `true` = forward.
/// Drives the prompt label ("I-search" vs "I-search backward")
/// and the wrap direction of an empty-query repeat.
forward: bool,
}
/// The world state mutated by editor commands.
pub struct EditorCore {
/// Shared buffer registry. The registry is the canonical owner
@ -127,6 +150,19 @@ pub struct EditorCore {
/// skipped on pop (stale-handle safe, mirrors the registry's
/// `Missing` contract).
pub jump_ring: Vec<(BufferId, Position)>,
/// In-buffer incremental search store (Q#SR1). Per-buffer query +
/// matches + active index, written by the search session /
/// `search.*` commands and read by the decorations producer
/// ([`crate::semantic_render`]) and the TUI search overlay.
/// Cheaply cloneable (`Arc<Mutex>`); shared with both readers.
pub search_store: crate::search::SharedSearchStore,
/// Live incremental-search session (Q#SR5), or `None` when no
/// search is running. Frontend-agnostic: the TUI run loop and the
/// daemon's `FrontendEvent::Key` path both drive it through the
/// same `search_*` methods, so isearch behaves identically in the
/// terminal and GPU frontends. Only the *prompt surface* differs
/// (TUI bottom row vs GPU status band).
pub search: Option<SearchSession>,
}
impl EditorCore {
@ -161,6 +197,8 @@ impl EditorCore {
active_frontend: FrontendId::LOCAL,
pending_crdt_ops: Vec::new(),
jump_ring: Vec::new(),
search_store: crate::search::make_shared_store(),
search: None,
}
}
@ -469,6 +507,197 @@ impl EditorCore {
false
}
// ---- incremental search (Q#SR5) ----------------------------------------
//
// Frontend-agnostic isearch driven entirely through these methods.
// The TUI's `dispatch_search_key` and (later) the GPU's round-tripped
// keystrokes both call into here, so search behaves identically in
// both frontends. Matches live in `search_store` (shared with the
// decorations producer and the TUI overlay); `search` holds the
// live query + origin.
/// `true` iff an incremental search is in progress.
#[must_use]
pub fn search_active(&self) -> bool {
self.search.is_some()
}
/// The current isearch query (empty when no search is running).
#[must_use]
pub fn search_query(&self) -> &str {
self.search.as_ref().map_or("", |s| s.query.as_str())
}
/// Direction of the active search (`true` = forward). Defaults to
/// forward when no search is running — callers should gate on
/// [`Self::search_active`] first.
#[must_use]
pub fn search_forward(&self) -> bool {
self.search.as_ref().is_none_or(|s| s.forward)
}
/// `(active_index, total)` for the active buffer's matches, for the
/// prompt's "n/m" readout. `active_index` is 0-based and `None`
/// when there are no matches.
#[must_use]
pub fn search_match_summary(&self) -> (Option<usize>, usize) {
let bid = self.active_buffer_id();
let guard = self
.search_store
.lock()
.expect("search store mutex poisoned");
guard
.for_buffer(bid)
.map_or((None, 0), |s| (s.active_index(), s.len()))
}
/// Begin an incremental search anchored at the active buffer +
/// cursor. `forward` sets the initial step direction. A no-op if a
/// search is already running (the entry chord is intercepted while
/// active, so this is only reached from an inactive state — the
/// guard is belt-and-suspenders).
pub fn search_begin(&mut self, forward: bool) {
if self.search.is_some() {
return;
}
let origin = (self.active_buffer_id(), self.cursor());
// Attach the TUI match-wash overlay to the active window (once)
// so matches highlight live as the query grows. It
// self-suppresses when the store has no matches / is stale, so
// leaving it attached across searches is safe. The GPU gets the
// same matches via SearchMatch decorations and never reads this.
self.ensure_search_overlay();
self.search = Some(SearchSession {
query: String::new(),
origin,
forward,
});
}
/// Ensure the active window carries a [`crate::search::SearchView`]
/// overlay, attaching one if absent (deduped by overlay kind). The
/// view reads the per-buffer [`Self::search_store`] keyed on the
/// rendered buffer, so one instance suffices per window.
fn ensure_search_overlay(&mut self) {
let store = self.search_store.clone();
let win = self.active_window_mut();
if !win.overlay_kinds().contains(&"search") {
win.push_overlay(Box::new(crate::search::SearchView::new(store)));
}
}
/// Append a character to the query and re-search.
pub fn search_input_char(&mut self, ch: char) {
let Some(session) = self.search.as_mut() else {
return;
};
session.query.push(ch);
self.search_recompute();
}
/// Drop the last character of the query and re-search. With an
/// empty query this is a no-op (the search stays open, empty).
pub fn search_backspace(&mut self) {
let Some(session) = self.search.as_mut() else {
return;
};
session.query.pop();
self.search_recompute();
}
/// Re-run `find_all` for the current query against the origin
/// buffer, refresh the store, and move the cursor to the match
/// nearest the origin (first match at/after the origin cursor,
/// wrapping). An empty query or no match anchors the cursor back
/// at the origin so a failing search never drifts the view.
fn search_recompute(&mut self) {
let Some(session) = self.search.as_ref() else {
return;
};
let bid = session.origin.0;
let origin_byte = session.origin.1;
let query = session.query.clone();
let bytes = self.buffer_bytes(bid);
let matches = crate::search::find_all(&bytes, &query);
let focus = {
let mut guard = self
.search_store
.lock()
.expect("search store mutex poisoned");
guard.set(bid, query, matches);
guard.focus_from(bid, origin_byte)
};
let target = focus.map_or(origin_byte, |range| range.start);
self.search_place_cursor(target);
}
/// Step the active buffer's match focus forward/backward (wrapping)
/// and move the cursor to it. Operates on [`Self::search_store`]
/// directly, so it works both during a live session (C-s / C-r)
/// and after accept (a `search.next` navigation command). A no-op
/// when the active buffer has no matches.
pub fn search_step(&mut self, forward: bool) {
if let Some(session) = self.search.as_mut() {
session.forward = forward;
}
let bid = self.active_buffer_id();
let stepped = {
let mut guard = self
.search_store
.lock()
.expect("search store mutex poisoned");
guard.step(bid, forward)
};
if let Some(range) = stepped {
self.search_place_cursor(range.start);
}
}
/// End the active search. `accept` keeps the cursor at the current
/// match and leaves the matches in the store (so they stay
/// highlighted, and `search.next` can resume, until the next edit
/// marks them stale). Cancel restores the origin cursor and clears
/// the matches. A no-op when no search is running.
pub fn search_finish(&mut self, accept: bool) {
let Some(session) = self.search.take() else {
return;
};
if accept {
return;
}
let (bid, origin_byte) = session.origin;
if self.active_buffer_id() == bid {
self.search_place_cursor(origin_byte);
}
self.search_store
.lock()
.expect("search store mutex poisoned")
.clear(bid);
}
/// Move the active window's cursor to a byte offset (clamped to the
/// buffer extent), resetting the goal column. Shared by the search
/// motions.
fn search_place_cursor(&mut self, byte: u64) {
let clamped = byte.min(self.active_buffer_len());
let aw = self.active_window_mut();
aw.cursor = clamped;
aw.goal_col = None;
}
/// Snapshot a buffer's full byte content (empty if the id is
/// stale). O(1) rope snapshot + one copy; used to feed `find_all`.
fn buffer_bytes(&self, buffer_id: BufferId) -> Vec<u8> {
let reg = self.registry.borrow();
let Ok(buf) = reg.get(buffer_id) else {
return Vec::new();
};
let len = buf.len();
let mut out = vec![0u8; len as usize];
buf.snapshot_rope().slice(0, len, &mut out);
out
}
// ---- editing primitives ------------------------------------------------
/// Apply `op` to the active buffer; notify every window
@ -504,6 +733,16 @@ impl EditorCore {
self.pending_crdt_ops
.push((CrdtOpOrigin::DaemonKey, buffer_id, (**crdt_op).clone()));
}
// Search matches were computed against the pre-edit text, so
// their byte positions are now wrong. Mark the buffer's matches
// stale (M11.8): the producer / TUI overlay suppress them until
// a fresh search re-runs against the current content. No-op for
// a buffer with no search state. The headline isearch bet —
// "stale-after-edit linger" — is closed here.
self.search_store
.lock()
.expect("search store mutex poisoned")
.mark_stale(buffer_id);
Ok(edit.new_rope.len())
}
@ -2169,4 +2408,142 @@ mod tests {
// Next pop would be the stale `doomed` entry — skipped, ring empties.
assert!(!s.jump_back());
}
// ---- incremental search (Q#SR5) ------------------------------------
fn type_query(s: &mut EditorCore, q: &str) {
for ch in q.chars() {
s.search_input_char(ch);
}
}
#[test]
fn search_begin_then_type_highlights_from_origin() {
let mut s = from_bytes(b"foo bar foo baz foo");
let bid = s.active_buffer_id();
s.active_window_mut().cursor = 0;
s.search_begin(true);
assert!(s.search_active());
type_query(&mut s, "foo");
// Three matches: 0..3, 8..11, 16..19; first (at/after origin 0)
// is active and the cursor sits on it.
assert_eq!(s.search_match_summary(), (Some(0), 3));
assert_eq!(s.cursor(), 0);
let guard = s.search_store.lock().expect("store");
assert!(!guard.is_stale(bid));
assert_eq!(guard.for_buffer(bid).expect("entry").len(), 3);
}
#[test]
fn search_step_walks_matches_and_wraps() {
let mut s = from_bytes(b"foo bar foo baz foo");
s.active_window_mut().cursor = 0;
s.search_begin(true);
type_query(&mut s, "foo");
assert_eq!(s.cursor(), 0);
s.search_step(true);
assert_eq!((s.search_match_summary(), s.cursor()), ((Some(1), 3), 8));
s.search_step(true);
assert_eq!(s.cursor(), 16);
s.search_step(true); // wraps to the first match
assert_eq!(s.cursor(), 0);
s.search_step(false); // backward wraps to the last
assert_eq!(s.cursor(), 16);
}
#[test]
fn search_focuses_first_match_at_or_after_origin() {
let mut s = from_bytes(b"foo bar foo");
s.active_window_mut().cursor = 5; // inside "bar"
s.search_begin(true);
type_query(&mut s, "foo");
// First match with start >= 5 is the one at byte 8.
assert_eq!(s.cursor(), 8);
assert_eq!(s.search_match_summary(), (Some(1), 2));
}
#[test]
fn search_cancel_restores_origin_and_clears_store() {
let mut s = from_bytes(b"foo bar foo");
let bid = s.active_buffer_id();
s.active_window_mut().cursor = 5;
s.search_begin(true);
type_query(&mut s, "foo");
assert_eq!(s.cursor(), 8);
s.search_finish(false); // cancel
assert!(!s.search_active());
assert_eq!(s.cursor(), 5, "cancel restores the pre-search cursor");
assert!(
s.search_store
.lock()
.expect("store")
.for_buffer(bid)
.is_none(),
"cancel clears the matches"
);
}
#[test]
fn search_accept_keeps_cursor_and_matches() {
let mut s = from_bytes(b"foo bar foo");
let bid = s.active_buffer_id();
s.active_window_mut().cursor = 0;
s.search_begin(true);
type_query(&mut s, "foo");
s.search_step(true); // focus the match at byte 8
assert_eq!(s.cursor(), 8);
s.search_finish(true); // accept
assert!(!s.search_active());
assert_eq!(s.cursor(), 8, "accept keeps the cursor on the match");
assert!(
s.search_store
.lock()
.expect("store")
.for_buffer(bid)
.is_some(),
"accept keeps matches for highlight + navigation"
);
}
#[test]
fn search_backspace_widens_the_match_set() {
let mut s = from_bytes(b"fo foo food");
s.active_window_mut().cursor = 0;
s.search_begin(true);
type_query(&mut s, "foo"); // matches "foo" at 3..6, 7..10
assert_eq!(s.search_match_summary().1, 2);
s.search_backspace(); // query "fo"
assert_eq!(s.search_query(), "fo");
assert_eq!(s.search_match_summary().1, 3);
}
#[test]
fn search_smart_case_is_case_sensitive_with_uppercase() {
let mut s = from_bytes(b"Foo foo FOO");
s.active_window_mut().cursor = 0;
s.search_begin(true);
type_query(&mut s, "Foo"); // uppercase => case-sensitive
assert_eq!(s.search_match_summary().1, 1);
s.search_backspace();
s.search_backspace();
s.search_backspace();
type_query(&mut s, "foo"); // lowercase => smart-case folds all
assert_eq!(s.search_match_summary().1, 3);
}
#[test]
fn edit_marks_accepted_matches_stale() {
let mut s = from_bytes(b"foo foo");
let bid = s.active_buffer_id();
s.active_window_mut().cursor = 0;
s.search_begin(true);
type_query(&mut s, "foo");
s.search_finish(true); // matches persist after accept
assert!(!s.search_store.lock().expect("store").is_stale(bid));
s.insert_char('x'); // any edit invalidates the match offsets
assert!(
s.search_store.lock().expect("store").is_stale(bid),
"an edit marks the buffer's matches stale (linger fix)"
);
}
}

View File

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

View File

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

View File

@ -5126,6 +5126,7 @@ pub fn install_editor(lua: &Lua, core: &SharedCore) -> mlua::Result<()> {
install_editing(&editor, lua, core)?;
install_history(&editor, lua, core)?;
install_session(&editor, lua, core)?;
install_search(&editor, lua, core)?;
pmacs.set("editor", editor)?;
pmacs.set("frontend", install_frontend_module(lua, core)?)?;
@ -11431,6 +11432,48 @@ fn install_history(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result
Ok(())
}
/// Install the `pmacs.editor.search_*` primitives that drive
/// incremental search (Q#SR5). The live-typing keys are intercepted in
/// Rust (`EditorState::dispatch_search_key`); these bindings exist so
/// the *entry* commands (`search.forward` / `search.backward`) and any
/// post-accept navigation commands can begin / step a search from Lua.
fn install_search(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result<()> {
{
// search_start(forward): begin an isearch in the given
// direction, anchored at the active buffer + cursor.
let cc = core.clone();
editor.set(
"search_start",
lua.create_function(move |_, forward: bool| {
cc.borrow_mut().search_begin(forward);
Ok(())
})?,
)?;
}
{
// search_step(forward): move the active buffer's match focus
// (works during a live search and after accept, for navigation
// commands). No-op when the buffer has no matches.
let cc = core.clone();
editor.set(
"search_step",
lua.create_function(move |_, forward: bool| {
cc.borrow_mut().search_step(forward);
Ok(())
})?,
)?;
}
{
// search_active(): true while an isearch session is running.
let cc = core.clone();
editor.set(
"search_active",
lua.create_function(move |_, ()| Ok(cc.borrow().search_active()))?,
)?;
}
Ok(())
}
#[allow(
clippy::too_many_lines,
reason = "linear list of session bindings; the surface is coherent and split would fragment review"

View File

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

528
src/search.rs Normal file
View File

@ -0,0 +1,528 @@
// search.rs --- in-buffer incremental search store + match primitive.
//! Per-buffer in-buffer search state and the smart-case substring
//! matcher that fills it. Mirrors [`crate::diag::DiagnosticStore`]:
//! a cheaply-cloneable shared store written by the search session /
//! navigation commands and read by the decorations producer
//! ([`crate::semantic_render`]) and the TUI [`SearchView`]
//! (`crate::search_view`-equivalent — lives here for v1).
//!
//! # Why per-buffer (not per-window)
//!
//! Matches are a function of buffer *content*, so they live per
//! buffer, like diagnostics — not per window like the selection. The
//! active-match index is navigation state kept on the store; v1
//! accepts that two windows showing the same buffer share the active
//! highlight (the same tradeoff diagnostics make).
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use pmacs_protocol::ByteRange;
use crate::buffer::BufferId;
/// One buffer's search state: the resolved query, its matches (byte
/// ranges, ascending and non-overlapping), and the active index.
#[derive(Clone, Debug, Default)]
pub struct SearchState {
query: String,
matches: Vec<ByteRange>,
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<ByteRange> {
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<usize> {
(!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<BufferId, SearchState>,
stale: HashSet<BufferId>,
}
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<String>, matches: Vec<ByteRange>) {
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<ByteRange> {
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<ByteRange> {
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<Mutex<SearchStore>>;
/// 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<ByteRange> {
let q = query.as_bytes();
if q.is_empty() || haystack.len() < q.len() {
return Vec::new();
}
let case_sensitive = query.chars().any(char::is_uppercase);
let matches_at = |i: usize| {
haystack[i..i + q.len()].iter().zip(q).all(|(&h, &needle)| {
if case_sensitive {
h == needle
} else {
h.eq_ignore_ascii_case(&needle)
}
})
};
let mut out = Vec::new();
let mut i = 0;
while i + q.len() <= haystack.len() {
if matches_at(i) {
out.push(ByteRange {
start: i as u64,
end: (i + q.len()) as u64,
});
i += q.len(); // non-overlapping
} else {
i += 1;
}
}
out
}
// ---------------------------------------------------------------------------
// TUI view
// ---------------------------------------------------------------------------
use crate::buffer::Buffer;
use crate::cell::{CellCoord, CellGrid, Color, Style};
use crate::overlay::merge_styles;
use crate::view::{View, Viewport};
/// Background style applied to a non-active search match (Q#SR4) —
/// black-on-yellow so the highlighted text reads on any theme.
fn match_style() -> Style {
Style {
bg: Color::Indexed(3), // yellow
fg: Color::Indexed(0), // black
..Style::default()
}
}
/// Background style for the active match — brighter yellow so it
/// stands out from the lazy matches as you step through.
fn active_match_style() -> Style {
Style {
bg: Color::Indexed(11), // bright yellow
fg: Color::Indexed(0),
..Style::default()
}
}
/// TUI overlay that washes search matches in the visible region,
/// mirroring [`crate::diag::DiagnosticView`]: snapshot the store under
/// the lock, skip while stale, map each match's byte range to display
/// columns, and merge the highlight style into those cells. Matches
/// are single-line (the minibuffer query carries no newline), so each
/// maps to one row.
pub struct SearchView {
store: SharedSearchStore,
}
impl SearchView {
/// Construct a view reading `store` for whichever buffer the host
/// window is showing. The view keys on the *rendered* buffer
/// ([`Buffer::id`]) rather than a fixed id, so a single attached
/// instance keeps highlighting correctly even if the window
/// switches buffers (the store is per-buffer; a buffer with no
/// search entry simply paints nothing).
#[must_use]
pub fn new(store: SharedSearchStore) -> Self {
Self { store }
}
}
impl View for SearchView {
fn kind(&self) -> &'static str {
"search"
}
fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
let buffer_id = buf.id();
// Snapshot the matches under the lock, release immediately
// (same discipline as DiagnosticView).
let (matches, active): (Vec<ByteRange>, Option<ByteRange>) = {
let guard = self.store.lock().expect("search store mutex poisoned");
if guard.is_stale(buffer_id) {
return;
}
match guard.for_buffer(buffer_id) {
Some(s) => (s.matches().to_vec(), s.active_match()),
None => return,
}
};
if matches.is_empty() {
return;
}
let source: Vec<u8> = {
let mut bytes = vec![0u8; buf.len() as usize];
if !bytes.is_empty() {
buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
}
bytes
};
let line_offsets = crate::diag::compute_line_offsets(&source);
let start_line_buf =
crate::diag::line_at_offset(&line_offsets, viewport.buffer_start as u32);
let max_rows = viewport.cell_size.rows;
let max_cols = viewport.cell_size.cols;
let cell_origin = viewport.cell_origin;
for m in &matches {
let line = crate::diag::line_at_offset(&line_offsets, m.start as u32);
if line < start_line_buf {
continue;
}
let row_offset = line - start_line_buf;
if row_offset >= max_rows {
break;
}
let line_start = line_offsets[line as usize];
let line_end = line_offsets
.get(line as usize + 1)
.copied()
.unwrap_or(source.len() as u32);
let line_end_no_nl = if line_end > line_start
&& source.get(line_end as usize - 1).copied() == Some(b'\n')
{
line_end - 1
} else {
line_end
};
let line_bytes = &source[line_start as usize..line_end_no_nl as usize];
let within_start = (m.start as u32).saturating_sub(line_start) as usize;
let within_end = (m.end as u32).saturating_sub(line_start) as usize;
let (start_col, end_col) =
crate::diag::byte_range_to_display_cols(line_bytes, within_start, within_end);
if end_col <= start_col {
continue;
}
let style = if Some(*m) == active {
active_match_style()
} else {
match_style()
};
let cell_row = cell_origin.row + row_offset;
let clamped_start = start_col.min(max_cols);
let clamped_end = end_col.min(max_cols);
for col in clamped_start..clamped_end {
let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
cell.style = merge_styles(cell.style, style);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn r(start: u64, end: u64) -> ByteRange {
ByteRange { start, end }
}
#[test]
fn find_all_is_case_insensitive_for_lowercase_queries() {
// "fn" matches "fn" and "Fn" and "FN" when the query is all
// lowercase (smart-case).
let hay = b"fn Fn FN fnord";
assert_eq!(
find_all(hay, "fn"),
vec![r(0, 2), r(3, 5), r(6, 8), r(9, 11)]
);
}
#[test]
fn find_all_is_case_sensitive_when_query_has_uppercase() {
// "Fn" (has uppercase) matches only "Fn".
let hay = b"fn Fn FN";
assert_eq!(find_all(hay, "Fn"), vec![r(3, 5)]);
}
#[test]
fn find_all_matches_are_non_overlapping() {
// "aa" in "aaaa": [0,2) and [2,4), not [1,3).
assert_eq!(find_all(b"aaaa", "aa"), vec![r(0, 2), r(2, 4)]);
}
#[test]
fn find_all_empty_query_and_too_short_haystack() {
assert!(find_all(b"hello", "").is_empty());
assert!(find_all(b"hi", "hello").is_empty());
assert!(find_all(b"", "x").is_empty());
}
#[test]
fn store_set_clamps_active_and_clears_on_empty() {
let mut s = SearchStore::new();
let bid = BufferId::next();
s.set(bid, "x", vec![r(0, 1), r(4, 5), r(8, 9)]);
// Step to the last match, then a shorter re-search clamps the
// active index instead of pointing past the end.
s.step(bid, true);
s.step(bid, true);
assert_eq!(s.for_buffer(bid).unwrap().active_index(), Some(2));
s.set(bid, "x", vec![r(0, 1)]);
assert_eq!(s.for_buffer(bid).unwrap().active_index(), Some(0));
// Empty query drops the entry.
s.set(bid, "", vec![]);
assert!(s.for_buffer(bid).is_none());
}
#[test]
fn store_step_wraps_both_directions() {
let mut s = SearchStore::new();
let bid = BufferId::next();
s.set(bid, "x", vec![r(0, 1), r(4, 5), r(8, 9)]);
assert_eq!(s.step(bid, true), Some(r(4, 5)));
assert_eq!(s.step(bid, true), Some(r(8, 9)));
assert_eq!(s.step(bid, true), Some(r(0, 1)), "wraps to first");
assert_eq!(s.step(bid, false), Some(r(8, 9)), "wraps back to last");
}
#[test]
fn store_focus_from_picks_match_at_or_after_cursor() {
let mut s = SearchStore::new();
let bid = BufferId::next();
s.set(bid, "x", vec![r(2, 3), r(10, 11), r(20, 21)]);
assert_eq!(s.focus_from(bid, 5), Some(r(10, 11)));
assert_eq!(s.for_buffer(bid).unwrap().active_index(), Some(1));
// Past the last match → wrap to the first.
assert_eq!(s.focus_from(bid, 99), Some(r(2, 3)));
}
#[test]
fn search_view_washes_matches_and_distinguishes_active() {
use crate::cell::{Cell, CellGrid, CellSize};
use crate::view::Viewport;
let store = make_shared_store();
let bid = BufferId::next();
let mut buf = Buffer::new(bid, "test.txt");
buf.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"lo lo lo\n",
})
.expect("seed");
store
.lock()
.unwrap()
.set(bid, "lo", find_all(b"lo lo lo\n", "lo"));
let mut view = SearchView::new(store.clone());
let mut backing = vec![Cell::default(); 10];
let mut grid = CellGrid {
cells: &mut backing,
stride: 10,
size: CellSize::new(1, 10),
};
view.render(
&buf,
Viewport {
buffer_start: 0,
buffer_end: buf.len(),
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(1, 10),
},
&mut grid,
);
// Match 0 [0,2) is active (bright yellow), matches at [3,5) and
// [6,8) are lazy (yellow); the spaces between carry no bg.
assert_eq!(grid.get(CellCoord::new(0, 0)).style.bg, Color::Indexed(11));
assert_eq!(grid.get(CellCoord::new(0, 1)).style.bg, Color::Indexed(11));
assert_eq!(grid.get(CellCoord::new(0, 2)).style.bg, Color::Default);
assert_eq!(grid.get(CellCoord::new(0, 3)).style.bg, Color::Indexed(3));
assert_eq!(grid.get(CellCoord::new(0, 6)).style.bg, Color::Indexed(3));
// Stale store paints nothing.
store.lock().unwrap().mark_stale(bid);
let mut backing2 = vec![Cell::default(); 10];
let mut grid2 = CellGrid {
cells: &mut backing2,
stride: 10,
size: CellSize::new(1, 10),
};
view.render(
&buf,
Viewport {
buffer_start: 0,
buffer_end: buf.len(),
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(1, 10),
},
&mut grid2,
);
assert_eq!(
grid2.get(CellCoord::new(0, 0)).style.bg,
Color::Default,
"stale store washes nothing"
);
}
#[test]
fn store_staleness_tracks_per_buffer() {
let mut s = SearchStore::new();
let bid = BufferId::next();
s.set(bid, "x", vec![r(0, 1)]);
assert!(!s.is_stale(bid));
s.mark_stale(bid);
assert!(s.is_stale(bid));
// A fresh set clears stale.
s.set(bid, "x", vec![r(0, 1)]);
assert!(!s.is_stale(bid));
// mark_stale is a no-op for a buffer with no search state.
let other = BufferId::next();
s.mark_stale(other);
assert!(!s.is_stale(other));
}
}

View File

@ -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<BufferId, (String, bool, u32, u32)>,
/// `(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<BufferId, (Option<String>, Option<u32>, 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<InstanceMessage> {
// Off-active-buffer viewports never touch the search band — the
// active buffer owns it. (Without this, switching buffers mid-
// session would let an inactive viewport clobber the cache.)
let facts = {
let core = state.core.borrow();
if buffer_id != core.active_buffer_id() {
return None;
}
if core.search_active() {
let (active_idx, total) = core.search_match_summary();
(
Some(core.search_query().to_owned()),
active_idx.and_then(|i| u32::try_from(i).ok()),
u32::try_from(total).unwrap_or(u32::MAX),
)
} else {
// No search → a cleared band. active/total are zeroed so
// the inactive state is one canonical tuple (the GPU only
// reads them when `query` is `Some`). The accepted matches
// keep highlighting via Decorations regardless.
(None, None, 0)
}
};
if self.last_search_prompt.get(&buffer_id) == Some(&facts) {
return None;
}
let cached = self.last_search_prompt.get(&buffer_id);
if cached == Some(&facts) {
return None;
}
// First sight of this buffer with no active search: there is
// nothing to clear, so stay silent rather than ship an empty
// band on every fresh buffer. Record the baseline so a *later*
// search→clear transition still diffs. (Mirrors the inline-
// adornments "speak only if there's something to show" rule.)
if cached.is_none() && facts.0.is_none() {
self.last_search_prompt.insert(buffer_id, facts);
return None;
}
let msg = InstanceMessage::SearchPrompt {
buffer_id,
query: facts.0.clone(),
active: facts.1,
total: facts.2,
};
self.last_search_prompt.insert(buffer_id, facts);
Some(msg)
}
/// The `StatusFacts` message for this frame, or `None` when
/// nothing changed. Carries the facts a semantic frontend cannot
/// derive locally: buffer name, modified flag, whole-file
@ -715,6 +784,34 @@ impl SemanticRenderState {
}
}
// In-buffer search matches (Q#SR3). Already byte ranges — no
// line/col conversion. Skipped while stale (an edit leaves the
// matches at pre-edit positions until the next re-search, the
// M11.8 model). The active match emits `SearchMatchActive`,
// the rest `SearchMatch`; matches are non-overlapping so each
// range carries exactly one kind.
{
let store = core.search_store.clone();
let guard = store.lock().expect("search store mutex poisoned");
if !guard.is_stale(vp.buffer_id)
&& let Some(search) = guard.for_buffer(vp.buffer_id)
{
let active = search.active_match();
for m in search.matches() {
if let Some(range) = clip_to_viewport(m.start, m.end, vp) {
out.push(Decoration {
range,
kind: if Some(*m) == active {
DecorationKind::SearchMatchActive
} else {
DecorationKind::SearchMatch
},
});
}
}
}
}
out
}
}
@ -1434,8 +1531,8 @@ mod tests {
/// All `InstanceMessage` variants the semantic projection may
/// emit are `StyleSpans`, `Decorations`, `InlineAdornments`,
/// `FileStyleSummary`, or `StatusFacts` (Q#S1) — never
/// `CellDelta`, grid `Cursor`, or the still-unwired
/// `FileStyleSummary`, `StatusFacts` (Q#S1), or `SearchPrompt`
/// (Q#SR5) — never `CellDelta`, grid `Cursor`, or the still-unwired
/// `BlockAdornments` / `FoldState` families.
fn assert_semantic_only(msgs: &[InstanceMessage]) {
for m in msgs {
@ -1447,6 +1544,7 @@ mod tests {
| InstanceMessage::InlineAdornments { .. }
| InstanceMessage::FileStyleSummary { .. }
| InstanceMessage::StatusFacts { .. }
| InstanceMessage::SearchPrompt { .. }
),
"semantic projection emitted an unexpected variant: {m:?}"
);
@ -1518,6 +1616,76 @@ mod tests {
);
}
#[test]
fn search_matches_emit_as_decorations_with_active_distinguished() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
// "lo lo lo" — three "lo" matches at 0..2, 3..5, 6..8.
{
let core = state.core.borrow();
core.registry
.clone()
.borrow_mut()
.get_mut(bid)
.expect("active buffer")
.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"lo lo lo",
})
.expect("seed");
}
{
let store = state.core.borrow().search_store.clone();
let matches = crate::search::find_all(b"lo lo lo", "lo");
store.lock().expect("search store").set(bid, "lo", matches);
}
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
let (_full, decos) =
decorations_of(&s.render_frame(&state)).expect("search frame ships decorations");
let search: Vec<_> = decos
.iter()
.filter(|d| {
matches!(
d.kind,
DecorationKind::SearchMatch | DecorationKind::SearchMatchActive
)
})
.collect();
assert_eq!(search.len(), 3, "three matches highlighted; got {decos:?}");
let active: Vec<_> = decos
.iter()
.filter(|d| d.kind == DecorationKind::SearchMatchActive)
.collect();
assert_eq!(active.len(), 1, "exactly one active match");
assert_eq!(
active[0].range,
ByteRange { start: 0, end: 2 },
"the first match is active by default"
);
// Marking the store stale suppresses search emission (M11.8):
// the next frame ships a clearing diff, never a search kind.
state
.core
.borrow()
.search_store
.clone()
.lock()
.expect("search store")
.mark_stale(bid);
if let Some((_full, decos)) = decorations_of(&s.render_frame(&state)) {
assert!(
decos.iter().all(|d| !matches!(
d.kind,
DecorationKind::SearchMatch | DecorationKind::SearchMatchActive
)),
"stale search store paints no matches; got {decos:?}"
);
}
}
#[test]
fn emits_nothing_before_viewport_declared() {
let mut s = local();
@ -2887,6 +3055,77 @@ mod tests {
})
}
fn search_prompt_of(msgs: &[InstanceMessage]) -> Option<(Option<String>, Option<u32>, 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();