diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index cddab83..ccd3b8b 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -4874,8 +4874,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 @@ -5566,14 +5570,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 +5609,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" ); } } diff --git a/src/diag.rs b/src/diag.rs index 97b30c7..cc2ea7e 100644 --- a/src/diag.rs +++ b/src/diag.rs @@ -592,7 +592,7 @@ impl View for DiagnosticView { // cross-module coupling on internal helpers) // --------------------------------------------------------------------------- -fn compute_line_offsets(source: &[u8]) -> Vec { +pub(crate) fn compute_line_offsets(source: &[u8]) -> Vec { 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 { 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 { diff --git a/src/editor_core.rs b/src/editor_core.rs index 5ad0bd9..b993951 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -127,6 +127,12 @@ 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`); shared with both readers. + pub search_store: crate::search::SharedSearchStore, } impl EditorCore { @@ -161,6 +167,7 @@ impl EditorCore { active_frontend: FrontendId::LOCAL, pending_crdt_ops: Vec::new(), jump_ring: Vec::new(), + search_store: crate::search::make_shared_store(), } } diff --git a/src/search.rs b/src/search.rs index 0035eaa..88e936d 100644 --- a/src/search.rs +++ b/src/search.rs @@ -228,6 +228,135 @@ pub fn find_all(haystack: &[u8], query: &str) -> Vec { 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 { + buffer_id: BufferId, + store: SharedSearchStore, +} + +impl SearchView { + /// Construct a view reading `store` for `buffer_id`. + #[must_use] + pub fn new(buffer_id: BufferId, store: SharedSearchStore) -> Self { + Self { buffer_id, store } + } +} + +impl View for SearchView { + fn kind(&self) -> &'static str { + "search" + } + + fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) { + // 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) { + return; + } + match guard.for_buffer(self.buffer_id) { + Some(s) => (s.matches().to_vec(), s.active_match()), + None => return, + } + }; + if matches.is_empty() { + return; + } + + let source: Vec = { + 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::*; @@ -306,6 +435,75 @@ mod tests { 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(bid, 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(); diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 01360fc..cbb0961 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -715,6 +715,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 } } @@ -1518,6 +1546,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();