Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 8 additions & 10 deletions src/apps/cli/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
Expand Down
124 changes: 100 additions & 24 deletions src/apps/picker.rs
Original file line number Diff line number Diff line change
@@ -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.
///
Expand Down Expand Up @@ -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<crate::markdown_files::MarkdownFile>,
pub matches: Vec<usize>,
pub query: String,
pub selected: usize,
matcher: Matcher,
/// Reused UTF-32 buffer for matching display paths.
scratch: Vec<char>,
/// Reused match index buffer, cleared per file.
indices: Vec<u32>,
/// Matched byte ranges in each file's display path, keyed by file index.
highlights: Vec<Vec<(usize, usize)>>,
}

impl PickerState {
Expand All @@ -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<usize> {
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
Expand All @@ -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 => {
Expand All @@ -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
}
77 changes: 60 additions & 17 deletions src/apps/tui/file_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,25 +208,33 @@ impl FilePicker {
}

/// Builds styled list rows for the visible match window.
fn visible_match_items(&self, height: u16) -> Vec<ratatui::widgets::ListItem<'static>> {
fn visible_match_lines(&self, height: u16) -> Vec<Line<'static>> {
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()
}
Expand Down Expand Up @@ -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,
);

Expand Down Expand Up @@ -396,17 +404,46 @@ 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(),
Path::new("/repo/docs/install.md")
);
}

#[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();
Expand Down Expand Up @@ -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);
Expand All @@ -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(),
Expand Down
Loading