clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit 6f1d4dd7f9dd
tarballunverified3 files changed+317-5
@@ -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);@@ -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(@@ -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();