use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher, Utf32Str};
use tui_input::Input;
use tui_input::backend::crossterm::EventHandler;
use crate::keys::Key;
use crate::tree::{NodeId, Tree};
struct Candidate {
path: String,
active: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match {
pub id: NodeId,
pub score: u32,
pub indices: Vec<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum JumpOutcome {
Stay,
Accept(NodeId),
Cancel,
}
pub struct Jump {
input: Input,
candidates: Vec<Candidate>,
candidate_count: usize,
results: Vec<Match>,
selected: usize,
scroll: usize,
page_height: usize,
matcher: Matcher,
}
impl Jump {
pub fn open(tree: &Tree) -> Self {
let candidates: Vec<_> = (0..tree.len())
.map(|id| Candidate {
path: tree.jump_key(id),
active: tree.is_in_view(id),
})
.collect();
let candidate_count = candidates
.iter()
.filter(|candidate| candidate.active)
.count();
let mut jump = Self {
input: Input::default(),
candidates,
candidate_count,
results: Vec::new(),
selected: 0,
scroll: 0,
page_height: 10,
matcher: Matcher::new(Config::DEFAULT.match_paths()),
};
jump.rank();
jump
}
pub fn handle_key(&mut self, key: Key) -> JumpOutcome {
let ctrl = key.mods == KeyModifiers::CONTROL;
match key.code {
KeyCode::Esc => return JumpOutcome::Cancel,
KeyCode::Char('c') if ctrl => return JumpOutcome::Cancel,
KeyCode::Enter => {
return match self.results.get(self.selected) {
Some(m) => JumpOutcome::Accept(m.id),
None => JumpOutcome::Stay,
};
}
KeyCode::Down => self.move_selection(1),
KeyCode::Up => self.move_selection(-1),
KeyCode::Char('j' | 'n') if ctrl => self.move_selection(1),
KeyCode::Char('k' | 'p') if ctrl => self.move_selection(-1),
_ => {
let event = Event::Key(KeyEvent::new(key.code, key.mods));
if self
.input
.handle_event(&event)
.is_some_and(|change| change.value)
{
self.rank();
}
}
}
JumpOutcome::Stay
}
fn rank(&mut self) {
self.selected = 0;
self.scroll = 0;
self.results.clear();
if self.input.value().is_empty() {
self.results = self
.candidates
.iter()
.enumerate()
.filter(|(_, candidate)| candidate.active)
.map(|(id, _)| Match {
id,
score: 0,
indices: Vec::new(),
})
.collect();
return;
}
let pattern = Pattern::parse(
self.input.value(),
CaseMatching::Smart,
Normalization::Smart,
);
let mut buf = Vec::new();
for (id, candidate) in self
.candidates
.iter()
.enumerate()
.filter(|(_, candidate)| candidate.active)
{
let mut indices = Vec::new();
let haystack = Utf32Str::new(&candidate.path, &mut buf);
if let Some(score) = pattern.indices(haystack, &mut self.matcher, &mut indices) {
indices.sort_unstable();
indices.dedup();
self.results.push(Match { id, score, indices });
}
}
let candidates = &self.candidates;
self.results.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then_with(|| {
candidates[a.id]
.path
.len()
.cmp(&candidates[b.id].path.len())
})
.then_with(|| a.id.cmp(&b.id))
});
}
fn move_selection(&mut self, delta: isize) {
if self.results.is_empty() {
return;
}
let last = self.results.len() as isize - 1;
self.selected = (self.selected as isize + delta).clamp(0, last) as usize;
self.follow_selection();
}
fn follow_selection(&mut self) {
let height = self.page_height.max(1);
if self.selected < self.scroll {
self.scroll = self.selected;
} else if self.selected >= self.scroll + height {
self.scroll = self.selected + 1 - height;
}
}
pub fn query(&self) -> &str {
self.input.value()
}
pub fn visual_cursor(&self) -> usize {
self.input.visual_cursor()
}
pub fn visual_scroll(&self, width: usize) -> usize {
self.input.visual_scroll(width)
}
pub fn results(&self) -> &[Match] {
&self.results
}
pub fn selected(&self) -> usize {
self.selected
}
pub fn scroll(&self) -> usize {
self.scroll
}
pub fn matched(&self) -> usize {
self.results.len()
}
pub fn total(&self) -> usize {
self.candidate_count
}
pub fn path(&self, id: NodeId) -> &str {
&self.candidates[id].path
}
pub fn set_viewport(&mut self, rows: usize) {
self.page_height = rows.max(1);
self.follow_selection();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tree::ActionValues;
fn jump_over(paths: &[&str]) -> Jump {
let mut tree = Tree::new();
for p in paths {
tree.push(None, *p, false, ActionValues::new(*p, *p, *p));
}
Jump::open(&tree)
}
fn ch(c: char) -> Key {
Key::new(KeyCode::Char(c), KeyModifiers::NONE)
}
fn ctrl(c: char) -> Key {
Key::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn key(code: KeyCode) -> Key {
Key::new(code, KeyModifiers::NONE)
}
fn type_str(j: &mut Jump, s: &str) {
for c in s.chars() {
j.handle_key(ch(c));
}
}
fn result_ids(j: &Jump) -> Vec<NodeId> {
j.results().iter().map(|m| m.id).collect()
}
#[test]
fn empty_query_lists_all_candidates_in_tree_order() {
let j = jump_over(&["a", "b", "c"]);
assert_eq!(result_ids(&j), vec![0, 1, 2]);
assert_eq!(j.total(), 3);
assert_eq!(j.matched(), 3);
}
#[test]
fn narrowed_tree_only_offers_nodes_under_the_view_root() {
let mut tree = Tree::new();
tree.push(None, "outside", false, ActionValues::new("", "", "outside"));
let root = tree.push(None, "scope", true, ActionValues::new("", "", "scope"));
let child = tree.push(
Some(root),
"child",
false,
ActionValues::new("", "", "scope/child"),
);
tree.set_view_root(Some(root));
let jump = Jump::open(&tree);
assert_eq!(result_ids(&jump), vec![root, child]);
assert_eq!(jump.total(), 2);
assert_eq!(jump.path(child), "scope/child");
}
#[test]
fn typing_filters_to_fuzzy_matches() {
let mut j = jump_over(&["src/app.rs", "apple/pie", "README"]);
type_str(&mut j, "apprs");
assert_eq!(result_ids(&j), vec![0]);
}
#[test]
fn ranking_puts_the_stronger_match_first() {
let mut j = jump_over(&["app.rs", "zz/app.rs"]);
type_str(&mut j, "app");
assert_eq!(result_ids(&j), vec![0, 1]);
}
#[test]
fn extended_syntax_prefix_anchor() {
let mut j = jump_over(&["src/a", "x/src"]);
type_str(&mut j, "^src");
assert_eq!(result_ids(&j), vec![0]);
}
#[test]
fn extended_syntax_negation_excludes() {
let mut j = jump_over(&["app.rs", "app_test.rs"]);
type_str(&mut j, "app !test");
assert_eq!(result_ids(&j), vec![0]);
}
#[test]
fn space_is_a_second_and_atom() {
let mut j = jump_over(&["src/app.rs", "src/xx"]);
type_str(&mut j, "src rs");
assert_eq!(result_ids(&j), vec![0]);
}
#[test]
fn match_indices_are_recorded_for_highlighting() {
let mut j = jump_over(&["abcd"]);
type_str(&mut j, "abc");
assert_eq!(j.results()[0].indices, vec![0, 1, 2]);
}
#[test]
fn navigation_keys_move_and_clamp_selection() {
let mut j = jump_over(&["a1", "a2", "a3"]);
type_str(&mut j, "a");
assert_eq!(j.selected(), 0);
j.handle_key(ctrl('n'));
assert_eq!(j.selected(), 1);
j.handle_key(key(KeyCode::Down));
assert_eq!(j.selected(), 2);
j.handle_key(ctrl('n')); assert_eq!(j.selected(), 2);
j.handle_key(ctrl('p'));
assert_eq!(j.selected(), 1);
j.handle_key(ctrl('k'));
assert_eq!(j.selected(), 0);
j.handle_key(key(KeyCode::Up)); assert_eq!(j.selected(), 0);
}
#[test]
fn enter_accepts_the_selected_result() {
let mut j = jump_over(&["a1", "a2", "a3"]);
type_str(&mut j, "a");
j.handle_key(ctrl('n')); assert_eq!(j.handle_key(key(KeyCode::Enter)), JumpOutcome::Accept(1));
}
#[test]
fn enter_with_no_matches_is_a_noop() {
let mut j = jump_over(&["a", "b"]);
type_str(&mut j, "zzzz");
assert_eq!(j.matched(), 0);
assert_eq!(j.handle_key(key(KeyCode::Enter)), JumpOutcome::Stay);
}
#[test]
fn esc_and_ctrl_c_cancel() {
let mut j = jump_over(&["a"]);
assert_eq!(j.handle_key(key(KeyCode::Esc)), JumpOutcome::Cancel);
assert_eq!(j.handle_key(ctrl('c')), JumpOutcome::Cancel);
}
#[test]
fn backspace_edits_the_query_and_reranks() {
let mut j = jump_over(&["app.rs", "api.rs"]);
type_str(&mut j, "app");
assert_eq!(result_ids(&j), vec![0]);
j.handle_key(key(KeyCode::Backspace)); assert_eq!(j.query(), "ap");
assert_eq!(result_ids(&j), vec![0, 1]);
}
#[test]
fn a_new_query_char_resets_the_selection() {
let mut j = jump_over(&["a1", "a2", "a3"]);
type_str(&mut j, "a");
j.handle_key(ctrl('n'));
j.handle_key(ctrl('n'));
assert_eq!(j.selected(), 2);
type_str(&mut j, "3"); assert_eq!(j.selected(), 0);
}
#[test]
fn scroll_follows_selection_within_a_small_viewport() {
let mut j = jump_over(&["a1", "a2", "a3", "a4", "a5"]);
type_str(&mut j, "a");
j.set_viewport(2); for _ in 0..4 {
j.handle_key(ctrl('n'));
}
assert_eq!(j.selected(), 4);
assert!(
j.scroll() <= 4 && 4 < j.scroll() + 2,
"scroll={}",
j.scroll()
);
}
#[test]
fn cursor_can_move_and_edit_mid_query() {
let mut j = jump_over(&["abc"]);
type_str(&mut j, "ac");
j.handle_key(key(KeyCode::Left)); assert_eq!(j.visual_cursor(), 1);
type_str(&mut j, "b"); assert_eq!(j.query(), "abc");
assert_eq!(result_ids(&j), vec![0]);
}
#[test]
fn home_end_move_the_caret_to_the_bounds() {
let mut j = jump_over(&["x"]);
type_str(&mut j, "abc");
j.handle_key(key(KeyCode::Home));
assert_eq!(j.visual_cursor(), 0);
j.handle_key(key(KeyCode::End));
assert_eq!(j.visual_cursor(), 3);
}
#[test]
fn ctrl_w_deletes_the_previous_word() {
let mut j = jump_over(&["foo", "bar"]);
type_str(&mut j, "foo bar");
j.handle_key(ctrl('w')); assert!(j.query().starts_with("foo"));
assert!(!j.query().contains("bar"), "query: {:?}", j.query());
}
#[test]
fn moving_the_caret_does_not_rerank_or_reset_selection() {
let mut j = jump_over(&["a1", "a2", "a3"]);
type_str(&mut j, "a");
j.handle_key(ctrl('n'));
assert_eq!(j.selected(), 1);
j.handle_key(key(KeyCode::Left)); assert_eq!(j.selected(), 1);
assert_eq!(result_ids(&j), vec![0, 1, 2]);
}
}