search: attach the TUI match-wash overlay so isearch is visible

Fix for "seems to only search for the first character." The TUI's
`SearchView` overlay was written (commit 2) but never attached to a
window, so the terminal frontend painted no match highlights — the
only feedback was the cursor jumping to the first match, which made
refining the query past the first character look like a no-op even
though the search was working (verified: the query accumulates
correctly through the full run-loop path).

`search_begin` now attaches a `SearchView` to the active window
(deduped by overlay kind, so repeat searches don't stack it). The
view self-suppresses when the store has no matches or is stale, so a
persistent attach is safe — it paints only while a search has live
matches and stops the moment an edit invalidates them.

`SearchView` now keys on the *rendered* buffer (`Buffer::id`) instead
of a fixed id captured at construction, so one attached instance
keeps highlighting correctly even if the window later switches
buffers (the store is per-buffer; a buffer with no entry paints
nothing).

Tests: a render-level test that paints a real frame mid-search and
asserts both the match wash (bright `Indexed(11)` on the active
match) and the full `I-search: foo` prompt land on the grid — the
coverage that was missing, which would have caught the unattached
overlay. Plus a run-loop-fidelity test (renders interleaved with
keystrokes) pinning that the query accumulates rather than sticking
at the first character.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-27 12:47:12 -04:00
parent 5111ae82e7
commit 22773737e9
3 changed files with 104 additions and 7 deletions

View File

@ -2106,6 +2106,80 @@ mod tests {
assert_eq!(s.core.borrow().search_query(), "foo");
}
#[test]
fn isearch_accumulates_across_renders_like_run_loop() {
// Reproduce the real run loop: a render between every keystroke
// (the in-process TUI renders once per burst, but paint_frame
// borrows the core mutably and reads the search state, so a
// render must not corrupt mid-search input).
use crate::frontend::Event;
let mut s = fresh_with(b"foo bar foo baz foo");
s.core.borrow_mut().active_window_mut().cursor = 0;
let size = crate::cell::CellSize::new(24, 80);
let mut rs = crate::instance_render::RenderState::new(size);
let _ = rs.render_frame(&s, &[]);
process_event(&mut s, Event::Key(ctrl('s')), size);
assert!(s.core.borrow().search_active(), "C-s starts the search");
let _ = rs.render_frame(&s, &[]);
for c in "foo".chars() {
process_event(
&mut s,
Event::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
size,
);
let _ = rs.render_frame(&s, &[]);
}
assert_eq!(
s.core.borrow().search_query(),
"foo",
"query must accumulate across renders, not stick at the first char"
);
}
#[test]
fn isearch_tui_washes_matches_and_shows_full_query() {
// The regression behind "only searches for the first character":
// the TUI had no match-wash overlay, so the only feedback was the
// cursor jump. Paint a real frame and assert both the wash and
// the full-query prompt land on the grid.
use crate::cell::{Cell, CellCoord, CellGrid, CellSize, Color, Glyph};
let mut s = fresh_with(b"foo bar foo");
s.core.borrow_mut().active_window_mut().cursor = 0;
s.dispatch_key(FrontendId::LOCAL, ctrl('s'));
type_chars(&mut s, "foo");
let size = CellSize::new(24, 80);
let mut backing = vec![Cell::default(); (size.rows * size.cols) as usize];
let mut grid = CellGrid {
cells: &mut backing,
stride: size.cols,
size,
};
let _ = paint_frame(&s, &mut grid, size);
// The active match [0,3) washes row 0's first cells (bright
// Indexed(11); lazy matches would be Indexed(3)).
let bg0 = grid.get(CellCoord::new(0, 0)).style.bg;
assert!(
matches!(bg0, Color::Indexed(11) | Color::Indexed(3)),
"first match cell should carry the search wash, got {bg0:?}"
);
// The bottom row shows the full live query, not just "f".
let row = size.rows - 1;
let prompt: String = (0..size.cols)
.filter_map(|c| match grid.get(CellCoord::new(row, c)).glyph {
Glyph::Char(ch) => Some(ch),
_ => None,
})
.collect();
assert!(
prompt.contains("I-search: foo"),
"bottom row should show the accumulated query, got {prompt:?}"
);
}
#[test]
fn isearch_flips_dispatch_idle_so_gpu_round_trips() {
// The GPU's optimistic-apply gate (M11.6) keys off dispatch_idle.

View File

@ -561,6 +561,12 @@ impl EditorCore {
return;
}
let origin = (self.active_buffer_id(), self.cursor());
// Attach the TUI match-wash overlay to the active window (once)
// so matches highlight live as the query grows. It
// self-suppresses when the store has no matches / is stale, so
// leaving it attached across searches is safe. The GPU gets the
// same matches via SearchMatch decorations and never reads this.
self.ensure_search_overlay();
self.search = Some(SearchSession {
query: String::new(),
origin,
@ -568,6 +574,18 @@ impl EditorCore {
});
}
/// Ensure the active window carries a [`crate::search::SearchView`]
/// overlay, attaching one if absent (deduped by overlay kind). The
/// view reads the per-buffer [`Self::search_store`] keyed on the
/// rendered buffer, so one instance suffices per window.
fn ensure_search_overlay(&mut self) {
let store = self.search_store.clone();
let win = self.active_window_mut();
if !win.overlay_kinds().contains(&"search") {
win.push_overlay(Box::new(crate::search::SearchView::new(store)));
}
}
/// Append a character to the query and re-search.
pub fn search_input_char(&mut self, ch: char) {
let Some(session) = self.search.as_mut() else {

View File

@ -264,15 +264,19 @@ fn active_match_style() -> Style {
/// are single-line (the minibuffer query carries no newline), so each
/// maps to one row.
pub struct SearchView {
buffer_id: BufferId,
store: SharedSearchStore,
}
impl SearchView {
/// Construct a view reading `store` for `buffer_id`.
/// Construct a view reading `store` for whichever buffer the host
/// window is showing. The view keys on the *rendered* buffer
/// ([`Buffer::id`]) rather than a fixed id, so a single attached
/// instance keeps highlighting correctly even if the window
/// switches buffers (the store is per-buffer; a buffer with no
/// search entry simply paints nothing).
#[must_use]
pub fn new(buffer_id: BufferId, store: SharedSearchStore) -> Self {
Self { buffer_id, store }
pub fn new(store: SharedSearchStore) -> Self {
Self { store }
}
}
@ -282,14 +286,15 @@ impl View for SearchView {
}
fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
let buffer_id = buf.id();
// Snapshot the matches under the lock, release immediately
// (same discipline as DiagnosticView).
let (matches, active): (Vec<ByteRange>, Option<ByteRange>) = {
let guard = self.store.lock().expect("search store mutex poisoned");
if guard.is_stale(self.buffer_id) {
if guard.is_stale(buffer_id) {
return;
}
match guard.for_buffer(self.buffer_id) {
match guard.for_buffer(buffer_id) {
Some(s) => (s.matches().to_vec(), s.active_match()),
None => return,
}
@ -453,7 +458,7 @@ mod tests {
.unwrap()
.set(bid, "lo", find_all(b"lo lo lo\n", "lo"));
let mut view = SearchView::new(bid, store.clone());
let mut view = SearchView::new(store.clone());
let mut backing = vec![Cell::default(); 10];
let mut grid = CellGrid {
cells: &mut backing,