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,
|
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.
|
/// The active screen plus cursor, scroll region, and current pen.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Grid {
|
pub struct Grid {
|
||||||
|
|
@ -190,6 +206,8 @@ pub struct Grid {
|
||||||
mouse_encoding: MouseEncoding,
|
mouse_encoding: MouseEncoding,
|
||||||
/// Focus in/out reporting (DECSET 1004).
|
/// Focus in/out reporting (DECSET 1004).
|
||||||
focus_events: bool,
|
focus_events: bool,
|
||||||
|
/// Active incremental scrollback search, if any.
|
||||||
|
search: Option<SearchState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_tabs(cols: usize) -> Vec<bool> {
|
fn default_tabs(cols: usize) -> Vec<bool> {
|
||||||
|
|
@ -239,6 +257,7 @@ impl Grid {
|
||||||
mouse_protocol: MouseProtocol::Off,
|
mouse_protocol: MouseProtocol::Off,
|
||||||
mouse_encoding: MouseEncoding::X10,
|
mouse_encoding: MouseEncoding::X10,
|
||||||
focus_events: false,
|
focus_events: false,
|
||||||
|
search: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -582,6 +601,7 @@ impl Grid {
|
||||||
}
|
}
|
||||||
if evicted > 0 {
|
if evicted > 0 {
|
||||||
self.shift_selection(evicted);
|
self.shift_selection(evicted);
|
||||||
|
self.shift_search(evicted);
|
||||||
}
|
}
|
||||||
// Keep a scrolled-back viewport anchored to the same content.
|
// Keep a scrolled-back viewport anchored to the same content.
|
||||||
if self.view_offset > 0 {
|
if self.view_offset > 0 {
|
||||||
|
|
@ -976,6 +996,135 @@ impl Grid {
|
||||||
.collect()
|
.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) {
|
pub fn set_bracketed_paste(&mut self, on: bool) {
|
||||||
self.bracketed_paste = on;
|
self.bracketed_paste = on;
|
||||||
}
|
}
|
||||||
|
|
@ -1198,6 +1347,33 @@ mod tests {
|
||||||
assert_eq!(g.selection_text().as_deref(), Some("bc\nfg\njk"));
|
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]
|
#[test]
|
||||||
fn scroll_region_limits_line_feed() {
|
fn scroll_region_limits_line_feed() {
|
||||||
let mut g = Grid::new(4, 4);
|
let mut g = Grid::new(4, 4);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ const DEFAULT_FG: Rgb = Rgb(0xc5, 0xc8, 0xc6);
|
||||||
const DEFAULT_BG: Rgb = Rgb(0x18, 0x18, 0x18);
|
const DEFAULT_BG: Rgb = Rgb(0x18, 0x18, 0x18);
|
||||||
/// Background painted behind selected cells.
|
/// Background painted behind selected cells.
|
||||||
const SELECTION_BG: Rgb = Rgb(0x44, 0x47, 0x5a);
|
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)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
struct Rgb(u8, u8, u8);
|
struct Rgb(u8, u8, u8);
|
||||||
|
|
@ -131,11 +136,20 @@ impl Renderer {
|
||||||
// `cols` after a resize, so clamp with `take`.
|
// `cols` after a resize, so clamp with `take`.
|
||||||
let abs = grid.view_to_abs(y);
|
let abs = grid.view_to_abs(y);
|
||||||
let cells = grid.view_row(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() {
|
for (x, cell) in cells.iter().take(cols).enumerate() {
|
||||||
let bg = if grid.is_selected(abs, x) {
|
// Focused match > selection > other match > the cell's own bg.
|
||||||
SELECTION_BG
|
let bg = match match_at(x) {
|
||||||
} else {
|
Some(true) => CURRENT_MATCH_BG,
|
||||||
cell_colors(cell).1
|
_ if grid.is_selected(abs, x) => SELECTION_BG,
|
||||||
|
Some(false) => MATCH_BG,
|
||||||
|
None => cell_colors(cell).1,
|
||||||
};
|
};
|
||||||
if bg != DEFAULT_BG {
|
if bg != DEFAULT_BG {
|
||||||
canvas.fill_rect(x as i32 * m.width as i32, row_top, m.width, m.height, 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
|
/// 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`.
|
/// outline when not. A blinking cursor shape is only drawn while `blink_on`.
|
||||||
fn draw_cursor(
|
fn draw_cursor(
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,10 @@ struct RowSnap {
|
||||||
cursor: Option<(usize, CursorShape, bool)>,
|
cursor: Option<(usize, CursorShape, bool)>,
|
||||||
/// Inclusive selected column span on this row.
|
/// Inclusive selected column span on this row.
|
||||||
sel: Option<(usize, usize)>,
|
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
|
/// Blink phase, but only varied when the row actually has blinking ink, so
|
||||||
/// non-blinking rows stay equal across phase toggles.
|
/// non-blinking rows stay equal across phase toggles.
|
||||||
blink: bool,
|
blink: bool,
|
||||||
|
|
@ -151,6 +155,8 @@ fn row_snap(grid: &Grid, y: usize, focused: bool, blink_on: bool) -> RowSnap {
|
||||||
cells,
|
cells,
|
||||||
cursor,
|
cursor,
|
||||||
sel: grid.selection_span_on(abs),
|
sel: grid.selection_span_on(abs),
|
||||||
|
search: grid.search_spans_on(abs),
|
||||||
|
overlay: None,
|
||||||
blink: if has_blink { blink_on } else { true },
|
blink: if has_blink { blink_on } else { true },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -241,6 +247,7 @@ pub fn run() -> anyhow::Result<ExitCode> {
|
||||||
buf_dims: (0, 0),
|
buf_dims: (0, 0),
|
||||||
blink_on: true,
|
blink_on: true,
|
||||||
sync_timeout: None,
|
sync_timeout: None,
|
||||||
|
searching: false,
|
||||||
focused: true,
|
focused: true,
|
||||||
exit: false,
|
exit: false,
|
||||||
exit_code: ExitCode::SUCCESS,
|
exit_code: ExitCode::SUCCESS,
|
||||||
|
|
@ -359,6 +366,8 @@ struct App {
|
||||||
blink_on: bool,
|
blink_on: bool,
|
||||||
/// Armed while synchronized output holds the screen, to force it open.
|
/// Armed while synchronized output holds the screen, to force it open.
|
||||||
sync_timeout: Option<RegistrationToken>,
|
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).
|
/// Whether the toplevel currently has keyboard focus (drives the cursor).
|
||||||
focused: bool,
|
focused: bool,
|
||||||
exit: bool,
|
exit: bool,
|
||||||
|
|
@ -429,6 +438,19 @@ impl App {
|
||||||
/// viewport locally; anything else is encoded to the shell and snaps the
|
/// viewport locally; anything else is encoded to the shell and snaps the
|
||||||
/// viewport back to the live screen.
|
/// viewport back to the live screen.
|
||||||
fn handle_key(&mut self, event: &KeyEvent) {
|
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
|
// Ctrl+Shift+C/V copy the selection and paste the clipboard; these take
|
||||||
// precedence over the control bytes the chord would otherwise encode.
|
// precedence over the control bytes the chord would otherwise encode.
|
||||||
if self.modifiers.ctrl && self.modifiers.shift {
|
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.
|
/// Map window pixel coordinates to an absolute `(row, col)` grid point.
|
||||||
fn cell_at(&self, px: f64, py: f64) -> Option<(usize, usize)> {
|
fn cell_at(&self, px: f64, py: f64) -> Option<(usize, usize)> {
|
||||||
let session = self.session.as_ref()?;
|
let session = self.session.as_ref()?;
|
||||||
|
|
@ -974,10 +1045,25 @@ impl App {
|
||||||
};
|
};
|
||||||
let grid = session.term.grid();
|
let grid = session.term.grid();
|
||||||
let rows = grid.rows();
|
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))
|
.map(|y| row_snap(grid, y, focused, blink_on))
|
||||||
.collect();
|
.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.
|
// Reuse a buffer the compositor has released, else grow the ring.
|
||||||
let stride = w as i32 * 4;
|
let stride = w as i32 * 4;
|
||||||
let mut idx = None;
|
let mut idx = None;
|
||||||
|
|
@ -1032,6 +1118,12 @@ impl App {
|
||||||
self.renderer
|
self.renderer
|
||||||
.render_row(canvas, dims, grid, y, focused, blink_on);
|
.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;
|
self.frames[idx].rows = cur;
|
||||||
|
|
||||||
let surface = self.window.wl_surface();
|
let surface = self.window.wl_surface();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue