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