search: render matches in both frontends (Q#SR3/SR4)

The search store now hangs off EditorCore (reachable by the
producer, the Lua commands, and the TUI view). The decorations
producer emits SearchMatch for every visible match and
SearchMatchActive for the active one, byte-range-direct (no
line/col conversion), viewport-clipped, and stale-skipped on the
M11.8 model. pmacs-gpu wires the two decoration_kind_to_bg_color
arms (translucent yellow / stronger amber). The TUI gets a
SearchView overlay mirroring DiagnosticView (black-on-yellow wash,
brighter for the active match), reusing diag.rs's now-pub(crate)
line/col helpers.

Nothing populates the store yet (commit 3 wires the input), so the
paths are dormant until then — verified by populating the store
directly: producer emits the right kinds + stale-suppresses, the
TUI view washes the cells.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-27 10:48:06 -04:00
parent b167b9a6df
commit 7c46288ef0
5 changed files with 322 additions and 18 deletions

View File

@ -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"
);
}
}

View File

@ -592,7 +592,7 @@ impl View for DiagnosticView {
// cross-module coupling on internal helpers)
// ---------------------------------------------------------------------------
fn compute_line_offsets(source: &[u8]) -> Vec<u32> {
pub(crate) fn compute_line_offsets(source: &[u8]) -> Vec<u32> {
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<u32> {
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 {

View File

@ -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<Mutex>`); 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(),
}
}

View File

@ -228,6 +228,135 @@ pub fn find_all(haystack: &[u8], query: &str) -> Vec<ByteRange> {
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<ByteRange>, Option<ByteRange>) = {
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<u8> = {
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();

View File

@ -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();