diff --git a/src/editor.rs b/src/editor.rs index 5adfade..42be2cb 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -2106,6 +2106,80 @@ mod tests { assert_eq!(s.core.borrow().search_query(), "foo"); } + #[test] + fn isearch_accumulates_across_renders_like_run_loop() { + // Reproduce the real run loop: a render between every keystroke + // (the in-process TUI renders once per burst, but paint_frame + // borrows the core mutably and reads the search state, so a + // render must not corrupt mid-search input). + use crate::frontend::Event; + let mut s = fresh_with(b"foo bar foo baz foo"); + s.core.borrow_mut().active_window_mut().cursor = 0; + let size = crate::cell::CellSize::new(24, 80); + let mut rs = crate::instance_render::RenderState::new(size); + + let _ = rs.render_frame(&s, &[]); + process_event(&mut s, Event::Key(ctrl('s')), size); + assert!(s.core.borrow().search_active(), "C-s starts the search"); + let _ = rs.render_frame(&s, &[]); + + for c in "foo".chars() { + process_event( + &mut s, + Event::Key(key(KeyCode::Char(c), KeyModifiers::NONE)), + size, + ); + let _ = rs.render_frame(&s, &[]); + } + assert_eq!( + s.core.borrow().search_query(), + "foo", + "query must accumulate across renders, not stick at the first char" + ); + } + + #[test] + fn isearch_tui_washes_matches_and_shows_full_query() { + // The regression behind "only searches for the first character": + // the TUI had no match-wash overlay, so the only feedback was the + // cursor jump. Paint a real frame and assert both the wash and + // the full-query prompt land on the grid. + use crate::cell::{Cell, CellCoord, CellGrid, CellSize, Color, Glyph}; + let mut s = fresh_with(b"foo bar foo"); + s.core.borrow_mut().active_window_mut().cursor = 0; + s.dispatch_key(FrontendId::LOCAL, ctrl('s')); + type_chars(&mut s, "foo"); + + let size = CellSize::new(24, 80); + let mut backing = vec![Cell::default(); (size.rows * size.cols) as usize]; + let mut grid = CellGrid { + cells: &mut backing, + stride: size.cols, + size, + }; + let _ = paint_frame(&s, &mut grid, size); + + // The active match [0,3) washes row 0's first cells (bright + // Indexed(11); lazy matches would be Indexed(3)). + let bg0 = grid.get(CellCoord::new(0, 0)).style.bg; + assert!( + matches!(bg0, Color::Indexed(11) | Color::Indexed(3)), + "first match cell should carry the search wash, got {bg0:?}" + ); + // The bottom row shows the full live query, not just "f". + let row = size.rows - 1; + let prompt: String = (0..size.cols) + .filter_map(|c| match grid.get(CellCoord::new(row, c)).glyph { + Glyph::Char(ch) => Some(ch), + _ => None, + }) + .collect(); + assert!( + prompt.contains("I-search: foo"), + "bottom row should show the accumulated query, got {prompt:?}" + ); + } + #[test] fn isearch_flips_dispatch_idle_so_gpu_round_trips() { // The GPU's optimistic-apply gate (M11.6) keys off dispatch_idle. diff --git a/src/editor_core.rs b/src/editor_core.rs index 7cf4c9b..9c0feb2 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -561,6 +561,12 @@ impl EditorCore { return; } let origin = (self.active_buffer_id(), self.cursor()); + // Attach the TUI match-wash overlay to the active window (once) + // so matches highlight live as the query grows. It + // self-suppresses when the store has no matches / is stale, so + // leaving it attached across searches is safe. The GPU gets the + // same matches via SearchMatch decorations and never reads this. + self.ensure_search_overlay(); self.search = Some(SearchSession { query: String::new(), origin, @@ -568,6 +574,18 @@ impl EditorCore { }); } + /// Ensure the active window carries a [`crate::search::SearchView`] + /// overlay, attaching one if absent (deduped by overlay kind). The + /// view reads the per-buffer [`Self::search_store`] keyed on the + /// rendered buffer, so one instance suffices per window. + fn ensure_search_overlay(&mut self) { + let store = self.search_store.clone(); + let win = self.active_window_mut(); + if !win.overlay_kinds().contains(&"search") { + win.push_overlay(Box::new(crate::search::SearchView::new(store))); + } + } + /// Append a character to the query and re-search. pub fn search_input_char(&mut self, ch: char) { let Some(session) = self.search.as_mut() else { diff --git a/src/search.rs b/src/search.rs index 88e936d..eacb5be 100644 --- a/src/search.rs +++ b/src/search.rs @@ -264,15 +264,19 @@ fn active_match_style() -> Style { /// are single-line (the minibuffer query carries no newline), so each /// maps to one row. pub struct SearchView { - buffer_id: BufferId, store: SharedSearchStore, } impl SearchView { - /// Construct a view reading `store` for `buffer_id`. + /// Construct a view reading `store` for whichever buffer the host + /// window is showing. The view keys on the *rendered* buffer + /// ([`Buffer::id`]) rather than a fixed id, so a single attached + /// instance keeps highlighting correctly even if the window + /// switches buffers (the store is per-buffer; a buffer with no + /// search entry simply paints nothing). #[must_use] - pub fn new(buffer_id: BufferId, store: SharedSearchStore) -> Self { - Self { buffer_id, store } + pub fn new(store: SharedSearchStore) -> Self { + Self { store } } } @@ -282,14 +286,15 @@ impl View for SearchView { } fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) { + let buffer_id = buf.id(); // Snapshot the matches under the lock, release immediately // (same discipline as DiagnosticView). let (matches, active): (Vec, Option) = { let guard = self.store.lock().expect("search store mutex poisoned"); - if guard.is_stale(self.buffer_id) { + if guard.is_stale(buffer_id) { return; } - match guard.for_buffer(self.buffer_id) { + match guard.for_buffer(buffer_id) { Some(s) => (s.matches().to_vec(), s.active_match()), None => return, } @@ -453,7 +458,7 @@ mod tests { .unwrap() .set(bid, "lo", find_all(b"lo lo lo\n", "lo")); - let mut view = SearchView::new(bid, store.clone()); + let mut view = SearchView::new(store.clone()); let mut backing = vec![Cell::default(); 10]; let mut grid = CellGrid { cells: &mut backing,