search: incremental scrollback search with match highlight and prompt

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I0a0450eba48d308763db297f105565346a6a6964
This commit is contained in:
raf 2026-06-25 09:46:24 +03:00
commit 6f1d4dd7f9
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
3 changed files with 317 additions and 5 deletions

View file

@ -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<Match>,
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<SearchState>,
}
fn default_tabs(cols: usize) -> Vec<bool> {
@ -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<Match> {
// 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<char> = 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<char> = 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);

View file

@ -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<bool> {
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(

View file

@ -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<String>,
/// 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<ExitCode> {
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<RegistrationToken>,
/// 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<RowSnap> = (0..rows)
let mut cur: Vec<RowSnap> = (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();