From d60a1dad40f45330c78f6b8f98fbb0ef0468300a Mon Sep 17 00:00:00 2001 From: altsem Date: Sat, 8 Aug 2026 17:33:30 +0200 Subject: [PATCH] feat: search via `/`, `n` and `N`, backwards search (vim '?') still bound to help menu by default --- src/config.rs | 14 +- src/default_config.toml | 6 + src/error.rs | 6 + src/lib.rs | 1 + src/ops/mod.rs | 10 + src/ops/search.rs | 86 ++++++++ src/screen/mod.rs | 170 +++++++++++++++ src/search.rs | 51 +++++ src/tests/helpers/ui.rs | 29 ++- src/tests/mod.rs | 1 + src/tests/search.rs | 170 +++++++++++++++ .../snapshots/gitu__tests__help_menu.snap | 8 +- ...h__a_cleared_search_is_not_repeatable.snap | 25 +++ ...d_by_an_empty_query_is_not_repeatable.snap | 25 +++ .../gitu__tests__search__search_aborted.snap | 25 +++ ...u__tests__search__search_context_line.snap | 25 +++ .../gitu__tests__search__search_forward.snap | 25 +++ ...gitu__tests__search__search_hunk_line.snap | 25 +++ ...sts__search__search_next_and_previous.snap | 25 +++ .../gitu__tests__search__search_prompt.snap | 25 +++ ...arch_repeat_without_a_previous_search.snap | 25 +++ ...__tests__search__search_without_match.snap | 25 +++ src/ui.rs | 206 +++++++++++++++++- src/ui/layout/mod.rs | 105 ++++++++- src/ui/layout/node.rs | 11 + 25 files changed, 1112 insertions(+), 12 deletions(-) create mode 100644 src/ops/search.rs create mode 100644 src/search.rs create mode 100644 src/tests/search.rs create mode 100644 src/tests/snapshots/gitu__tests__search__a_cleared_search_is_not_repeatable.snap create mode 100644 src/tests/snapshots/gitu__tests__search__a_search_cleared_by_an_empty_query_is_not_repeatable.snap create mode 100644 src/tests/snapshots/gitu__tests__search__search_aborted.snap create mode 100644 src/tests/snapshots/gitu__tests__search__search_context_line.snap create mode 100644 src/tests/snapshots/gitu__tests__search__search_forward.snap create mode 100644 src/tests/snapshots/gitu__tests__search__search_hunk_line.snap create mode 100644 src/tests/snapshots/gitu__tests__search__search_next_and_previous.snap create mode 100644 src/tests/snapshots/gitu__tests__search__search_prompt.snap create mode 100644 src/tests/snapshots/gitu__tests__search__search_repeat_without_a_previous_search.snap create mode 100644 src/tests/snapshots/gitu__tests__search__search_without_match.snap diff --git a/src/config.rs b/src/config.rs index a9d9dcdda1..ed4bd78566 100644 --- a/src/config.rs +++ b/src/config.rs @@ -109,6 +109,9 @@ pub struct StyleConfig { pub selection_line: StyleConfigEntry, pub selection_area: StyleConfigEntry, + #[serde(default)] + pub search_match: StyleConfigEntry, + pub hash: StyleConfigEntry, pub branch: StyleConfigEntry, pub remote: StyleConfigEntry, @@ -353,11 +356,14 @@ pub fn config_path() -> PathBuf { .join("gitu/config.toml") } +#[cfg(test)] +pub(crate) const TEST_SEARCH_HIGHLIGHT_BG: Color = Color::LightYellow; + #[cfg(test)] pub(crate) fn init_test_config() -> Res { let FigmentConfig { mut general, - style, + mut style, bindings: bindings_config, } = Figment::new() .merge(Toml::string(DEFAULT_CONFIG)) @@ -368,6 +374,12 @@ pub(crate) fn init_test_config() -> Res { general.always_show_help.enabled = false; general.refresh_on_file_change.enabled = false; + style.search_match = StyleConfigEntry { + fg: None, + bg: Some(TEST_SEARCH_HIGHLIGHT_BG), + mods: None, + }; + Ok(Config { general, style, diff --git a/src/default_config.toml b/src/default_config.toml index d95974629d..6088ab2bbc 100644 --- a/src/default_config.toml +++ b/src/default_config.toml @@ -89,6 +89,8 @@ picker.info = { mods = "DIM" } picker.selection_line = { mods = "BOLD" } picker.matched = { fg = "yellow", mods = "BOLD" } +search_match = { mods = "REVERSED" } + cursor = { symbol = "▌", fg = "blue" } selection_bar = { symbol = "▌", fg = "blue", mods = "DIM" } selection_line = { mods = "BOLD" } @@ -123,6 +125,10 @@ root.move_top = ["g+g"] root.move_bottom = ["G"] root.scroll_view_up = ["ctrl+y"] root.scroll_view_down = ["ctrl+e"] +root.search = ["/"] +root.search_backward = [] +root.search_next = ["n"] +root.search_previous = ["N"] root.show_refs = ["Y"] root.show = ["enter"] root.discard = ["K"] diff --git a/src/error.rs b/src/error.rs index 890d984cae..0f674847a2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -59,6 +59,9 @@ pub enum Error { BaseCommitOid, UpstreamCommitOid, GitBlame(io::Error), + NoSearchMatch(String), + NoPreviousSearch, + InvalidSearchRegex(regex::Error), } impl std::error::Error for Error {} @@ -166,6 +169,9 @@ impl Display for Error { f.write_str("Could not resolve OID of upstream branch commit") } Error::GitBlame(e) => f.write_fmt(format_args!("Git blame error: {e}")), + Error::NoSearchMatch(query) => f.write_fmt(format_args!("No match: {query}")), + Error::NoPreviousSearch => f.write_str("No previous search"), + Error::InvalidSearchRegex(e) => f.write_fmt(format_args!("Invalid search: {e}")), } } } diff --git a/src/lib.rs b/src/lib.rs index 33d063716c..9c582a05ee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ mod ops; pub mod picker; mod prompt; mod screen; +mod search; pub mod style; mod syntax_parser; pub mod term; diff --git a/src/ops/mod.rs b/src/ops/mod.rs index 37fedd322a..3fe8e652c1 100644 --- a/src/ops/mod.rs +++ b/src/ops/mod.rs @@ -27,6 +27,7 @@ pub(crate) mod remote; pub(crate) mod reset; pub(crate) mod reverse; pub(crate) mod revert; +pub(crate) mod search; pub(crate) mod show; pub(crate) mod show_refs; pub(crate) mod stage; @@ -128,6 +129,11 @@ pub(crate) enum Op { ScrollViewUp, ScrollViewDown, + Search, + SearchBackward, + SearchNext, + SearchPrevious, + Refresh, Quit, @@ -161,6 +167,10 @@ impl Op { Op::MoveBottom => Box::new(editor::MoveBottom), Op::ScrollViewUp => Box::new(editor::ScrollViewUp), Op::ScrollViewDown => Box::new(editor::ScrollViewDown), + Op::Search => Box::new(search::Search), + Op::SearchBackward => Box::new(search::SearchBackward), + Op::SearchNext => Box::new(search::SearchNext), + Op::SearchPrevious => Box::new(search::SearchPrevious), Op::Checkout => Box::new(branch::Checkout), Op::CheckoutNewBranch => Box::new(branch::CheckoutNewBranch), Op::Spinoff => Box::new(branch::Spinoff), diff --git a/src/ops/search.rs b/src/ops/search.rs new file mode 100644 index 0000000000..3710138293 --- /dev/null +++ b/src/ops/search.rs @@ -0,0 +1,86 @@ +use super::{Action, OpTrait}; +use crate::{ + Res, + app::{App, PromptParams, State}, + error::Error, + item_data::ItemData, + screen::SearchDirection, + term::Term, +}; +use std::rc::Rc; + +pub(crate) struct Search; +impl OpTrait for Search { + fn get_action(&self, _target: &ItemData) -> Option { + Some(Rc::new(|app, term| { + prompt_search(app, term, SearchDirection::Forward) + })) + } + + fn display(&self, _state: &State) -> String { + "Search".into() + } +} + +pub(crate) struct SearchBackward; +impl OpTrait for SearchBackward { + fn get_action(&self, _target: &ItemData) -> Option { + Some(Rc::new(|app, term| { + prompt_search(app, term, SearchDirection::Backward) + })) + } + + fn display(&self, _state: &State) -> String { + "Search backward".into() + } +} + +pub(crate) struct SearchNext; +impl OpTrait for SearchNext { + fn get_action(&self, _target: &ItemData) -> Option { + Some(Rc::new(|app, _term| app.screen_mut().search_repeat(false))) + } + + fn display(&self, _state: &State) -> String { + "Next match".into() + } +} + +pub(crate) struct SearchPrevious; +impl OpTrait for SearchPrevious { + fn get_action(&self, _target: &ItemData) -> Option { + Some(Rc::new(|app, _term| app.screen_mut().search_repeat(true))) + } + + fn display(&self, _state: &State) -> String { + "Previous match".into() + } +} + +fn prompt_search(app: &mut App, term: &mut Term, direction: SearchDirection) -> Res<()> { + let prompt = match direction { + SearchDirection::Forward => "Search", + SearchDirection::Backward => "Search backward", + }; + + let query = match app.prompt( + term, + &PromptParams { + prompt, + ..Default::default() + }, + ) { + Ok(query) if query.is_empty() => { + app.screen_mut().clear_search(); + return Ok(()); + } + Ok(query) => query, + Err(Error::PromptAborted) => { + app.screen_mut().clear_search(); + return Ok(()); + } + Err(err) => return Err(err), + }; + + app.screen_mut().search(&query, direction) +} diff --git a/src/screen/mod.rs b/src/screen/mod.rs index de730a8783..b3186c6a2a 100644 --- a/src/screen/mod.rs +++ b/src/screen/mod.rs @@ -1,4 +1,6 @@ use crate::config::StyleConfig; +use crate::error::Error; +use crate::search::RunText; use crate::style::Style; use crate::ui::layout::{LayoutTree, opts}; use crate::ui::{UiTree, layout_span}; @@ -7,6 +9,7 @@ use crate::{item_data::ItemData, ui}; use crate::{Res, config::Config, items::hash}; use super::Item; +use regex::{Regex, RegexBuilder}; use std::borrow::Cow; use std::cell::RefCell; use std::collections::HashSet; @@ -35,6 +38,52 @@ struct Scroll { offset: usize, } +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub(crate) enum SearchDirection { + Forward, + Backward, +} + +impl SearchDirection { + fn reverse(self) -> Self { + match self { + SearchDirection::Forward => SearchDirection::Backward, + SearchDirection::Backward => SearchDirection::Forward, + } + } +} + +/// The search to repeat, as `n` and `N` do in vim. +struct Search { + query: String, + matcher: Regex, + direction: SearchDirection, +} + +/// Compiles `query` as a regex, ignoring case unless the query has uppercase in +/// it. +pub(crate) fn matcher(query: &str) -> Res { + RegexBuilder::new(query) + .case_insensitive(!has_uppercase(query)) + .build() + .map_err(Error::InvalidSearchRegex) +} + +/// Whether the query asks for a case of its own. Skips e.g. `\W`, `\S`. +fn has_uppercase(query: &str) -> bool { + let mut chars = query.chars(); + + while let Some(char) = chars.next() { + match char { + '\\' => _ = chars.next(), + _ if char.is_uppercase() => return true, + _ => (), + } + } + + false +} + pub(crate) struct Screen { pub(crate) size: (u16, u16), cursor: usize, @@ -45,6 +94,7 @@ pub(crate) struct Screen { /// Memoized `item_height`, indexed like `items`. Dropped by `invalidate`. item_heights: RefCell>>, collapsed: HashSet, + search: Option, } impl Screen { @@ -70,6 +120,7 @@ impl Screen { items: vec![], item_heights: RefCell::new(vec![]), collapsed, + search: None, }; screen.items = (screen.refresh_items)()?; @@ -693,6 +744,125 @@ impl Screen { self.clamp_scroll(); } + /// Moves the cursor to the nearest item whose text `query` matches, looking + /// in `direction` and wrapping around the ends. `query` is a regex, matched + /// ignoring case unless it has uppercase in it. + pub(crate) fn search(&mut self, query: &str, direction: SearchDirection) -> Res<()> { + debug_assert!(!query.is_empty()); + + let matcher = matcher(query)?; + let found = self.move_to_match(&matcher, direction); + + // Remembered even when nothing matched, so that `n` retries it. + self.search = Some(Search { + query: query.to_string(), + matcher, + direction, + }); + + if found { + Ok(()) + } else { + Err(Error::NoSearchMatch(query.to_string())) + } + } + + /// Repeats the last search. `reverse` flips its direction, as `N` does in vim. + pub(crate) fn search_repeat(&mut self, reverse: bool) -> Res<()> { + let Some(search) = &self.search else { + return Err(Error::NoPreviousSearch); + }; + + let query = search.query.clone(); + let matcher = search.matcher.clone(); + let direction = if reverse { + search.direction.reverse() + } else { + search.direction + }; + + if self.move_to_match(&matcher, direction) { + Ok(()) + } else { + Err(Error::NoSearchMatch(query)) + } + } + + /// Whether a match was found, in which case the cursor is on it. + fn move_to_match(&mut self, matcher: &Regex, direction: SearchDirection) -> bool { + let Some(item_i) = self.find_match(matcher, direction) else { + return false; + }; + + self.reveal(item_i); + self.cursor = item_i; + self.scroll_to_cursor(); + true + } + + fn reveal(&mut self, item_i: usize) { + while let Some(section) = self.hidden_by(item_i, 0) { + self.collapsed.remove(&self.items[section].id); + + // Only the opened section's own height changes, by losing its `…`. + self.item_heights.get_mut()[section] = None; + } + + self.clamp_scroll(); + } + + /// The nearest match from the cursor, wrapping around the ends. + fn find_match(&self, matcher: &Regex, direction: SearchDirection) -> Option { + match direction { + SearchDirection::Forward => self + .matching_items(matcher) + .find(|&item_i| item_i > self.cursor) + .or_else(|| self.matching_items(matcher).next()), + SearchDirection::Backward => self + .matching_items(matcher) + .take_while(|&item_i| item_i < self.cursor) + .last() + .or_else(|| self.matching_items(matcher).last()), + } + } + + /// The items `matcher` finds something in. + fn matching_items<'a>(&'a self, matcher: &'a Regex) -> impl Iterator + 'a { + (0..self.items.len()).filter(move |&item_i| self.item_matches(item_i, matcher)) + } + + fn item_matches(&self, item_i: usize, matcher: &Regex) -> bool { + let mut layout = UiTree::new(); + let view = ItemView { + item_index: item_i, + highlighted: false, + }; + layout_item(&mut layout, self, false, view); + + let mut run_text = RunText::default(); + + layout + .leaf_runs() + .any(|run| matcher.is_match(run_text.read(run.map(|(leaf, span)| (leaf, span.text()))))) + } + + pub(crate) fn clear_search(&mut self) { + self.search = None; + } + + pub(crate) fn get_search_matcher(&self) -> Option<&Regex> { + self.search.as_ref().map(|search| &search.matcher) + } + + fn scroll_to_cursor(&mut self) { + if self.is_cursor_off_screen() { + self.center_on_cursor(); + } + + self.scroll_fit_end(); + self.scroll_fit_start(); + } + fn find_item bool>(&self, predicate: P) -> Option { self.visible_items() .find(|&item_i| predicate(&self.items[item_i])) diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 0000000000..2606381787 --- /dev/null +++ b/src/search.rs @@ -0,0 +1,51 @@ +use std::ops::Range; + +#[derive(Default)] +pub(crate) struct RunText { + pub(crate) buf: String, + /// Where each span of the run starts in `buf`, and the leaf it came from. + pub(crate) spans: Vec<(usize, usize)>, +} + +impl RunText { + /// Reads a run, as the text of each span and the leaf it came from. + pub(crate) fn read<'a>(&mut self, run: impl Iterator) -> &str { + self.buf.clear(); + self.spans.clear(); + + for (leaf, text) in run { + self.spans.push((self.buf.len(), leaf)); + self.buf.push_str(text); + } + + &self.buf + } + + /// Cuts `matched` up along the spans it covers, as each is painted on its + /// own and knows only its own text. + pub(crate) fn per_leaf( + &self, + matched: Range, + ) -> impl Iterator)> { + let from = self + .spans + .partition_point(|&(start, _)| start <= matched.start) + .saturating_sub(1); + + self.spans[from..] + .iter() + .enumerate() + .take_while(move |&(_, &(start, _))| start < matched.end) + .map(move |(i, &(start, leaf))| { + let end = match self.spans.get(from + i + 1) { + Some(&(next, _)) => next, + None => self.buf.len(), + }; + + ( + leaf, + matched.start.max(start) - start..matched.end.min(end) - start, + ) + }) + } +} diff --git a/src/tests/helpers/ui.rs b/src/tests/helpers/ui.rs index 402039040f..d837858b1c 100644 --- a/src/tests/helpers/ui.rs +++ b/src/tests/helpers/ui.rs @@ -1,7 +1,7 @@ use crate::{ app::App, cli::Args, - config::{self, Config}, + config::{self, Config, TEST_SEARCH_HIGHLIGHT_BG}, error::Error, key_parser::parse_test_keys, term::{Term, TermBackend, TestBuffer}, @@ -105,6 +105,33 @@ impl TestContext { assert!(app.state.quit || matches!(result, Err(Error::NoMoreEvents))); } + /// The text of every search match marked on screen, in reading order. + pub fn highlighted(&self) -> Vec { + let TermBackend::Test { buffer, .. } = &self.term else { + unreachable!(); + }; + + let mut marked: Vec = vec![]; + let mut last = None; + + for (i, cell) in buffer.cells.iter().enumerate() { + if cell.bg != TEST_SEARCH_HIGHLIGHT_BG { + continue; + } + + match last { + Some(before) if before + 1 == i && i % buffer.width as usize != 0 => { + marked.last_mut().unwrap().push_str(&cell.symbol) + } + _ => marked.push(cell.symbol.clone()), + } + + last = Some(i); + } + + marked + } + pub fn redact_buffer(&self) -> String { let TermBackend::Test { buffer, .. } = &self.term else { unreachable!(); diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 92f333f1ba..db04cda07d 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -31,6 +31,7 @@ mod rebase; mod remote; mod reset; mod reverse; +mod search; mod stage; mod stash; mod unstage; diff --git a/src/tests/search.rs b/src/tests/search.rs new file mode 100644 index 0000000000..fecb37c99a --- /dev/null +++ b/src/tests/search.rs @@ -0,0 +1,170 @@ +use super::*; + +fn setup(ctx: TestContext) -> TestContext { + commit(&ctx.dir, "firstfile", ""); + commit(&ctx.dir, "secondfile", ""); + commit(&ctx.dir, "thirdfile", ""); + ctx +} + +#[test] +fn search_prompt() { + snapshot!(setup(setup_clone!()), "/"); +} + +#[test] +fn search_forward() { + snapshot!(setup(setup_clone!()), "/second"); +} + +#[test] +fn search_next_and_previous() { + snapshot!(setup(setup_clone!()), "/filennN"); +} + +#[test] +fn search_aborted() { + snapshot!(setup(setup_clone!()), "/second"); +} + +#[test] +fn search_without_match() { + snapshot!(setup(setup_clone!()), "/nonesuch"); +} + +#[test] +fn search_repeat_without_a_previous_search() { + snapshot!(setup(setup_clone!()), "n"); +} + +fn marked(ctx: TestContext, input: &str) -> Vec { + let mut ctx = setup(ctx); + let mut app = ctx.init_app(); + ctx.update(&mut app, keys(input)); + ctx.highlighted() +} + +#[test] +fn every_match_on_screen_is_marked() { + assert_eq!( + vec!["file", "file", "file", "file"], + marked(setup_clone!(), "/file") + ); +} + +/// The cursor is left on one match, but every one of them is marked. +#[test] +fn repeating_a_search_leaves_the_marks_alone() { + assert_eq!( + vec!["file", "file", "file", "file"], + marked(setup_clone!(), "/filenn") + ); +} + +/// A query written in lowercase says nothing about case, so it matches either. +#[test] +fn a_lowercase_query_matches_whatever_the_case() { + assert_eq!( + vec!["Author", "Author", "Author", "Author"], + marked(setup_clone!(), "/author") + ); +} + +/// Uppercase in the query is taken to be meant, as vim's `smartcase` does. +#[test] +fn an_uppercase_query_matches_that_case_only() { + assert!(marked(setup_clone!(), "/Add").is_empty()); + assert_eq!( + vec!["add", "add", "add", "add"], + marked(setup_clone!(), "/add") + ); +} + +/// The branch and the summary are separate spans of a commit row, and both +/// the checked out branch and the remote's row read as `main add`. +#[test] +fn a_match_running_from_one_span_into_the_next_is_marked_whole() { + assert_eq!( + vec!["main add", "main add"], + marked(setup_clone!(), "/main add") + ); +} + +/// The author sits outside the group holding the summary, so the two are never +/// one match, however adjacent they end up looking. +#[test] +fn text_either_side_of_a_layout_break_is_not_one_match() { + assert!(marked(setup_clone!(), "/thirdfileAuthor").is_empty()); +} + +/// Nothing matched, and the query the command log echoes back is no match of +/// its own. +#[test] +fn nothing_is_marked_without_a_match() { + assert!(marked(setup_clone!(), "/nonesuch").is_empty()); +} + +#[test] +fn aborting_the_prompt_clears_the_marks() { + assert!(marked(setup_clone!(), "/file/").is_empty()); +} + +/// An empty query has nothing to search for, so it clears instead. +#[test] +fn an_empty_query_clears_the_marks() { + assert!(marked(setup_clone!(), "/file/").is_empty()); +} + +/// Cleared means forgotten, so there is nothing left for `n` to repeat. +#[test] +fn a_cleared_search_is_not_repeatable() { + snapshot!(setup(setup_clone!()), "/file/n"); +} + +/// And the same, whichever way it was cleared. +#[test] +fn a_search_cleared_by_an_empty_query_is_not_repeatable() { + snapshot!(setup(setup_clone!()), "/file/n"); +} + +/// The commit menu draws the selected item as a row of its own, which is not +/// what was searched and so is left unmarked. +#[test] +fn only_the_items_on_screen_are_marked() { + assert_eq!( + vec!["file", "file", "file", "file"], + marked(setup_clone!(), "/filec") + ); +} + +/// A hunk line is searchable, even when collapsed. +#[test] +fn search_hunk_line() { + let ctx = setup_clone!(); + commit(&ctx.dir, "testfile", "one\ntwo\nthree\n"); + std::fs::write(ctx.dir.join("testfile"), "one\ntwo\nfour\n").unwrap(); + snapshot!(ctx, "/four"); +} + +/// A diff's context lines are unselectable, and are most of what a diff has +/// written on it. Search reaches them, so that what is marked can be moved to. +#[test] +fn search_context_line() { + let ctx = setup_clone!(); + commit(&ctx.dir, "testfile", "one\ntwo\nthree\n"); + std::fs::write(ctx.dir.join("testfile"), "one\ntwo\nfour\n").unwrap(); + snapshot!(ctx, "jj/two"); +} + +#[test] +fn a_context_line_is_marked() { + let ctx = setup_clone!(); + commit(&ctx.dir, "testfile", "one\ntwo\nthree\n"); + std::fs::write(ctx.dir.join("testfile"), "one\ntwo\nfour\n").unwrap(); + + let mut ctx = ctx; + let mut app = ctx.init_app(); + ctx.update(&mut app, keys("jj/two")); + + assert_eq!(vec!["two"], ctx.highlighted()); +} diff --git a/src/tests/snapshots/gitu__tests__help_menu.snap b/src/tests/snapshots/gitu__tests__help_menu.snap index 9e0aa1afd8..9eb76e3197 100644 --- a/src/tests/snapshots/gitu__tests__help_menu.snap +++ b/src/tests/snapshots/gitu__tests__help_menu.snap @@ -2,8 +2,6 @@ source: src/tests/mod.rs expression: ctx.redact_buffer() --- -▌On branch main | -▌Your branch is up to date with 'origin/main'. | ────────────────────────────────────────────────────────────────────────────────| Help Submenu On branch main | Y Show Refs b Branch tab Fold | @@ -20,6 +18,8 @@ expression: ctx.redact_buffer() ctrl+d Scroll half page down V Revert | ctrl+y Scroll view up A Cherry-pick | ctrl+e Scroll view down z Stash | + / Search | + n Next match | + N Previous match | g+r Refresh | - q/esc Quit/Close | -styles_hash: eacaf294c6f7ca0a +styles_hash: 8e4b0994d480981d diff --git a/src/tests/snapshots/gitu__tests__search__a_cleared_search_is_not_repeatable.snap b/src/tests/snapshots/gitu__tests__search__a_cleared_search_is_not_repeatable.snap new file mode 100644 index 0000000000..a24535daf3 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__search__a_cleared_search_is_not_repeatable.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/search.rs +expression: ctx.redact_buffer() +--- + On branch main | + Your branch is ahead of 'origin/main' by 3 commit(s). | + | + Recent commits | +▌1e81efc main add thirdfile Author Name __ | + eb81c40 add secondfile Author Name __ | + 4c54307 add firstfile Author Name __ | + b66a0bf origin/main add initial-file Author Name __ | + | + | + | + | + | + | + | + | + | + | +────────────────────────────────────────────────────────────────────────────────| +! No previous search | +styles_hash: 485fe0f8bf08cacc diff --git a/src/tests/snapshots/gitu__tests__search__a_search_cleared_by_an_empty_query_is_not_repeatable.snap b/src/tests/snapshots/gitu__tests__search__a_search_cleared_by_an_empty_query_is_not_repeatable.snap new file mode 100644 index 0000000000..a24535daf3 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__search__a_search_cleared_by_an_empty_query_is_not_repeatable.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/search.rs +expression: ctx.redact_buffer() +--- + On branch main | + Your branch is ahead of 'origin/main' by 3 commit(s). | + | + Recent commits | +▌1e81efc main add thirdfile Author Name __ | + eb81c40 add secondfile Author Name __ | + 4c54307 add firstfile Author Name __ | + b66a0bf origin/main add initial-file Author Name __ | + | + | + | + | + | + | + | + | + | + | +────────────────────────────────────────────────────────────────────────────────| +! No previous search | +styles_hash: 485fe0f8bf08cacc diff --git a/src/tests/snapshots/gitu__tests__search__search_aborted.snap b/src/tests/snapshots/gitu__tests__search__search_aborted.snap new file mode 100644 index 0000000000..a29c32bf01 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__search__search_aborted.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/search.rs +expression: ctx.redact_buffer() +--- +▌On branch main | +▌Your branch is ahead of 'origin/main' by 3 commit(s). | + | + Recent commits | + 1e81efc main add thirdfile Author Name __ | + eb81c40 add secondfile Author Name __ | + 4c54307 add firstfile Author Name __ | + b66a0bf origin/main add initial-file Author Name __ | + | + | + | + | + | + | + | + | + | + | + | + | +styles_hash: cd13957817e00c13 diff --git a/src/tests/snapshots/gitu__tests__search__search_context_line.snap b/src/tests/snapshots/gitu__tests__search__search_context_line.snap new file mode 100644 index 0000000000..71e1804fc8 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__search__search_context_line.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/search.rs +expression: ctx.redact_buffer() +--- + On branch main | + Your branch is ahead of 'origin/main' by 1 commit(s). | + | + Unstaged changes (1) | + modified testfile | + @@ -1,3 +1,3 @@ | + one | +▌ two | + -three | + +four | + | + Recent commits | + 1c4af61 main add testfile Author Name __ | + b66a0bf origin/main add initial-file Author Name __ | + | + | + | + | + | + | +styles_hash: 7f897d9940b4d5c2 diff --git a/src/tests/snapshots/gitu__tests__search__search_forward.snap b/src/tests/snapshots/gitu__tests__search__search_forward.snap new file mode 100644 index 0000000000..c517872dc4 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__search__search_forward.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/search.rs +expression: ctx.redact_buffer() +--- + On branch main | + Your branch is ahead of 'origin/main' by 3 commit(s). | + | + Recent commits | + 1e81efc main add thirdfile Author Name __ | +▌eb81c40 add secondfile Author Name __ | + 4c54307 add firstfile Author Name __ | + b66a0bf origin/main add initial-file Author Name __ | + | + | + | + | + | + | + | + | + | + | + | + | +styles_hash: a5766a58f69648e0 diff --git a/src/tests/snapshots/gitu__tests__search__search_hunk_line.snap b/src/tests/snapshots/gitu__tests__search__search_hunk_line.snap new file mode 100644 index 0000000000..1318cf9003 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__search__search_hunk_line.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/search.rs +expression: ctx.redact_buffer() +--- + On branch main | + Your branch is ahead of 'origin/main' by 1 commit(s). | + | + Unstaged changes (1) | + modified testfile | + @@ -1,3 +1,3 @@ | + one | + two | + -three | +▌+four | + | + Recent commits | + 1c4af61 main add testfile Author Name __ | + b66a0bf origin/main add initial-file Author Name __ | + | + | + | + | + | + | +styles_hash: 7b32b6b5b7643770 diff --git a/src/tests/snapshots/gitu__tests__search__search_next_and_previous.snap b/src/tests/snapshots/gitu__tests__search__search_next_and_previous.snap new file mode 100644 index 0000000000..42aff60f0c --- /dev/null +++ b/src/tests/snapshots/gitu__tests__search__search_next_and_previous.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/search.rs +expression: ctx.redact_buffer() +--- + On branch main | + Your branch is ahead of 'origin/main' by 3 commit(s). | + | + Recent commits | + 1e81efc main add thirdfile Author Name __ | +▌eb81c40 add secondfile Author Name __ | + 4c54307 add firstfile Author Name __ | + b66a0bf origin/main add initial-file Author Name __ | + | + | + | + | + | + | + | + | + | + | + | + | +styles_hash: cea6305637e4e2b diff --git a/src/tests/snapshots/gitu__tests__search__search_prompt.snap b/src/tests/snapshots/gitu__tests__search__search_prompt.snap new file mode 100644 index 0000000000..3dcfa6242d --- /dev/null +++ b/src/tests/snapshots/gitu__tests__search__search_prompt.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/search.rs +expression: ctx.redact_buffer() +--- +▌On branch main | +▌Your branch is ahead of 'origin/main' by 3 commit(s). | + | + Recent commits | + 1e81efc main add thirdfile Author Name __ | + eb81c40 add secondfile Author Name __ | + 4c54307 add firstfile Author Name __ | + b66a0bf origin/main add initial-file Author Name __ | + | + | + | + | + | + | + | + | + | + | +────────────────────────────────────────────────────────────────────────────────| +? Search: › █ | +styles_hash: 34eae83c0a39578a diff --git a/src/tests/snapshots/gitu__tests__search__search_repeat_without_a_previous_search.snap b/src/tests/snapshots/gitu__tests__search__search_repeat_without_a_previous_search.snap new file mode 100644 index 0000000000..5a2913c9e5 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__search__search_repeat_without_a_previous_search.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/search.rs +expression: ctx.redact_buffer() +--- +▌On branch main | +▌Your branch is ahead of 'origin/main' by 3 commit(s). | + | + Recent commits | + 1e81efc main add thirdfile Author Name __ | + eb81c40 add secondfile Author Name __ | + 4c54307 add firstfile Author Name __ | + b66a0bf origin/main add initial-file Author Name __ | + | + | + | + | + | + | + | + | + | + | +────────────────────────────────────────────────────────────────────────────────| +! No previous search | +styles_hash: 1854571c7d0db91d diff --git a/src/tests/snapshots/gitu__tests__search__search_without_match.snap b/src/tests/snapshots/gitu__tests__search__search_without_match.snap new file mode 100644 index 0000000000..280a884b56 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__search__search_without_match.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/search.rs +expression: ctx.redact_buffer() +--- +▌On branch main | +▌Your branch is ahead of 'origin/main' by 3 commit(s). | + | + Recent commits | + 1e81efc main add thirdfile Author Name __ | + eb81c40 add secondfile Author Name __ | + 4c54307 add firstfile Author Name __ | + b66a0bf origin/main add initial-file Author Name __ | + | + | + | + | + | + | + | + | + | + | +────────────────────────────────────────────────────────────────────────────────| +! No match: nonesuch | +styles_hash: 1854571c7d0db91d diff --git a/src/ui.rs b/src/ui.rs index 9d42cc8ed4..1599853719 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,9 +1,11 @@ use std::borrow::Cow; +use std::ops::Range; use crate::Res; use crate::app::State; use crate::error::Error; use crate::screen; +use crate::search; use crate::style::{Color, Modifier, Style}; use crate::term::TermBackend; use crate::text_input::Status; @@ -28,6 +30,12 @@ const BLANKS: &str = " pub(crate) struct Span<'a>(pub(crate) Cow<'a, str>, pub(crate) Style); pub(crate) type UiTree<'a> = LayoutTree, Style>; +impl Span<'_> { + pub(crate) fn text(&self) -> &str { + self.0.as_ref() + } +} + impl Measure for Span<'_> { type Unit = u16; @@ -40,10 +48,14 @@ pub(crate) fn ui(term: &mut TermBackend, state: &mut State) -> Res<()> { let size = term.size().unwrap(); let mut layout = UiTree::new(); + let mut screen_leaves = 0..0; + layout.col(opts(), |layout| { layout.col(opts().fill_xy(), |layout| { let hide_cursor = state.picker.is_some(); + let start = layout.node_count(); screen::layout_screen(layout, state.screens.last().unwrap(), hide_cursor); + screen_leaves = start..layout.node_count(); }); layout.col(opts(), |layout| { @@ -68,20 +80,78 @@ pub(crate) fn ui(term: &mut TermBackend, state: &mut State) -> Res<()> { }); }); + let highlight = Highlight { + matches: search_matches(&layout, state, screen_leaves), + style: Style::from(&state.config.style.search_match), + }; + let computed = layout.compute([size.0, size.1]); let mut items = computed.iter().collect::>(); items.sort_by_key(|item| [item.pos[1], item.pos[0]]); - clear_blanks(term, size, items)?; + print_spans(term, size, items, &highlight)?; term.flush().map_err(Error::Term)?; - state.screens.last_mut().unwrap().size = size; Ok(()) } +fn search_matches( + layout: &UiTree, + state: &State, + screen_leaves: Range, +) -> Vec<(usize, Range)> { + let Some(matcher) = state.screens.last().unwrap().get_search_matcher() else { + return vec![]; + }; + + let mut run_text = search::RunText::default(); + let mut matches = vec![]; + + for run in layout.leaf_runs() { + let mut run = run.peekable(); + + if !run + .peek() + .is_some_and(|&(leaf, _)| screen_leaves.contains(&leaf)) + { + continue; + } + + let text = run_text.read(run.map(|(leaf, span)| (leaf, span.text()))); + + for matched in matcher + .find_iter(text) + .map(|m| m.range()) + .collect::>() + { + matches.extend(run_text.per_leaf(matched)); + } + } + + matches +} + +struct Highlight { + /// Sorted by leaf, and within a leaf in ascending order. + matches: Vec<(usize, Range)>, + style: Style, +} + +impl Highlight { + /// What matched within `leaf`, as ranges of that leaf's own text. + fn within(&self, leaf: usize) -> impl Iterator> { + let from = self.matches.partition_point(|&(at, _)| at < leaf); + + self.matches[from..] + .iter() + .take_while(move |&&(at, _)| at == leaf) + .map(|(_, matched)| matched.clone()) + } +} + fn layout_prompt<'a>(layout: &mut UiTree<'a>, state: &'a State, width: usize) { let Some(ref prompt_data) = state.prompt.data else { return; @@ -200,16 +270,18 @@ pub(crate) fn repeat_chars(layout: &mut UiTree, count: usize, chars: &'static st }); } -fn clear_blanks( +fn print_spans( term: &mut TermBackend, size: (u16, u16), items: Vec, Style>, u16>>, + highlight: &Highlight, ) -> Result<(), Error> { let mut at = [0, 0]; let mut bg = Style::new(); let mut bg_end = 0; for item in items { let LayoutItem { + index, data, pos, size: item_size, @@ -218,10 +290,10 @@ fn clear_blanks( blank_until(term, &mut at, [0, pos[1]], size.0, bg, bg_end)?; match data { - Payload::Leaf(Span(text, style)) => { + Payload::Leaf(span) => { blank_until(term, &mut at, pos, size.0, bg, bg_end)?; term.queue_move_cursor(pos[0], pos[1])?; - term.queue_print(text, style)?; + print_span(term, span, highlight.within(index), highlight.style)?; at[0] = pos[0].saturating_add(item_size[0]); } @@ -235,6 +307,30 @@ fn clear_blanks( Ok(()) } +fn print_span( + term: &mut TermBackend, + Span(text, style): &Span, + matches: impl Iterator>, + match_style: Style, +) -> Result<(), Error> { + let mut at = 0; + + for matched in matches { + if at < matched.start { + term.queue_print(&text[at..matched.start], style)?; + } + + at = matched.end; + term.queue_print(&text[matched], &style.patch(match_style))?; + } + + if at < text.len() { + term.queue_print(&text[at..], style)?; + } + + Ok(()) +} + fn blank_until( term: &mut TermBackend, at: &mut [u16; 2], @@ -272,3 +368,103 @@ fn queue_blanks(term: &mut TermBackend, at: [u16; 2], width: u16, style: &Style) Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::screen::matcher; + + /// Lays `spans` out as one run and reads back what a search goes against. + fn run_of(spans: &[&'static str]) -> (UiTree<'static>, search::RunText) { + let mut layout = UiTree::new(); + + layout.row(opts(), |layout| { + for span in spans { + layout_span(layout, ((*span).into(), Style::new())); + } + }); + + (layout, search::RunText::default()) + } + + /// What `query` matched, as the text of each match. + fn matched(spans: &[&'static str], query: &str) -> Vec { + let (layout, mut run_text) = run_of(spans); + let matcher = matcher(query).unwrap(); + + layout + .leaf_runs() + .flat_map(|run| { + let text = run_text.read(run.map(|(leaf, span)| (leaf, span.text()))); + matcher + .find_iter(text) + .map(|matched| matched.as_str().to_string()) + .collect::>() + }) + .collect() + } + + #[test] + fn a_match_may_run_from_one_span_into_the_next() { + assert_eq!( + vec!["main add"], + matched(&["main", " ", "add thirdfile"], "main add") + ); + } + + #[test] + fn matching_ignores_case_without_shifting_what_it_hands_back() { + assert_eq!( + vec!["Add Thirdfile"], + matched(&["Add Thirdfile"], "add thirdfile") + ); + } + + #[test] + fn multi_byte_characters_are_matched_whole() { + assert_eq!(vec!["⊕"], matched(&["ändra ⊕ hunk"], "⊕")); + assert_eq!(vec!["ändra"], matched(&["ändra ⊕ hunk"], "ändra")); + } + + /// Runs are what search goes against, and a nested container is a break in + /// one, so text on either side of it is never one match. + #[test] + fn a_match_may_not_run_across_a_nested_container() { + let mut layout = UiTree::new(); + + layout.row(opts(), |layout| { + layout_span(layout, ("1e81efc".into(), Style::new())); + + // What pushes the author and age to the right on a commit row. + layout.row(opts().fill_x(), |layout| { + layout_span(layout, (" main".into(), Style::new())); + }); + }); + + let mut run_text = search::RunText::default(); + let matcher = matcher("1e81efc main").unwrap(); + let matched = layout.leaf_runs().any(|run| { + matcher.is_match(run_text.read(run.map(|(leaf, span)| (leaf, span.text())))) + }); + + assert!(!matched); + } + + /// A match is painted by each leaf it covers, so it has to be cut up along + /// the ones it spans. + #[test] + fn a_match_is_handed_back_per_leaf_it_covers() { + let (layout, mut run_text) = run_of(&["main", " ", "add thirdfile"]); + let run = layout.leaf_runs().next().unwrap(); + let text = run_text + .read(run.map(|(leaf, span)| (leaf, span.text()))) + .to_string(); + + let matched = matcher("main add").unwrap().find(&text).unwrap().range(); + let per_leaf = run_text.per_leaf(matched).collect::>(); + + // "main" whole, the space whole, then "add" of "add thirdfile". The + // leaves are numbered from 2, after the root and the row holding them. + assert_eq!(vec![(2, 0..4), (3, 0..1), (4, 0..3)], per_leaf); + } +} diff --git a/src/ui/layout/mod.rs b/src/ui/layout/mod.rs index d86f5794d0..9de5d9313f 100644 --- a/src/ui/layout/mod.rs +++ b/src/ui/layout/mod.rs @@ -181,6 +181,49 @@ impl LayoutTree { }); } + /// The leaves grouped into runs of siblings, broken wherever a nested + /// container comes between two of them. + /// + /// Leaves are added in reading order, so a run is what reads as one + /// uninterrupted piece, while what separate runs hold is only adjacent by + /// coincidence of where the layout put them. + /// + /// Each leaf comes with the index that identifies it, which is what + /// [`LayoutItem`] carries once a layout has been computed and its items + /// have been reordered. + pub fn leaf_runs(&self) -> impl Iterator> { + let mut at = 0; + + iter::from_fn(move || { + while !self.data.get(at)?.is_leaf() { + at += 1; + } + + let start = at; + let parent = self.index.parents[start]; + + while self.data.get(at).is_some_and(Node::is_leaf) && self.index.parents[at] == parent { + at += 1; + } + + Some( + self.data[start..at] + .iter() + .enumerate() + .filter_map(move |(i, node)| Some((start + i, node.as_leaf()?))), + ) + }) + } + + // TODO I dislike this and would rather remove it, but it serves to allow search highlighting for + // the moment. There must be a better way... + // + /// How many nodes have been added, for marking off the ones some part of + /// the tree went on to lay out. + pub fn node_count(&self) -> usize { + self.data.len() + } + /// Places every node within `avail_size`, handing back the result to read /// positions off. pub fn compute(&mut self, avail_size: [L::Unit; 2]) -> Computed<'_, L, C> { @@ -365,6 +408,7 @@ impl Computed<'_, L, C> { } Some(LayoutItem { + index, data, pos: node.pos?.into(), size: node.size.into(), @@ -388,6 +432,9 @@ pub enum Payload<'a, L, C> { #[derive(Debug)] pub struct LayoutItem { + /// Identifies the node this was laid out from, which survives the items + /// being reordered. Pairs with what [`LayoutTree::leaf_runs`] hands out. + pub index: usize, pub data: T, pub pos: [U; 2], pub size: [U; 2], @@ -472,7 +519,10 @@ mod tests { let mut grid = vec![' '; height * width]; - for LayoutItem { data, pos, size } in computed.iter() { + for LayoutItem { + data, pos, size, .. + } in computed.iter() + { let x0 = pos[0] as usize; let y0 = pos[1] as usize; let item_width = size[0] as usize; @@ -487,6 +537,59 @@ mod tests { .join("\n") } + /// Renders the runs as their leaves joined, which is what a caller reading + /// text off the tree gets. + fn runs_of(layout: &TestTree) -> Vec { + layout + .leaf_runs() + .map(|run| run.map(|(_, leaf)| *leaf).collect::()) + .collect() + } + + #[test] + fn siblings_are_one_run() { + let mut layout = TestTree::new(); + + layout.row(opts(), |layout| { + layout.leaf("add "); + layout.leaf("thirdfile"); + }); + + assert_eq!(vec!["add thirdfile"], runs_of(&layout)); + } + + #[test] + fn a_nested_container_breaks_a_run() { + let mut layout = TestTree::new(); + + layout.row(opts(), |layout| { + layout.leaf("1e81efc"); + + layout.row(opts().fill_x(), |layout| { + layout.leaf(" main"); + }); + + layout.leaf("Author Name"); + }); + + assert_eq!( + vec!["1e81efc", " main", "Author Name"], + runs_of(&layout), + "the leaves either side of the nested row are not one piece of text" + ); + } + + #[test] + fn rows_are_runs_of_their_own() { + let mut layout = TestTree::new(); + + for text in ["first", "second"] { + layout.row(opts(), |layout| layout.leaf(text)); + } + + assert_eq!(vec!["first", "second"], runs_of(&layout)); + } + #[test] fn test_iter_distribute_size_no_flex() { let mut layout = TestTree::new(); diff --git a/src/ui/layout/node.rs b/src/ui/layout/node.rs index bce8420307..16b9a99852 100644 --- a/src/ui/layout/node.rs +++ b/src/ui/layout/node.rs @@ -121,6 +121,17 @@ pub(crate) struct Node { } impl Node { + pub(crate) fn as_leaf(&self) -> Option<&L> { + match &self.data { + NodeData::Leaf(leaf) => Some(leaf), + NodeData::Container(_) => None, + } + } + + pub(crate) fn is_leaf(&self) -> bool { + matches!(self.data, NodeData::Leaf(_)) + } + pub(crate) fn is_wrapping(&self) -> bool { self.opts .wrap