forked from NotAShelf/beer
search: incremental scrollback search with match highlight and prompt
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I0a0450eba48d308763db297f105565346a6a6964
This commit is contained in:
parent
28a49c5bbe
commit
6f1d4dd7f9
3 changed files with 317 additions and 5 deletions
176
src/grid.rs
176
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<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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue