diff --git a/Cargo.lock b/Cargo.lock index d58e6d5..97d77a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1421,6 +1421,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -2913,6 +2923,7 @@ dependencies = [ "insta", "keymap", "libc", + "nucleo-matcher", "portable-pty", "ratatui", "ratatui-image", @@ -2922,6 +2933,7 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "unicode-segmentation", "unicode-width", "upmd-parser", "upmd-pty", diff --git a/Cargo.toml b/Cargo.toml index 4668bcd..4899711 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,12 +55,14 @@ ratatui-image = { version = "11.0.6", default-features = false, features = ["cro image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] } syntect = "5.3.0" unicode-width = { version = "0.2.2", features = ["cjk"] } +unicode-segmentation = "1.13.2" vt100 = "0.16.2" # Platform and utilities arboard = "3.6.1" ignore = "0.4" tempfile = "3.27" +nucleo-matcher = "0.3.1" [dev-dependencies] insta = "1.47.2" diff --git a/src/apps/cli/app.rs b/src/apps/cli/app.rs index f35f3b0..2ac45d8 100644 --- a/src/apps/cli/app.rs +++ b/src/apps/cli/app.rs @@ -662,6 +662,7 @@ impl App { let reset = "\x1b[0m"; let muted = ansi_fg(self.theme.muted); let active = ansi_fg(self.theme.active); + let match_color = ansi_fg(self.theme.accent); let inactive = ansi_fg(self.theme.info_background); let foreground = ansi_fg(self.theme.foreground); let mut lines: u16 = 0; @@ -689,17 +690,14 @@ impl App { } else { for (i, &file_idx) in picker.matches.iter().enumerate() { let is_sel = i == picker.selected; - let file = &picker.files[file_idx]; - if is_sel { - write!( - out, - "{active}\u{25b8} {} {}{reset}\r\n", - i + 1, - file.display - )?; - } else { - write!(out, "{foreground} {} {}{reset}\r\n", i + 1, file.display)?; + let prefix_color = if is_sel { &active } else { &foreground }; + let prefix = if is_sel { "\u{25b8} " } else { " " }; + write!(out, "{prefix_color}{prefix}{} ", i + 1)?; + for (text, matched) in picker.highlight_parts(file_idx) { + let color = if matched { &match_color } else { &foreground }; + write!(out, "{color}{text}")?; } + write!(out, "{reset}\r\n")?; lines += 1; } } diff --git a/src/apps/picker.rs b/src/apps/picker.rs index f6d3b27..7a13cce 100644 --- a/src/apps/picker.rs +++ b/src/apps/picker.rs @@ -1,4 +1,7 @@ use keymap::KeyMap; +use nucleo_matcher::pattern::{Atom, AtomKind, CaseMatching, Normalization}; +use nucleo_matcher::{Config, Matcher, Utf32Str}; +use unicode_segmentation::UnicodeSegmentation; /// Actions shared by the TUI and CLI file pickers. /// @@ -27,18 +30,20 @@ pub enum PickerAction { Input(char), } -/// Shared state for file pickers: the file list, filter query, filtered -/// matches, and current selection index. -/// -/// Both the TUI [`FilePicker`](crate::apps::tui::file_picker::FilePicker) and -/// CLI [`CliPicker`](crate::apps::cli::picker::CliPicker) embed this struct and -/// delegate navigation logic to [`PickerState::handle_navigation`], ensuring -/// consistent filter and selection behavior across frontends. +/// Shared file-picker state: files, query, ranked matches, highlight ranges, +/// and selection. pub struct PickerState { pub files: Vec, pub matches: Vec, pub query: String, pub selected: usize, + matcher: Matcher, + /// Reused UTF-32 buffer for matching display paths. + scratch: Vec, + /// Reused match index buffer, cleared per file. + indices: Vec, + /// Matched byte ranges in each file's display path, keyed by file index. + highlights: Vec>, } impl PickerState { @@ -48,29 +53,80 @@ impl PickerState { matches: Vec::new(), query: String::new(), selected: 0, + matcher: Matcher::new(Config::DEFAULT.match_paths()), + scratch: Vec::new(), + indices: Vec::new(), + highlights: Vec::new(), }; state.rebuild(); state } - /// Recomputes `matches` from `query` and clamps `selected`. + /// Rebuilds ranked matches and highlight ranges from `query`, resetting the + /// selection. An empty query keeps discovery order; no matches leave nothing + /// selected. pub fn rebuild(&mut self) { - self.matches = self.build_matches(); - self.selected = self.selected.min(self.matches.len().saturating_sub(1)); + self.selected = 0; + self.highlights.clear(); + self.highlights.resize(self.files.len(), Vec::new()); + + if self.query.is_empty() { + self.matches = (0..self.files.len()).collect(); + return; + } + + let atom = Atom::new( + &self.query, + CaseMatching::Ignore, + Normalization::Smart, + AtomKind::Fuzzy, + false, + ); + let mut scored = Vec::new(); + for (idx, file) in self.files.iter().enumerate() { + self.scratch.clear(); + self.scratch + .extend(nucleo_matcher::chars::graphemes(&file.display)); + let haystack = Utf32Str::Unicode(&self.scratch); + + self.indices.clear(); + if let Some(score) = atom.indices(haystack, &mut self.matcher, &mut self.indices) { + self.highlights[idx] = matched_ranges(&file.display, &self.indices); + scored.push((idx, score)); + } + } + + scored.sort_by(|&(a_idx, a_score), &(b_idx, b_score)| { + b_score + .cmp(&a_score) + .then_with(|| self.files[a_idx].display.cmp(&self.files[b_idx].display)) + .then_with(|| a_idx.cmp(&b_idx)) + }); + self.matches = scored.into_iter().map(|(idx, _)| idx).collect(); } - /// Returns indices into `files` whose display path contains `query` - /// (case-insensitive). An empty query matches everything. - pub fn build_matches(&self) -> Vec { - let query = self.query.to_lowercase(); - self.files - .iter() - .enumerate() - .filter(|(_, file)| { - self.query.is_empty() || file.display.to_lowercase().contains(&query) - }) - .map(|(idx, _)| idx) - .collect() + /// Splits a file's display path into unmatched and matcher-selected parts. + pub fn highlight_parts(&self, file_idx: usize) -> Vec<(&str, bool)> { + let file = &self.files[file_idx]; + let ranges = &self.highlights[file_idx]; + + // Pre-allocate capacity: up to 2 parts per range + 1 for trailing text + let mut parts = Vec::with_capacity(ranges.len() * 2 + 1); + let mut start = 0; + + for &(range_start, range_end) in ranges { + if start < range_start { + parts.push((&file.display[start..range_start], false)); + } + parts.push((&file.display[range_start..range_end], true)); + start = range_end; + } + + if start < file.display.len() { + parts.push((&file.display[start..], false)); + } + + parts } /// Returns the index into `files` of the currently selected match, or @@ -93,8 +149,9 @@ impl PickerState { true } PickerAction::Delete => { - self.query.pop(); - self.rebuild(); + if self.query.pop().is_some() { + self.rebuild(); + } true } PickerAction::Next => { @@ -111,3 +168,22 @@ impl PickerState { } } } + +/// Converts increasing matcher grapheme indices to byte ranges in `display`, +/// merging adjacent matches. +fn matched_ranges(display: &str, indices: &[u32]) -> Vec<(usize, usize)> { + let graphemes: Vec<(usize, usize)> = display + .grapheme_indices(true) + .map(|(start, grapheme)| (start, start + grapheme.len())) + .collect(); + + let mut matched: Vec<(usize, usize)> = Vec::with_capacity(indices.len()); + for &idx in indices { + let range = graphemes[idx as usize]; + match matched.last_mut() { + Some(last) if last.1 == range.0 => last.1 = range.1, // Merge contiguous ranges + _ => matched.push(range), + } + } + matched +} diff --git a/src/apps/tui/file_picker.rs b/src/apps/tui/file_picker.rs index 2034181..13d99d5 100644 --- a/src/apps/tui/file_picker.rs +++ b/src/apps/tui/file_picker.rs @@ -208,25 +208,33 @@ impl FilePicker { } /// Builds styled list rows for the visible match window. - fn visible_match_items(&self, height: u16) -> Vec> { + fn visible_match_lines(&self, height: u16) -> Vec> { let range = self.visible_match_range(height); - self.state.matches[range.clone()] - .iter() - .enumerate() - .filter_map(|(offset, file_idx)| { - let abs = range.start + offset; + let path_color = self.theme.foreground; + let match_color = self.theme.accent; + + range + .clone() + .zip(self.state.matches[range].iter()) + .map(|(abs, file_idx)| { let is_selected = abs == self.state.selected; - let file = self.state.files.get(*file_idx)?; let prefix = if is_selected { "▸ " } else { " " }; - let fg = if is_selected { + let prefix_color = if is_selected { self.theme.active } else { - self.theme.foreground + path_color }; - Some(ratatui::widgets::ListItem::new(Line::from(Span::styled( - format!("{prefix}{}", file.display), - Style::default().fg(fg), - )))) + + let mut spans = vec![Span::styled(prefix, Style::default().fg(prefix_color))]; + + spans.extend(self.state.highlight_parts(*file_idx).into_iter().map( + |(text, matched)| { + let color = if matched { match_color } else { path_color }; + Span::styled(text.to_string(), Style::default().fg(color)) + }, + )); + + Line::from(spans) }) .collect() } @@ -361,7 +369,7 @@ impl Output for FilePicker { }; frame.render_widget( - List::new(self.visible_match_items(list_area.height)), + List::new(self.visible_match_lines(list_area.height)), list_area, ); @@ -396,10 +404,15 @@ mod tests { } #[test] - fn filters_by_display_path() { + fn filters_paths_correctly() { let mut picker = picker(); - let _ = picker.update(Action::Input('i')); - let _ = picker.update(Action::Input('n')); + let _ = picker.update(Action::Input('d')); + assert_eq!(picker.state.matches, vec![1, 0]); + + for ch in "inst".chars() { + let _ = picker.update(Action::Input(ch)); + } + assert_eq!(picker.state.matches.len(), 1); assert_eq!( picker.selected_path().unwrap(), @@ -407,6 +420,30 @@ mod tests { ); } + #[test] + fn highlights_graphemes_and_clears() { + let mut picker = picker(); + picker.state.files[0].display = "ca\tfe\u{301}.md".into(); + picker.state.query = "ce".into(); + picker.state.rebuild(); + assert_eq!( + picker.state.highlight_parts(0), + [ + ("c", true), + ("a\tf", false), + ("e\u{301}", true), + (".md", false) + ] + ); + + let _ = picker.update(Action::Delete); + let _ = picker.update(Action::Delete); + assert_eq!( + picker.state.highlight_parts(0), + [("ca\tfe\u{301}.md", false)] + ); + } + #[test] fn preview_prefix_reads_only_top_lines() { let tmp = tempfile::NamedTempFile::new().unwrap(); @@ -447,6 +484,7 @@ mod tests { let _ = picker.update(Action::Input('z')); assert!(picker.state.matches.is_empty()); assert_eq!(picker.selected_path(), None); + assert!(picker.update(Action::Select).is_none()); let _ = picker.update(Action::Delete); assert_eq!(picker.state.matches.len(), 2); @@ -465,6 +503,11 @@ mod tests { picker.selected_path().unwrap(), Path::new("/repo/docs/install.md") ); + let _ = picker.update(Action::Delete); + assert_eq!( + picker.selected_path().unwrap(), + Path::new("/repo/docs/install.md") + ); let _ = picker.update(Action::Prev); assert_eq!( picker.selected_path().unwrap(),