From 6f1d4dd7f9ddb8d47e6acc8386000307cbabb2c5 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 25 Jun 2026 09:46:24 +0300 Subject: [PATCH] search: incremental scrollback search with match highlight and prompt Signed-off-by: NotAShelf Change-Id: I0a0450eba48d308763db297f105565346a6a6964 --- src/grid.rs | 176 +++++++++++++++++++++++++++++++++++++++++++++++++ src/render.rs | 52 +++++++++++++-- src/wayland.rs | 94 +++++++++++++++++++++++++- 3 files changed, 317 insertions(+), 5 deletions(-) diff --git a/src/grid.rs b/src/grid.rs index b208fe8..3a36a76 100644 --- a/src/grid.rs +++ b/src/grid.rs @@ -142,6 +142,22 @@ pub struct Point { pub col: usize, } +/// One scrollback-search hit: a run of `len` cells at absolute `(row, col)`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +struct Match { + row: usize, + col: usize, + len: usize, +} + +/// Incremental scrollback search: the query, every hit, and the focused one. +#[derive(Clone, Debug, Default)] +struct SearchState { + query: String, + matches: Vec, + current: usize, +} + /// The active screen plus cursor, scroll region, and current pen. #[derive(Debug)] pub struct Grid { @@ -190,6 +206,8 @@ pub struct Grid { mouse_encoding: MouseEncoding, /// Focus in/out reporting (DECSET 1004). focus_events: bool, + /// Active incremental scrollback search, if any. + search: Option, } fn default_tabs(cols: usize) -> Vec { @@ -239,6 +257,7 @@ impl Grid { mouse_protocol: MouseProtocol::Off, mouse_encoding: MouseEncoding::X10, focus_events: false, + search: None, } } @@ -582,6 +601,7 @@ impl Grid { } if evicted > 0 { self.shift_selection(evicted); + self.shift_search(evicted); } // Keep a scrolled-back viewport anchored to the same content. if self.view_offset > 0 { @@ -976,6 +996,135 @@ impl Grid { .collect() } + // --- scrollback search --- + + /// Set the search query and recompute matches over scrollback + the live + /// screen, focusing the most recent hit and scrolling it into view. An + /// empty query keeps search mode active but clears the hit list. + pub fn set_search(&mut self, query: &str) { + let matches = if query.is_empty() { + Vec::new() + } else { + self.compute_matches(query) + }; + let current = matches.len().saturating_sub(1); + self.search = Some(SearchState { + query: query.to_string(), + matches, + current, + }); + self.jump_to_current(); + } + + /// Move the focused match `forward` (toward newer) or back (toward older), + /// wrapping, and scroll it into view. + pub fn search_step(&mut self, forward: bool) { + if let Some(s) = self.search.as_mut() + && !s.matches.is_empty() + { + let n = s.matches.len(); + s.current = if forward { + (s.current + 1) % n + } else { + (s.current + n - 1) % n + }; + } + self.jump_to_current(); + } + + pub fn clear_search(&mut self) { + self.search = None; + } + + /// The current query, while search mode is active. + pub fn search_query(&self) -> Option<&str> { + self.search.as_ref().map(|s| s.query.as_str()) + } + + /// `(focused match index 1-based, total matches)` for the search prompt. + pub fn search_count(&self) -> (usize, usize) { + self.search.as_ref().map_or((0, 0), |s| { + let total = s.matches.len(); + (if total == 0 { 0 } else { s.current + 1 }, total) + }) + } + + /// Match spans `(lo, hi, is_current)` on absolute row `row`, for highlight. + pub fn search_spans_on(&self, row: usize) -> Vec<(usize, usize, bool)> { + let Some(s) = self.search.as_ref() else { + return Vec::new(); + }; + s.matches + .iter() + .enumerate() + .filter(|(_, m)| m.row == row && m.len > 0) + .map(|(i, m)| (m.col, m.col + m.len - 1, i == s.current)) + .collect() + } + + fn compute_matches(&self, query: &str) -> Vec { + // Smart case: an uppercase letter in the query forces case sensitivity. + let sensitive = query.chars().any(|c| c.is_ascii_uppercase()); + let fold = |c: char| { + if sensitive { c } else { c.to_ascii_lowercase() } + }; + let needle: Vec = query.chars().map(fold).collect(); + if needle.is_empty() { + return Vec::new(); + } + let total = self.scrollback.len() + self.rows; + let mut matches = Vec::new(); + for row in 0..total { + let hay: Vec = self.abs_row(row).iter().map(|cell| fold(cell.c)).collect(); + let mut i = 0; + while i + needle.len() <= hay.len() { + if hay[i..i + needle.len()] == needle[..] { + matches.push(Match { + row, + col: i, + len: needle.len(), + }); + i += needle.len(); + } else { + i += 1; + } + } + } + matches + } + + /// Scroll the viewport so the focused match is on screen, centering it only + /// when it would otherwise be off the visible range. + fn jump_to_current(&mut self) { + let Some(abs) = self + .search + .as_ref() + .and_then(|s| s.matches.get(s.current)) + .map(|m| m.row) + else { + return; + }; + let sb = self.scrollback.len(); + let top = sb - self.view_offset; + let bottom = top + self.rows - 1; + if abs < top || abs > bottom { + let target = sb as isize + self.rows as isize / 2 - abs as isize; + self.view_offset = target.clamp(0, sb as isize) as usize; + } + } + + /// Slide search hits up by `n` rows after scrollback eviction, dropping any + /// that scrolled off the top. + fn shift_search(&mut self, n: usize) { + if let Some(s) = self.search.as_mut() { + s.matches.retain(|m| m.row >= n); + for m in &mut s.matches { + m.row -= n; + } + s.current = s.current.min(s.matches.len().saturating_sub(1)); + } + } + pub fn set_bracketed_paste(&mut self, on: bool) { self.bracketed_paste = on; } @@ -1198,6 +1347,33 @@ mod tests { assert_eq!(g.selection_text().as_deref(), Some("bc\nfg\njk")); } + #[test] + fn search_finds_matches_across_history() { + let mut g = Grid::new(16, 2); + for line in ["alpha", "beta", "ALPHA", "gamma"] { + for c in line.chars() { + g.print(c); + } + g.carriage_return(); + g.line_feed(); + } + // Case-insensitive (no uppercase in query) matches both alphas. + g.set_search("alpha"); + assert_eq!(g.search_count(), (2, 2)); // focus starts on the latest hit + let spans0 = g.search_spans_on(0); + assert_eq!(spans0, vec![(0, 4, false)]); + // Smart case: an uppercase letter restricts to the exact-case hit. + g.set_search("ALPHA"); + assert_eq!(g.search_count(), (1, 1)); + assert_eq!(g.search_spans_on(2), vec![(0, 4, true)]); + // Stepping wraps and the no-match query reports zero. + g.set_search("zzz"); + assert_eq!(g.search_count(), (0, 0)); + assert_eq!(g.search_query(), Some("zzz")); + g.clear_search(); + assert_eq!(g.search_query(), None); + } + #[test] fn scroll_region_limits_line_feed() { let mut g = Grid::new(4, 4); diff --git a/src/render.rs b/src/render.rs index 552698a..e817a62 100644 --- a/src/render.rs +++ b/src/render.rs @@ -13,6 +13,11 @@ const DEFAULT_FG: Rgb = Rgb(0xc5, 0xc8, 0xc6); const DEFAULT_BG: Rgb = Rgb(0x18, 0x18, 0x18); /// Background painted behind selected cells. const SELECTION_BG: Rgb = Rgb(0x44, 0x47, 0x5a); +/// Background behind a search match, and the focused match. +const MATCH_BG: Rgb = Rgb(0x5a, 0x51, 0x2a); +const CURRENT_MATCH_BG: Rgb = Rgb(0xb8, 0x8a, 0x2a); +/// Search prompt bar drawn along the bottom row. +const SEARCH_BAR_BG: Rgb = Rgb(0x30, 0x30, 0x40); #[derive(Clone, Copy, PartialEq, Eq)] struct Rgb(u8, u8, u8); @@ -131,11 +136,20 @@ impl Renderer { // `cols` after a resize, so clamp with `take`. let abs = grid.view_to_abs(y); let cells = grid.view_row(y); + let search = grid.search_spans_on(abs); + let match_at = |x: usize| -> Option { + search + .iter() + .find(|(lo, hi, _)| x >= *lo && x <= *hi) + .map(|(_, _, current)| *current) + }; for (x, cell) in cells.iter().take(cols).enumerate() { - let bg = if grid.is_selected(abs, x) { - SELECTION_BG - } else { - cell_colors(cell).1 + // Focused match > selection > other match > the cell's own bg. + let bg = match match_at(x) { + Some(true) => CURRENT_MATCH_BG, + _ if grid.is_selected(abs, x) => SELECTION_BG, + Some(false) => MATCH_BG, + None => cell_colors(cell).1, }; if bg != DEFAULT_BG { canvas.fill_rect(x as i32 * m.width as i32, row_top, m.width, m.height, bg); @@ -163,6 +177,36 @@ impl Renderer { } } + /// Draw the incremental-search prompt across the bottom row, over whatever + /// grid content was there. The caller marks the bottom row dirty so this + /// repaints whenever the query or match count changes. + pub fn render_search_bar(&mut self, pixels: &mut [u8], dims: (usize, usize), text: &str) { + let (width, height) = dims; + let mut canvas = Canvas { + pixels, + width, + height, + }; + let m = self.fonts.metrics(); + let rows = (height / m.height as usize).max(1); + let row_top = (rows - 1) as i32 * m.height as i32; + canvas.fill_rect(0, row_top, width as u32, m.height, SEARCH_BAR_BG); + let style = Style { + bold: false, + italic: false, + }; + let mut x = 0i32; + for c in text.chars() { + if x as usize + m.width as usize > width { + break; + } + if c != ' ' { + self.draw_glyph(&mut canvas, c, style, x, row_top, DEFAULT_FG); + } + x += m.width as i32; + } + } + /// Draw the cursor: a solid block/underline/beam when focused, a hollow /// outline when not. A blinking cursor shape is only drawn while `blink_on`. fn draw_cursor( diff --git a/src/wayland.rs b/src/wayland.rs index 7a4de4e..04cbf04 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -122,6 +122,10 @@ struct RowSnap { cursor: Option<(usize, CursorShape, bool)>, /// Inclusive selected column span on this row. sel: Option<(usize, usize)>, + /// Search-match spans `(lo, hi, is_current)` highlighted on this row. + search: Vec<(usize, usize, bool)>, + /// Search-prompt text drawn over this row (only the bottom row, when active). + overlay: Option, /// Blink phase, but only varied when the row actually has blinking ink, so /// non-blinking rows stay equal across phase toggles. blink: bool, @@ -151,6 +155,8 @@ fn row_snap(grid: &Grid, y: usize, focused: bool, blink_on: bool) -> RowSnap { cells, cursor, sel: grid.selection_span_on(abs), + search: grid.search_spans_on(abs), + overlay: None, blink: if has_blink { blink_on } else { true }, } } @@ -241,6 +247,7 @@ pub fn run() -> anyhow::Result { buf_dims: (0, 0), blink_on: true, sync_timeout: None, + searching: false, focused: true, exit: false, exit_code: ExitCode::SUCCESS, @@ -359,6 +366,8 @@ struct App { blink_on: bool, /// Armed while synchronized output holds the screen, to force it open. sync_timeout: Option, + /// Whether incremental search mode is active (the query lives in the grid). + searching: bool, /// Whether the toplevel currently has keyboard focus (drives the cursor). focused: bool, exit: bool, @@ -429,6 +438,19 @@ impl App { /// viewport locally; anything else is encoded to the shell and snaps the /// viewport back to the live screen. fn handle_key(&mut self, event: &KeyEvent) { + // Ctrl+Shift+F toggles incremental search mode. + if self.modifiers.ctrl + && self.modifiers.shift + && matches!(event.keysym, Keysym::F | Keysym::f) + { + self.toggle_search(); + return; + } + // While searching, the keyboard edits the query and navigates matches. + if self.searching { + self.search_key(event); + return; + } // Ctrl+Shift+C/V copy the selection and paste the clipboard; these take // precedence over the control bytes the chord would otherwise encode. if self.modifiers.ctrl && self.modifiers.shift { @@ -475,6 +497,55 @@ impl App { } } + /// Enter or leave incremental search mode. + fn toggle_search(&mut self) { + self.searching = !self.searching; + if let Some(session) = self.session.as_mut() { + if self.searching { + session.term.grid_mut().set_search(""); + } else { + session.term.grid_mut().clear_search(); + } + } + self.needs_draw = true; + } + + /// Handle a key while search mode is active: edit the query incrementally, + /// step between matches, or exit. + fn search_key(&mut self, event: &KeyEvent) { + let Some(session) = self.session.as_mut() else { + return; + }; + let grid = session.term.grid_mut(); + match event.keysym { + Keysym::Escape => { + grid.clear_search(); + self.searching = false; + } + Keysym::Return | Keysym::KP_Enter | Keysym::Up | Keysym::Page_Up => { + grid.search_step(false); + } + Keysym::Down | Keysym::Page_Down => grid.search_step(true), + Keysym::BackSpace => { + let mut query = grid.search_query().unwrap_or("").to_string(); + query.pop(); + grid.set_search(&query); + } + _ => { + // Append typed text, ignoring control characters. + if let Some(text) = event.utf8.as_ref() { + let printable: String = text.chars().filter(|c| !c.is_control()).collect(); + if !printable.is_empty() { + let mut query = grid.search_query().unwrap_or("").to_string(); + query.push_str(&printable); + grid.set_search(&query); + } + } + } + } + self.needs_draw = true; + } + /// Map window pixel coordinates to an absolute `(row, col)` grid point. fn cell_at(&self, px: f64, py: f64) -> Option<(usize, usize)> { let session = self.session.as_ref()?; @@ -974,10 +1045,25 @@ impl App { }; let grid = session.term.grid(); let rows = grid.rows(); - let cur: Vec = (0..rows) + let mut cur: Vec = (0..rows) .map(|y| row_snap(grid, y, focused, blink_on)) .collect(); + // The search prompt occupies the bottom row while search mode is active. + // Recording it in the snapshot keeps the row's damage/diff correct. + let bar_text = self.searching.then(|| { + let (n, total) = grid.search_count(); + format!( + "search: {} [{n}/{total}]", + grid.search_query().unwrap_or("") + ) + }); + if let Some(text) = &bar_text + && rows > 0 + { + cur[rows - 1].overlay = Some(text.clone()); + } + // Reuse a buffer the compositor has released, else grow the ring. let stride = w as i32 * 4; let mut idx = None; @@ -1032,6 +1118,12 @@ impl App { self.renderer .render_row(canvas, dims, grid, y, focused, blink_on); } + // Draw the search prompt over the (now repainted) bottom row. + if let Some(text) = &bar_text + && dirty.contains(&(rows - 1)) + { + self.renderer.render_search_bar(canvas, dims, text); + } self.frames[idx].rows = cur; let surface = self.window.wl_surface();