Merge pull request #70 from levineuwirth/session-incremental-search
Incremental in-buffer search (smart-case isearch), both frontends
This commit is contained in:
commit
f359d4a634
|
|
@ -117,6 +117,21 @@ cmd { name = "buffer.self-insert",
|
|||
description = "Insert the codepoint argument at the cursor, replacing the active region.",
|
||||
fn = function(codepoint) ed.insert_char_over_region(codepoint) end }
|
||||
|
||||
-- Incremental search ---------------------------------------------------------
|
||||
--
|
||||
-- C-s / C-r begin a live in-buffer isearch: the match under the cursor
|
||||
-- highlights as you type, the same key steps to the next/previous
|
||||
-- match, RET accepts (keeping the highlights until the next edit), and
|
||||
-- C-g / Esc restore the pre-search cursor. While a search is running
|
||||
-- every keystroke is intercepted in Rust (dispatch_search_key), so
|
||||
-- these commands only run to *start* a search from an idle keymap.
|
||||
cmd { name = "search.forward",
|
||||
description = "Start an incremental search forward from the cursor.",
|
||||
fn = function() ed.search_start(true) end }
|
||||
cmd { name = "search.backward",
|
||||
description = "Start an incremental search backward from the cursor.",
|
||||
fn = function() ed.search_start(false) end }
|
||||
|
||||
-- History --------------------------------------------------------------------
|
||||
|
||||
cmd { name = "buffer.undo", description = "Undo the most recent edit.",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,16 @@ bind("C-d", "buffer.delete-forward")
|
|||
bind("RET", "buffer.newline")
|
||||
bind("TAB", "buffer.tab")
|
||||
|
||||
-- Incremental search ---------------------------------------------------------
|
||||
--
|
||||
-- C-s / C-r start a live isearch (forward / backward). Both keys are
|
||||
-- free in the default map (save is C-x C-s, redo is C-x r), so this
|
||||
-- adds isearch without colliding with the CUA / Emacs editing keys.
|
||||
-- Once a search is running, C-s / C-r step to the next / previous
|
||||
-- match; that interception happens in Rust, so it needs no binding.
|
||||
bind("C-s", "search.forward")
|
||||
bind("C-r", "search.backward")
|
||||
|
||||
-- CUA-style word-level deletion (the same shortcuts users expect from
|
||||
-- IDEs, browsers, terminals on Linux/Windows). C-BS deletes back to
|
||||
-- the start of the previous word; C-DEL deletes forward through the
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
10
src/diag.rs
10
src/diag.rs
|
|
@ -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 {
|
||||
|
|
|
|||
327
src/editor.rs
327
src/editor.rs
|
|
@ -450,7 +450,8 @@ impl EditorState {
|
|||
/// - the dispatcher holds a pending multi-key prefix (e.g. the
|
||||
/// user has typed `C-x` and the daemon is waiting for the next
|
||||
/// chord), or
|
||||
/// - a minibuffer prompt is active and absorbing keys.
|
||||
/// - a minibuffer prompt is active and absorbing keys, or
|
||||
/// - an incremental search is running and absorbing keys (Q#SR5).
|
||||
///
|
||||
/// Used by the daemon to drive the `InstanceMessage::DispatchIdle`
|
||||
/// wire signal that gates `crdt_replica` frontends' optimistic-apply
|
||||
|
|
@ -458,13 +459,18 @@ impl EditorState {
|
|||
/// plain-char keystroke into the active document while the
|
||||
/// daemon's actual intent is to route the keystroke into the
|
||||
/// minibuffer prompt — the M10.10 "documented limitation" that
|
||||
/// surfaced during session-5 manual validation.
|
||||
/// surfaced during session-5 manual validation. Isearch reuses the
|
||||
/// exact same gate: while a search runs every keystroke must
|
||||
/// round-trip so the daemon's `dispatch_search_key` receives it
|
||||
/// (extend the query / step) instead of the frontend self-inserting
|
||||
/// it into the buffer.
|
||||
#[must_use]
|
||||
pub fn dispatch_idle(&self) -> bool {
|
||||
if !self.dispatcher.pending().is_empty() {
|
||||
return false;
|
||||
}
|
||||
!self.core.borrow().minibuffer.is_active()
|
||||
let core = self.core.borrow();
|
||||
!core.minibuffer.is_active() && !core.search_active()
|
||||
}
|
||||
|
||||
/// `frontend_id` records which frontend produced the event. v0.1
|
||||
|
|
@ -484,6 +490,17 @@ impl EditorState {
|
|||
core.active_frontend = frontend_id;
|
||||
}
|
||||
|
||||
// Incremental-search interception: while an isearch is running,
|
||||
// every key routes through the search handler (the global keymap
|
||||
// is shadowed, like the minibuffer). Printable chars extend the
|
||||
// query; C-s / C-r step; RET accepts; C-g / Esc cancel. This is
|
||||
// the shared input path for both frontends — the daemon's
|
||||
// `FrontendEvent::Key` round-trip lands here too.
|
||||
if self.core.borrow().search_active() {
|
||||
self.dispatch_search_key(chord);
|
||||
return;
|
||||
}
|
||||
|
||||
// Minibuffer interception: when a prompt is active, every key
|
||||
// routes through the minibuffer's hardcoded handler. The main
|
||||
// editor's keymap is bypassed; the user can still cancel with
|
||||
|
|
@ -615,6 +632,33 @@ impl EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Hardcoded handler for keys delivered while an incremental search
|
||||
/// is active. The global keymap is shadowed (like the minibuffer),
|
||||
/// so these chords are fixed:
|
||||
///
|
||||
/// * `C-s` / `Down` --- step to the next match (wraps).
|
||||
/// * `C-r` / `Up` --- step to the previous match (wraps).
|
||||
/// * `RET` --- accept (keep cursor + highlights).
|
||||
/// * `C-g` / `Esc` --- cancel (restore origin cursor).
|
||||
/// * `BS` --- shorten the query by one char.
|
||||
/// * a printable char --- extend the query.
|
||||
///
|
||||
/// Unrecognized chords are swallowed (an active isearch eats every
|
||||
/// keystroke, matching Emacs). The next/prev chords mirror the
|
||||
/// entry bindings (`search.forward` / `search.backward`) so the
|
||||
/// same key that started the search repeats it.
|
||||
fn dispatch_search_key(&mut self, chord: Chord) {
|
||||
match SearchKey::from_chord(chord) {
|
||||
SearchKey::Next => self.core.borrow_mut().search_step(true),
|
||||
SearchKey::Prev => self.core.borrow_mut().search_step(false),
|
||||
SearchKey::Accept => self.core.borrow_mut().search_finish(true),
|
||||
SearchKey::Cancel => self.core.borrow_mut().search_finish(false),
|
||||
SearchKey::Backspace => self.core.borrow_mut().search_backspace(),
|
||||
SearchKey::Insert(ch) => self.core.borrow_mut().search_input_char(ch),
|
||||
SearchKey::Ignore => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_minibuffer<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]
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue