brewery
notashelf /
6f1d4dd7f9ddb8d47e6acc8386000307cbabb2c5

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit 6f1d4dd7f9dd

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 07:21 UTC

unverified3 files changed+317-5
search: incremental scrollback search with match highlight and prompt

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I0a0450eba48d308763db297f105565346a6a6964
diff --git a/src/grid.rs b/src/grid.rsindex b208fe8..3a36a76 100644--- a/src/grid.rs+++ b/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;     }@@ -1199,6 +1348,33 @@ mod tests {     }      #[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);         g.set_scroll_region(1, 2);diff --git a/src/render.rs b/src/render.rsindex 552698a..e817a62 100644--- a/src/render.rs+++ b/src/render.rs@@ -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(diff --git a/src/wayland.rs b/src/wayland.rsindex 7a4de4e..04cbf04 100644--- a/src/wayland.rs+++ b/src/wayland.rs@@ -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();