framing: incremental in-buffer search (smart-case isearch)

Decided: incremental isearch (live highlight, same key steps next,
Enter accepts, Esc restores origin) + smart-case substring. Q#SR1
per-buffer SearchStore mirroring DiagnosticStore; Q#SR2 smart-case
find_all over a rope snapshot (regex deferred); Q#SR3 producer +
TUI SearchView mirror the diagnostics path; Q#SR4 yellow/amber
palette; Q#SR5 minibuffer-hosted query + new on_changed hook +
next/prev interception; Q#SR6 navigation mirrors diag.next/prev.
Four bets (stale-after-edit linger the headline). Three-commit arc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-27 10:33:40 -04:00
parent 4d63f21905
commit d45b064ab2
1 changed files with 123 additions and 0 deletions

View File

@ -0,0 +1,123 @@
# Incremental in-buffer search — framing pass
Date: 2026-06-15. The last deferred GUI item: `SearchMatch` /
`SearchMatchActive` decorations exist on the wire (message.rs) and are
gated to `None` in both frontends "waiting on a search feature." This
builds that feature. Decided up front (user): **incremental isearch**
(highlight live as you type, same key steps to next, Enter accepts,
Esc/C-g restores origin) with **smart-case substring** matching
(case-insensitive unless the query has an uppercase letter).
## Survey facts (anchors)
- Greenfield in-buffer search; only `project.search` (cross-file grep)
exists. No `Buffer::find` / rope search.
- `DiagnosticStore` (diag.rs) is a near-exact template: keyed
`Arc<Mutex>` store, sorted entries, `next_after`/`previous_before`,
stale tracking, Lua nav bindings, overlay `_attach_view`.
- Producer `scoped_decorations` (semantic_render.rs) + TUI
`DiagnosticView::render` are the emit/paint templates (viewport clip,
line-start cache, stale-skip).
- GPU `decoration_kind_to_bg_color` already draws bg decorations
through the quad pipeline; SearchMatch/Active just need their arms.
- The minibuffer (minibuffer.rs) is keystroke-driven and pseudo-modal
(dispatch_minibuffer_key intercepts all keys while active) but has
**no live-preview/on_changed hook** — the one missing piece for
incremental highlight.
## Q#SR1 — store shape & ownership
**Stance: a per-buffer `SearchStore` mirroring `DiagnosticStore`.**
`by_buffer: HashMap<BufferId, SearchState>` where `SearchState` holds
the resolved query, the sorted `Vec<ByteRange>` matches, and the
active index. Shared `Arc<Mutex>`. The active index lives on the store
(navigation state), not per-window — v1 accepts that two windows on
the same buffer share the active highlight (note it; selection is the
per-window concept, search mirrors diagnostics). Edits mark the
buffer's entry stale (M11.8 model) so matches at pre-edit byte
positions aren't painted until re-search.
## Q#SR2 — search primitive
**Stance: smart-case substring over a rope snapshot, regex deferred.**
`find_all(haystack, query) -> Vec<ByteRange>`: case-insensitive unless
`query` contains an uppercase char (then exact). Built on
`snapshot_rope().slice` bytes (the diagnostics path's cheap snapshot).
Recomputed on query change; invalidated on edit. No `regex` crate in
v1 (literal-text is the 95% case; regex is a later toggle). Overlapping
matches: advance past each match's start+1 (standard non-overlapping).
## Q#SR3 — decoration emission
**Stance: mirror the diagnostics producer.** In `scoped_decorations`,
read the search store for the viewport buffer, emit `SearchMatch` for
every visible match and `SearchMatchActive` for the active one
(emitted last / higher z so it wins the overlap). Reuse the line cache
+ `clip_to_viewport`. Stale-skip exactly like diagnostics. TUI gets a
`SearchView` overlay (mirrors `DiagnosticView`) painting bg, attached
via `pmacs.search._attach_view`.
## Q#SR4 — colors
**Stance: a single search palette.** SearchMatch = translucent yellow
wash; SearchMatchActive = stronger amber/orange. GPU: the two
`decoration_kind_to_bg_color` arms. TUI: reverse-ish colored bg in the
`SearchView`. Distinct from selection (blue) and diagnostics
(severity).
## Q#SR5 — input & modality (incremental)
**Stance: host the query in the minibuffer + a small `on_changed`
hook + targeted next/prev interception.** Entry opens a minibuffer
search session (prompt `I-search: `); `on_changed` (new optional
session callback, fired after each content mutation in
dispatch_minibuffer_key) recomputes matches → updates the store →
re-decorate. While that session is active, the entry chord again =
`search.next`, its shift/`C-r` variant = `search.prev` (control keys,
not self-insert, so safe to intercept in the search branch of
dispatch_minibuffer_key). `Enter` accepts (close, leave cursor at the
active match); `Esc`/`C-g` cancels (close, restore the origin cursor
saved at entry, clear the store). Reusing the minibuffer's input
editing + prompt avoids reimplementing a modal query line.
## Q#SR6 — navigation
**Stance: `search.next`/`search.prev` mirror `diag.next/prev`.**
Advance the active index with wrap, move the active window's cursor to
the active match start, scroll it into view. The active index drives
which match is `SearchMatchActive`. Usable both during the live
session and afterward (matches persist until cleared / next search).
## Binding (proposal, flagged for veto)
`C-f` → search (CUA "Find"), rebound from `cursor.right`. Consistent
with the editor's CUA direction (arrows move; Ctrl+F finds); the
Emacs-holdover `C-f = forward-char` is the inconsistent one. Easy to
change — call out in validation.
## Predicted findings (categorical bets)
1. **Stale-after-edit linger** (the squiggle lesson again): matches at
pre-edit byte positions paint over shifted text until re-search —
the store's stale gate + re-search-on-change must be right, or
highlights drift during typing.
2. **Minibuffer `on_changed` × completion**: the hook interacts with
the existing per-keystroke candidate recompute; the search session
must opt out of completion cleanly (a session "kind" seam).
3. **Per-buffer active match across windows** surfaces as navigating
in one window moving the active highlight in another — accepted for
v1, but worth eyeballing.
4. **Empty / all-match queries**: empty query → no matches (not all);
a 1-char common letter → many matches → viewport-clipped emission
must stay cheap (line cache + only-visible).
## Session plan
Three green commits:
1. Core `SearchStore` + `find_all` smart-case primitive + unit tests.
2. Producer emission + GPU bg colors + TUI `SearchView` + attach.
3. Incremental UX: minibuffer `on_changed`, search session,
next/prev interception + commands, cancel-restores-origin, binding.
Manual validation gate as usual (type to highlight live, step matches,
edit mid-search, Esc restores).