From 26e69aaaf141ddffa4fd018a14fa9d562d6b935c Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Fri, 28 Mar 2025 11:28:11 +0000 Subject: [PATCH 1/5] feat: highlight matches in interactive search uses `norm` to do fzf-compatible matches when rendering history items in the search panel to highlight the matching ranges of the item this helps see _why_ certain history items have come up note that this will never be 100% perfect as we search on a sqlite query but it should be good enough in most cases --- Cargo.lock | 10 ++++++ crates/atuin/Cargo.toml | 1 + .../src/command/client/search/engines.rs | 1 + .../src/command/client/search/engines/db.rs | 18 +++++++++- .../src/command/client/search/engines/skim.rs | 5 +++ .../src/command/client/search/history_list.rs | 35 ++++++++++++++----- .../src/command/client/search/interactive.rs | 6 ++++ 7 files changed, 66 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d7adaff1b77..17dd161ad51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -245,6 +245,7 @@ dependencies = [ "interim", "itertools 0.13.0", "log", + "norm", "ratatui", "regex", "rpassword", @@ -2593,6 +2594,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "norm" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5725a3379c44dc0adf3437af87cf21c10df473ed858d654b12603dea102508" +dependencies = [ + "memchr", +] + [[package]] name = "ntapi" version = "0.4.1" diff --git a/crates/atuin/Cargo.toml b/crates/atuin/Cargo.toml index 5288c10e683..1bca4a9fa3f 100644 --- a/crates/atuin/Cargo.toml +++ b/crates/atuin/Cargo.toml @@ -80,6 +80,7 @@ tracing-subscriber = { workspace = true } uuid = { workspace = true } sysinfo = "0.30.7" regex = "1.10.5" +norm = { version = "0.1.1", features = ["fzf-v2"] } [target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] arboard = { version = "3.4", optional = true } diff --git a/crates/atuin/src/command/client/search/engines.rs b/crates/atuin/src/command/client/search/engines.rs index 30a23cb2b0e..95d6658bb98 100644 --- a/crates/atuin/src/command/client/search/engines.rs +++ b/crates/atuin/src/command/client/search/engines.rs @@ -69,4 +69,5 @@ pub trait SearchEngine: Send + Sync + 'static { self.full_query(state, db).await } } + fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec; } diff --git a/crates/atuin/src/command/client/search/engines/db.rs b/crates/atuin/src/command/client/search/engines/db.rs index e638f9d9b7b..3db76e394bb 100644 --- a/crates/atuin/src/command/client/search/engines/db.rs +++ b/crates/atuin/src/command/client/search/engines/db.rs @@ -3,7 +3,9 @@ use atuin_client::{ database::Database, database::OptFilters, history::History, settings::SearchMode, }; use eyre::Result; - +use std::ops::Range; +use norm::fzf::{FzfParser, FzfV2}; +use norm::Metric; use super::{SearchEngine, SearchState}; pub struct Search(pub SearchMode); @@ -30,4 +32,18 @@ impl SearchEngine for Search { // ignore errors as it may be caused by incomplete regex .map_or(Vec::new(), |r| r.into_iter().collect())) } + + fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec { + if self.0 == SearchMode::Prefix { + return vec![]; + } + let mut fzf = FzfV2::new(); + let mut parser = FzfParser::new(); + let query = parser.parse(search_input); + let mut ranges: Vec> = Vec::new(); + let _ = fzf.distance_and_ranges(query, command, &mut ranges); + + // convert ranges to all indices + ranges.into_iter().flat_map(|r| r).collect() + } } diff --git a/crates/atuin/src/command/client/search/engines/skim.rs b/crates/atuin/src/command/client/search/engines/skim.rs index e87e06d1e95..393efdde015 100644 --- a/crates/atuin/src/command/client/search/engines/skim.rs +++ b/crates/atuin/src/command/client/search/engines/skim.rs @@ -37,6 +37,11 @@ impl SearchEngine for Search { Ok(fuzzy_search(&self.engine, state, &self.all_history).await) } + + fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec { + let (_, indices) = self.engine.fuzzy_indices(command, search_input).unwrap_or_default(); + indices + } } async fn fuzzy_search( diff --git a/crates/atuin/src/command/client/search/history_list.rs b/crates/atuin/src/command/client/search/history_list.rs index ccffc95cc35..41e67a9e259 100644 --- a/crates/atuin/src/command/client/search/history_list.rs +++ b/crates/atuin/src/command/client/search/history_list.rs @@ -13,9 +13,9 @@ use ratatui::{ widgets::{Block, StatefulWidget, Widget}, }; use time::OffsetDateTime; - +use itertools::Itertools; use super::duration::format_duration; - +use super::engines::SearchEngine; pub struct HistoryList<'a> { history: &'a [History], block: Option>, @@ -25,6 +25,8 @@ pub struct HistoryList<'a> { now: &'a dyn Fn() -> OffsetDateTime, indicator: &'a str, theme: &'a Theme, + engine: &'a Box, + search_input: &'a str, } #[derive(Default)] @@ -84,7 +86,7 @@ impl StatefulWidget for HistoryList<'_> { s.index(); s.duration(item); s.time(item); - s.command(item); + s.command(item, &self.search_input, &self.engine); // reset line s.y += 1; @@ -101,6 +103,8 @@ impl<'a> HistoryList<'a> { now: &'a dyn Fn() -> OffsetDateTime, indicator: &'a str, theme: &'a Theme, + engine: &'a Box, + search_input: &'a str, ) -> Self { Self { history, @@ -110,6 +114,8 @@ impl<'a> HistoryList<'a> { now, indicator, theme, + engine, + search_input, } } @@ -201,7 +207,7 @@ impl DrawState<'_> { self.draw(" ago", style.into()); } - fn command(&mut self, h: &History) { + fn command(&mut self, h: &History, search_input: &str, engine: &Box) { let mut style = self.theme.as_style(Meaning::Base); if !self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected) { @@ -210,14 +216,25 @@ impl DrawState<'_> { style.attributes.set(style::Attribute::Bold); } + let highlight_indices = engine.get_highlight_indices(h.command.escape_control().split_ascii_whitespace().join(" ").as_str(), search_input); + + let mut pos = 0; for section in h.command.escape_control().split_ascii_whitespace() { self.draw(" ", style.into()); - if self.x > self.list_area.width { - // Avoid attempting to draw a command section beyond the width - // of the list - return; + for (_, ch) in section.chars().enumerate() { + if self.x > self.list_area.width { + // Avoid attempting to draw a command section beyond the width + // of the list + return; + } + let mut style = style.clone(); + if highlight_indices.contains(&pos) { + style.attributes.toggle(style::Attribute::Bold); + } + self.draw(&ch.to_string(), style.into()); + pos += 1; } - self.draw(section, style.into()); + pos += 1; } } diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs index c310f64df8e..53ad46a23ad 100644 --- a/crates/atuin/src/command/client/search/interactive.rs +++ b/crates/atuin/src/command/client/search/interactive.rs @@ -735,6 +735,8 @@ impl State { &self.now, indicator.as_str(), theme, + &self.engine, + &self.search.input.as_str(), ); f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state); } @@ -878,6 +880,8 @@ impl State { now: &'a dyn Fn() -> OffsetDateTime, indicator: &'a str, theme: &'a Theme, + engine: &'a Box, + search_input: &'a str, ) -> HistoryList<'a> { let results_list = HistoryList::new( results, @@ -886,6 +890,8 @@ impl State { now, indicator, theme, + engine, + search_input, ); if style.compact { From 1d99a472640fbf6fc63d0fbe2fd9b979aded6c2e Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Fri, 28 Mar 2025 11:38:59 +0000 Subject: [PATCH 2/5] fmt --- .../atuin/src/command/client/search/engines/db.rs | 6 +++--- .../src/command/client/search/engines/skim.rs | 5 ++++- .../src/command/client/search/history_list.rs | 15 +++++++++++---- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/atuin/src/command/client/search/engines/db.rs b/crates/atuin/src/command/client/search/engines/db.rs index 3db76e394bb..581c56c14d0 100644 --- a/crates/atuin/src/command/client/search/engines/db.rs +++ b/crates/atuin/src/command/client/search/engines/db.rs @@ -1,12 +1,12 @@ +use super::{SearchEngine, SearchState}; use async_trait::async_trait; use atuin_client::{ database::Database, database::OptFilters, history::History, settings::SearchMode, }; use eyre::Result; -use std::ops::Range; -use norm::fzf::{FzfParser, FzfV2}; use norm::Metric; -use super::{SearchEngine, SearchState}; +use norm::fzf::{FzfParser, FzfV2}; +use std::ops::Range; pub struct Search(pub SearchMode); diff --git a/crates/atuin/src/command/client/search/engines/skim.rs b/crates/atuin/src/command/client/search/engines/skim.rs index 393efdde015..e8aff7b4561 100644 --- a/crates/atuin/src/command/client/search/engines/skim.rs +++ b/crates/atuin/src/command/client/search/engines/skim.rs @@ -39,7 +39,10 @@ impl SearchEngine for Search { } fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec { - let (_, indices) = self.engine.fuzzy_indices(command, search_input).unwrap_or_default(); + let (_, indices) = self + .engine + .fuzzy_indices(command, search_input) + .unwrap_or_default(); indices } } diff --git a/crates/atuin/src/command/client/search/history_list.rs b/crates/atuin/src/command/client/search/history_list.rs index 41e67a9e259..2435fb60e78 100644 --- a/crates/atuin/src/command/client/search/history_list.rs +++ b/crates/atuin/src/command/client/search/history_list.rs @@ -1,10 +1,13 @@ use std::time::Duration; +use super::duration::format_duration; +use super::engines::SearchEngine; use atuin_client::{ history::History, theme::{Meaning, Theme}, }; use atuin_common::utils::Escapable as _; +use itertools::Itertools; use ratatui::{ buffer::Buffer, crossterm::style, @@ -13,9 +16,6 @@ use ratatui::{ widgets::{Block, StatefulWidget, Widget}, }; use time::OffsetDateTime; -use itertools::Itertools; -use super::duration::format_duration; -use super::engines::SearchEngine; pub struct HistoryList<'a> { history: &'a [History], block: Option>, @@ -216,7 +216,14 @@ impl DrawState<'_> { style.attributes.set(style::Attribute::Bold); } - let highlight_indices = engine.get_highlight_indices(h.command.escape_control().split_ascii_whitespace().join(" ").as_str(), search_input); + let highlight_indices = engine.get_highlight_indices( + h.command + .escape_control() + .split_ascii_whitespace() + .join(" ") + .as_str(), + search_input, + ); let mut pos = 0; for section in h.command.escape_control().split_ascii_whitespace() { From 09a2e084194f309592aef1c43482a2a79f2fb554 Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Fri, 28 Mar 2025 11:43:53 +0000 Subject: [PATCH 3/5] fix some clippy issues --- crates/atuin/src/command/client/search/history_list.rs | 4 ++-- crates/atuin/src/command/client/search/interactive.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/atuin/src/command/client/search/history_list.rs b/crates/atuin/src/command/client/search/history_list.rs index 2435fb60e78..b037468d23d 100644 --- a/crates/atuin/src/command/client/search/history_list.rs +++ b/crates/atuin/src/command/client/search/history_list.rs @@ -228,13 +228,13 @@ impl DrawState<'_> { let mut pos = 0; for section in h.command.escape_control().split_ascii_whitespace() { self.draw(" ", style.into()); - for (_, ch) in section.chars().enumerate() { + for ch in section.chars() { if self.x > self.list_area.width { // Avoid attempting to draw a command section beyond the width // of the list return; } - let mut style = style.clone(); + let mut style = style; if highlight_indices.contains(&pos) { style.attributes.toggle(style::Attribute::Bold); } diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs index 53ad46a23ad..c3080901795 100644 --- a/crates/atuin/src/command/client/search/interactive.rs +++ b/crates/atuin/src/command/client/search/interactive.rs @@ -736,7 +736,7 @@ impl State { indicator.as_str(), theme, &self.engine, - &self.search.input.as_str(), + self.search.input.as_str(), ); f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state); } From 5d8e7bd27986f2376e5b83b1faa6a2ef01fcd85d Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Fri, 28 Mar 2025 12:45:14 +0000 Subject: [PATCH 4/5] refactor to pass in a history_highlighter instead of search and engine --- .../src/command/client/search/engines/db.rs | 2 +- .../src/command/client/search/history_list.rs | 31 +++++++++++++------ .../src/command/client/search/interactive.rs | 25 ++++++++------- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/crates/atuin/src/command/client/search/engines/db.rs b/crates/atuin/src/command/client/search/engines/db.rs index 581c56c14d0..9358ee583f6 100644 --- a/crates/atuin/src/command/client/search/engines/db.rs +++ b/crates/atuin/src/command/client/search/engines/db.rs @@ -44,6 +44,6 @@ impl SearchEngine for Search { let _ = fzf.distance_and_ranges(query, command, &mut ranges); // convert ranges to all indices - ranges.into_iter().flat_map(|r| r).collect() + ranges.into_iter().flatten().collect() } } diff --git a/crates/atuin/src/command/client/search/history_list.rs b/crates/atuin/src/command/client/search/history_list.rs index b037468d23d..0c393ef7942 100644 --- a/crates/atuin/src/command/client/search/history_list.rs +++ b/crates/atuin/src/command/client/search/history_list.rs @@ -16,6 +16,19 @@ use ratatui::{ widgets::{Block, StatefulWidget, Widget}, }; use time::OffsetDateTime; + +pub struct HistoryHighlighter<'a> { + pub engine: &'a dyn SearchEngine, + pub search_input: &'a str, +} + +impl HistoryHighlighter<'_> { + pub fn get_highlight_indices(&self, command: &str) -> Vec { + self.engine + .get_highlight_indices(command, self.search_input) + } +} + pub struct HistoryList<'a> { history: &'a [History], block: Option>, @@ -25,8 +38,7 @@ pub struct HistoryList<'a> { now: &'a dyn Fn() -> OffsetDateTime, indicator: &'a str, theme: &'a Theme, - engine: &'a Box, - search_input: &'a str, + history_highlighter: HistoryHighlighter<'a>, } #[derive(Default)] @@ -80,13 +92,14 @@ impl StatefulWidget for HistoryList<'_> { now: &self.now, indicator: self.indicator, theme: self.theme, + history_highlighter: self.history_highlighter, }; for item in self.history.iter().skip(state.offset).take(end - start) { s.index(); s.duration(item); s.time(item); - s.command(item, &self.search_input, &self.engine); + s.command(item); // reset line s.y += 1; @@ -103,8 +116,7 @@ impl<'a> HistoryList<'a> { now: &'a dyn Fn() -> OffsetDateTime, indicator: &'a str, theme: &'a Theme, - engine: &'a Box, - search_input: &'a str, + history_highlighter: HistoryHighlighter<'a>, ) -> Self { Self { history, @@ -114,8 +126,7 @@ impl<'a> HistoryList<'a> { now, indicator, theme, - engine, - search_input, + history_highlighter, } } @@ -150,6 +161,7 @@ struct DrawState<'a> { now: &'a dyn Fn() -> OffsetDateTime, indicator: &'a str, theme: &'a Theme, + history_highlighter: HistoryHighlighter<'a>, } // longest line prefix I could come up with @@ -207,7 +219,7 @@ impl DrawState<'_> { self.draw(" ago", style.into()); } - fn command(&mut self, h: &History, search_input: &str, engine: &Box) { + fn command(&mut self, h: &History) { let mut style = self.theme.as_style(Meaning::Base); if !self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected) { @@ -216,13 +228,12 @@ impl DrawState<'_> { style.attributes.set(style::Attribute::Bold); } - let highlight_indices = engine.get_highlight_indices( + let highlight_indices = self.history_highlighter.get_highlight_indices( h.command .escape_control() .split_ascii_whitespace() .join(" ") .as_str(), - search_input, ); let mut pos = 0; diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs index c3080901795..2f2966506a0 100644 --- a/crates/atuin/src/command/client/search/interactive.rs +++ b/crates/atuin/src/command/client/search/interactive.rs @@ -10,6 +10,11 @@ use semver::Version; use time::OffsetDateTime; use unicode_width::UnicodeWidthStr; +use super::{ + cursor::Cursor, + engines::{SearchEngine, SearchState}, + history_list::{HistoryList, ListState, PREFIX_LENGTH}, +}; use atuin_client::{ database::{Database, current_context}, history::{History, HistoryStats, store::HistoryStore}, @@ -18,12 +23,7 @@ use atuin_client::{ }, }; -use super::{ - cursor::Cursor, - engines::{SearchEngine, SearchState}, - history_list::{HistoryList, ListState, PREFIX_LENGTH}, -}; - +use crate::command::client::search::history_list::HistoryHighlighter; use crate::command::client::theme::{Meaning, Theme}; use crate::{VERSION, command::client::search::engines}; @@ -728,6 +728,10 @@ impl State { match self.tab_index { 0 => { + let history_highlighter = HistoryHighlighter { + engine: self.engine.as_ref(), + search_input: self.search.input.as_str(), + }; let results_list = Self::build_results_list( style, results, @@ -735,8 +739,7 @@ impl State { &self.now, indicator.as_str(), theme, - &self.engine, - self.search.input.as_str(), + history_highlighter, ); f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state); } @@ -880,8 +883,7 @@ impl State { now: &'a dyn Fn() -> OffsetDateTime, indicator: &'a str, theme: &'a Theme, - engine: &'a Box, - search_input: &'a str, + history_highlighter: HistoryHighlighter<'a>, ) -> HistoryList<'a> { let results_list = HistoryList::new( results, @@ -890,8 +892,7 @@ impl State { now, indicator, theme, - engine, - search_input, + history_highlighter, ); if style.compact { From 9bdd7e5cb680e43e67c9971f0b71719b13308aa5 Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Fri, 28 Mar 2025 13:02:53 +0000 Subject: [PATCH 5/5] improve the highlighting on the selected row --- crates/atuin/src/command/client/search/history_list.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/atuin/src/command/client/search/history_list.rs b/crates/atuin/src/command/client/search/history_list.rs index 0c393ef7942..bed883c787d 100644 --- a/crates/atuin/src/command/client/search/history_list.rs +++ b/crates/atuin/src/command/client/search/history_list.rs @@ -221,8 +221,10 @@ impl DrawState<'_> { fn command(&mut self, h: &History) { let mut style = self.theme.as_style(Meaning::Base); + let mut row_highlighted = false; if !self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected) { + row_highlighted = true; // if not applying alternative highlighting to the whole row, color the command style = self.theme.as_style(Meaning::AlertError); style.attributes.set(style::Attribute::Bold); @@ -247,7 +249,12 @@ impl DrawState<'_> { } let mut style = style; if highlight_indices.contains(&pos) { - style.attributes.toggle(style::Attribute::Bold); + if row_highlighted { + // if the row is highlighted bold is not enough as the whole row is bold + // change the color too + style = self.theme.as_style(Meaning::AlertWarn); + } + style.attributes.set(style::Attribute::Bold); } self.draw(&ch.to_string(), style.into()); pos += 1;