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:
parent
5111ae82e7
commit
22773737e9
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue