1use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
12use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
13use nucleo_matcher::{Config, Matcher, Utf32Str};
14use tui_input::Input;
15use tui_input::backend::crossterm::EventHandler;
16
17use crate::keys::Key;
18use crate::tree::{NodeId, Tree};
19
20struct Candidate {
23 path: String,
24 active: bool,
25}
26
27#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct Match {
31 pub id: NodeId,
32 pub score: u32,
33 pub indices: Vec<u32>,
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
38pub enum JumpOutcome {
39 Stay,
41 Accept(NodeId),
43 Cancel,
45}
46
47pub struct Jump {
51 input: Input,
52 candidates: Vec<Candidate>,
54 candidate_count: usize,
55 results: Vec<Match>,
56 selected: usize,
58 scroll: usize,
60 page_height: usize,
62 matcher: Matcher,
63}
64
65impl Jump {
66 pub fn open(tree: &Tree) -> Self {
69 let candidates: Vec<_> = (0..tree.len())
70 .map(|id| Candidate {
71 path: tree.node(id).action.relpath.to_string_lossy().into_owned(),
72 active: tree.is_in_view(id),
73 })
74 .collect();
75 let candidate_count = candidates
76 .iter()
77 .filter(|candidate| candidate.active)
78 .count();
79 let mut jump = Self {
80 input: Input::default(),
81 candidates,
82 candidate_count,
83 results: Vec::new(),
84 selected: 0,
85 scroll: 0,
86 page_height: 10,
87 matcher: Matcher::new(Config::DEFAULT.match_paths()),
88 };
89 jump.rank();
90 jump
91 }
92
93 pub fn handle_key(&mut self, key: Key) -> JumpOutcome {
98 let ctrl = key.mods == KeyModifiers::CONTROL;
99 match key.code {
100 KeyCode::Esc => return JumpOutcome::Cancel,
101 KeyCode::Char('c') if ctrl => return JumpOutcome::Cancel,
102 KeyCode::Enter => {
103 return match self.results.get(self.selected) {
104 Some(m) => JumpOutcome::Accept(m.id),
105 None => JumpOutcome::Stay,
106 };
107 }
108 KeyCode::Down => self.move_selection(1),
109 KeyCode::Up => self.move_selection(-1),
110 KeyCode::Char('j' | 'n') if ctrl => self.move_selection(1),
111 KeyCode::Char('k' | 'p') if ctrl => self.move_selection(-1),
112 _ => {
117 let event = Event::Key(KeyEvent::new(key.code, key.mods));
118 if self
119 .input
120 .handle_event(&event)
121 .is_some_and(|change| change.value)
122 {
123 self.rank();
124 }
125 }
126 }
127 JumpOutcome::Stay
128 }
129
130 fn rank(&mut self) {
138 self.selected = 0;
139 self.scroll = 0;
140 self.results.clear();
141 if self.input.value().is_empty() {
142 self.results = self
144 .candidates
145 .iter()
146 .enumerate()
147 .filter(|(_, candidate)| candidate.active)
148 .map(|(id, _)| Match {
149 id,
150 score: 0,
151 indices: Vec::new(),
152 })
153 .collect();
154 return;
155 }
156 let pattern = Pattern::parse(
157 self.input.value(),
158 CaseMatching::Smart,
159 Normalization::Smart,
160 );
161 let mut buf = Vec::new();
162 for (id, candidate) in self
163 .candidates
164 .iter()
165 .enumerate()
166 .filter(|(_, candidate)| candidate.active)
167 {
168 let mut indices = Vec::new();
169 let haystack = Utf32Str::new(&candidate.path, &mut buf);
170 if let Some(score) = pattern.indices(haystack, &mut self.matcher, &mut indices) {
171 indices.sort_unstable();
174 indices.dedup();
175 self.results.push(Match { id, score, indices });
176 }
177 }
178 let candidates = &self.candidates;
180 self.results.sort_by(|a, b| {
181 b.score
182 .cmp(&a.score)
183 .then_with(|| {
184 candidates[a.id]
185 .path
186 .len()
187 .cmp(&candidates[b.id].path.len())
188 })
189 .then_with(|| a.id.cmp(&b.id))
190 });
191 }
192
193 fn move_selection(&mut self, delta: isize) {
194 if self.results.is_empty() {
195 return;
196 }
197 let last = self.results.len() as isize - 1;
198 self.selected = (self.selected as isize + delta).clamp(0, last) as usize;
199 self.follow_selection();
200 }
201
202 fn follow_selection(&mut self) {
204 let height = self.page_height.max(1);
205 if self.selected < self.scroll {
206 self.scroll = self.selected;
207 } else if self.selected >= self.scroll + height {
208 self.scroll = self.selected + 1 - height;
209 }
210 }
211
212 pub fn query(&self) -> &str {
215 self.input.value()
216 }
217
218 pub fn visual_cursor(&self) -> usize {
221 self.input.visual_cursor()
222 }
223
224 pub fn visual_scroll(&self, width: usize) -> usize {
227 self.input.visual_scroll(width)
228 }
229
230 pub fn results(&self) -> &[Match] {
231 &self.results
232 }
233
234 pub fn selected(&self) -> usize {
235 self.selected
236 }
237
238 pub fn scroll(&self) -> usize {
239 self.scroll
240 }
241
242 pub fn matched(&self) -> usize {
244 self.results.len()
245 }
246
247 pub fn total(&self) -> usize {
249 self.candidate_count
250 }
251
252 pub fn path(&self, id: NodeId) -> &str {
254 &self.candidates[id].path
255 }
256
257 pub fn set_viewport(&mut self, rows: usize) {
260 self.page_height = rows.max(1);
261 self.follow_selection();
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use crate::tree::ActionValues;
269
270 fn jump_over(paths: &[&str]) -> Jump {
273 let mut tree = Tree::new();
274 for p in paths {
275 tree.push(None, *p, false, ActionValues::new(*p, *p, *p));
276 }
277 Jump::open(&tree)
278 }
279
280 fn ch(c: char) -> Key {
281 Key::new(KeyCode::Char(c), KeyModifiers::NONE)
282 }
283
284 fn ctrl(c: char) -> Key {
285 Key::new(KeyCode::Char(c), KeyModifiers::CONTROL)
286 }
287
288 fn key(code: KeyCode) -> Key {
289 Key::new(code, KeyModifiers::NONE)
290 }
291
292 fn type_str(j: &mut Jump, s: &str) {
293 for c in s.chars() {
294 j.handle_key(ch(c));
295 }
296 }
297
298 fn result_ids(j: &Jump) -> Vec<NodeId> {
299 j.results().iter().map(|m| m.id).collect()
300 }
301
302 #[test]
303 fn empty_query_lists_all_candidates_in_tree_order() {
304 let j = jump_over(&["a", "b", "c"]);
305 assert_eq!(result_ids(&j), vec![0, 1, 2]);
306 assert_eq!(j.total(), 3);
307 assert_eq!(j.matched(), 3);
308 }
309
310 #[test]
311 fn narrowed_tree_only_offers_nodes_under_the_view_root() {
312 let mut tree = Tree::new();
313 tree.push(None, "outside", false, ActionValues::new("", "", "outside"));
314 let root = tree.push(None, "scope", true, ActionValues::new("", "", "scope"));
315 let child = tree.push(
316 Some(root),
317 "child",
318 false,
319 ActionValues::new("", "", "scope/child"),
320 );
321 tree.set_view_root(Some(root));
322
323 let jump = Jump::open(&tree);
324
325 assert_eq!(result_ids(&jump), vec![root, child]);
326 assert_eq!(jump.total(), 2);
327 assert_eq!(jump.path(child), "scope/child");
328 }
329
330 #[test]
331 fn typing_filters_to_fuzzy_matches() {
332 let mut j = jump_over(&["src/app.rs", "apple/pie", "README"]);
333 type_str(&mut j, "apprs");
334 assert_eq!(result_ids(&j), vec![0]);
336 }
337
338 #[test]
339 fn ranking_puts_the_stronger_match_first() {
340 let mut j = jump_over(&["app.rs", "zz/app.rs"]);
341 type_str(&mut j, "app");
342 assert_eq!(result_ids(&j), vec![0, 1]);
344 }
345
346 #[test]
347 fn extended_syntax_prefix_anchor() {
348 let mut j = jump_over(&["src/a", "x/src"]);
349 type_str(&mut j, "^src");
350 assert_eq!(result_ids(&j), vec![0]);
351 }
352
353 #[test]
354 fn extended_syntax_negation_excludes() {
355 let mut j = jump_over(&["app.rs", "app_test.rs"]);
356 type_str(&mut j, "app !test");
357 assert_eq!(result_ids(&j), vec![0]);
358 }
359
360 #[test]
361 fn space_is_a_second_and_atom() {
362 let mut j = jump_over(&["src/app.rs", "src/xx"]);
363 type_str(&mut j, "src rs");
364 assert_eq!(result_ids(&j), vec![0]);
366 }
367
368 #[test]
369 fn match_indices_are_recorded_for_highlighting() {
370 let mut j = jump_over(&["abcd"]);
371 type_str(&mut j, "abc");
372 assert_eq!(j.results()[0].indices, vec![0, 1, 2]);
373 }
374
375 #[test]
376 fn navigation_keys_move_and_clamp_selection() {
377 let mut j = jump_over(&["a1", "a2", "a3"]);
378 type_str(&mut j, "a");
379 assert_eq!(j.selected(), 0);
380 j.handle_key(ctrl('n'));
381 assert_eq!(j.selected(), 1);
382 j.handle_key(key(KeyCode::Down));
383 assert_eq!(j.selected(), 2);
384 j.handle_key(ctrl('n')); assert_eq!(j.selected(), 2);
386 j.handle_key(ctrl('p'));
387 assert_eq!(j.selected(), 1);
388 j.handle_key(ctrl('k'));
389 assert_eq!(j.selected(), 0);
390 j.handle_key(key(KeyCode::Up)); assert_eq!(j.selected(), 0);
392 }
393
394 #[test]
395 fn enter_accepts_the_selected_result() {
396 let mut j = jump_over(&["a1", "a2", "a3"]);
397 type_str(&mut j, "a");
398 j.handle_key(ctrl('n')); assert_eq!(j.handle_key(key(KeyCode::Enter)), JumpOutcome::Accept(1));
400 }
401
402 #[test]
403 fn enter_with_no_matches_is_a_noop() {
404 let mut j = jump_over(&["a", "b"]);
405 type_str(&mut j, "zzzz");
406 assert_eq!(j.matched(), 0);
407 assert_eq!(j.handle_key(key(KeyCode::Enter)), JumpOutcome::Stay);
408 }
409
410 #[test]
411 fn esc_and_ctrl_c_cancel() {
412 let mut j = jump_over(&["a"]);
413 assert_eq!(j.handle_key(key(KeyCode::Esc)), JumpOutcome::Cancel);
414 assert_eq!(j.handle_key(ctrl('c')), JumpOutcome::Cancel);
415 }
416
417 #[test]
418 fn backspace_edits_the_query_and_reranks() {
419 let mut j = jump_over(&["app.rs", "api.rs"]);
420 type_str(&mut j, "app");
421 assert_eq!(result_ids(&j), vec![0]);
422 j.handle_key(key(KeyCode::Backspace)); assert_eq!(j.query(), "ap");
424 assert_eq!(result_ids(&j), vec![0, 1]);
425 }
426
427 #[test]
428 fn a_new_query_char_resets_the_selection() {
429 let mut j = jump_over(&["a1", "a2", "a3"]);
430 type_str(&mut j, "a");
431 j.handle_key(ctrl('n'));
432 j.handle_key(ctrl('n'));
433 assert_eq!(j.selected(), 2);
434 type_str(&mut j, "3"); assert_eq!(j.selected(), 0);
436 }
437
438 #[test]
439 fn scroll_follows_selection_within_a_small_viewport() {
440 let mut j = jump_over(&["a1", "a2", "a3", "a4", "a5"]);
441 type_str(&mut j, "a");
442 j.set_viewport(2); for _ in 0..4 {
444 j.handle_key(ctrl('n'));
445 }
446 assert_eq!(j.selected(), 4);
447 assert!(
449 j.scroll() <= 4 && 4 < j.scroll() + 2,
450 "scroll={}",
451 j.scroll()
452 );
453 }
454
455 #[test]
456 fn cursor_can_move_and_edit_mid_query() {
457 let mut j = jump_over(&["abc"]);
458 type_str(&mut j, "ac");
459 j.handle_key(key(KeyCode::Left)); assert_eq!(j.visual_cursor(), 1);
461 type_str(&mut j, "b"); assert_eq!(j.query(), "abc");
463 assert_eq!(result_ids(&j), vec![0]);
464 }
465
466 #[test]
467 fn home_end_move_the_caret_to_the_bounds() {
468 let mut j = jump_over(&["x"]);
469 type_str(&mut j, "abc");
470 j.handle_key(key(KeyCode::Home));
471 assert_eq!(j.visual_cursor(), 0);
472 j.handle_key(key(KeyCode::End));
473 assert_eq!(j.visual_cursor(), 3);
474 }
475
476 #[test]
477 fn ctrl_w_deletes_the_previous_word() {
478 let mut j = jump_over(&["foo", "bar"]);
479 type_str(&mut j, "foo bar");
480 j.handle_key(ctrl('w')); assert!(j.query().starts_with("foo"));
482 assert!(!j.query().contains("bar"), "query: {:?}", j.query());
483 }
484
485 #[test]
486 fn moving_the_caret_does_not_rerank_or_reset_selection() {
487 let mut j = jump_over(&["a1", "a2", "a3"]);
488 type_str(&mut j, "a");
489 j.handle_key(ctrl('n'));
490 assert_eq!(j.selected(), 1);
491 j.handle_key(key(KeyCode::Left)); assert_eq!(j.selected(), 1);
493 assert_eq!(result_ids(&j), vec![0, 1, 2]);
494 }
495}