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

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