1use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
11use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
12use nucleo_matcher::{Config, Matcher, Utf32Str};
13use tui_input::Input;
14use tui_input::backend::crossterm::EventHandler;
15
16use crate::keys::Key;
17use crate::tree::{NodeId, Tree};
18
19struct Candidate {
22 id: NodeId,
23 path: String,
24}
25
26#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct Match {
30 pub id: NodeId,
31 pub score: u32,
32 pub indices: Vec<u32>,
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
37pub enum JumpOutcome {
38 Stay,
40 Accept(NodeId),
42 Cancel,
44}
45
46pub struct Jump {
50 input: Input,
51 candidates: Vec<Candidate>,
52 results: Vec<Match>,
53 selected: usize,
55 scroll: usize,
57 page_height: usize,
59 matcher: Matcher,
60}
61
62impl Jump {
63 pub fn open(tree: &Tree) -> Self {
66 let candidates = (0..tree.len())
67 .map(|id| Candidate {
68 id,
69 path: tree.node(id).action.relpath.to_string_lossy().into_owned(),
70 })
71 .collect();
72 let mut jump = Self {
73 input: Input::default(),
74 candidates,
75 results: Vec::new(),
76 selected: 0,
77 scroll: 0,
78 page_height: 10,
79 matcher: Matcher::new(Config::DEFAULT.match_paths()),
80 };
81 jump.rank();
82 jump
83 }
84
85 pub fn handle_key(&mut self, key: Key) -> JumpOutcome {
90 let ctrl = key.mods == KeyModifiers::CONTROL;
91 match key.code {
92 KeyCode::Esc => return JumpOutcome::Cancel,
93 KeyCode::Char('c') if ctrl => return JumpOutcome::Cancel,
94 KeyCode::Enter => {
95 return match self.results.get(self.selected) {
96 Some(m) => JumpOutcome::Accept(m.id),
97 None => JumpOutcome::Stay,
98 };
99 }
100 KeyCode::Down => self.move_selection(1),
101 KeyCode::Up => self.move_selection(-1),
102 KeyCode::Char('j' | 'n') if ctrl => self.move_selection(1),
103 KeyCode::Char('k' | 'p') if ctrl => self.move_selection(-1),
104 _ => {
109 let event = Event::Key(KeyEvent::new(key.code, key.mods));
110 if self
111 .input
112 .handle_event(&event)
113 .is_some_and(|change| change.value)
114 {
115 self.rank();
116 }
117 }
118 }
119 JumpOutcome::Stay
120 }
121
122 fn rank(&mut self) {
130 self.selected = 0;
131 self.scroll = 0;
132 self.results.clear();
133 if self.input.value().is_empty() {
134 self.results = self
136 .candidates
137 .iter()
138 .map(|c| Match {
139 id: c.id,
140 score: 0,
141 indices: Vec::new(),
142 })
143 .collect();
144 return;
145 }
146 let pattern = Pattern::parse(
147 self.input.value(),
148 CaseMatching::Smart,
149 Normalization::Smart,
150 );
151 let mut buf = Vec::new();
152 for c in &self.candidates {
153 let mut indices = Vec::new();
154 let haystack = Utf32Str::new(&c.path, &mut buf);
155 if let Some(score) = pattern.indices(haystack, &mut self.matcher, &mut indices) {
156 indices.sort_unstable();
159 indices.dedup();
160 self.results.push(Match {
161 id: c.id,
162 score,
163 indices,
164 });
165 }
166 }
167 let candidates = &self.candidates;
169 self.results.sort_by(|a, b| {
170 b.score
171 .cmp(&a.score)
172 .then_with(|| {
173 candidates[a.id]
174 .path
175 .len()
176 .cmp(&candidates[b.id].path.len())
177 })
178 .then_with(|| a.id.cmp(&b.id))
179 });
180 }
181
182 fn move_selection(&mut self, delta: isize) {
183 if self.results.is_empty() {
184 return;
185 }
186 let last = self.results.len() as isize - 1;
187 self.selected = (self.selected as isize + delta).clamp(0, last) as usize;
188 self.follow_selection();
189 }
190
191 fn follow_selection(&mut self) {
193 let height = self.page_height.max(1);
194 if self.selected < self.scroll {
195 self.scroll = self.selected;
196 } else if self.selected >= self.scroll + height {
197 self.scroll = self.selected + 1 - height;
198 }
199 }
200
201 pub fn query(&self) -> &str {
204 self.input.value()
205 }
206
207 pub fn visual_cursor(&self) -> usize {
210 self.input.visual_cursor()
211 }
212
213 pub fn visual_scroll(&self, width: usize) -> usize {
216 self.input.visual_scroll(width)
217 }
218
219 pub fn results(&self) -> &[Match] {
220 &self.results
221 }
222
223 pub fn selected(&self) -> usize {
224 self.selected
225 }
226
227 pub fn scroll(&self) -> usize {
228 self.scroll
229 }
230
231 pub fn matched(&self) -> usize {
233 self.results.len()
234 }
235
236 pub fn total(&self) -> usize {
238 self.candidates.len()
239 }
240
241 pub fn path(&self, id: NodeId) -> &str {
243 &self.candidates[id].path
244 }
245
246 pub fn set_viewport(&mut self, rows: usize) {
249 self.page_height = rows.max(1);
250 self.follow_selection();
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::tree::ActionValues;
258
259 fn jump_over(paths: &[&str]) -> Jump {
262 let mut tree = Tree::new();
263 for p in paths {
264 tree.push(None, *p, false, ActionValues::new(*p, *p, *p));
265 }
266 Jump::open(&tree)
267 }
268
269 fn ch(c: char) -> Key {
270 Key::new(KeyCode::Char(c), KeyModifiers::NONE)
271 }
272
273 fn ctrl(c: char) -> Key {
274 Key::new(KeyCode::Char(c), KeyModifiers::CONTROL)
275 }
276
277 fn key(code: KeyCode) -> Key {
278 Key::new(code, KeyModifiers::NONE)
279 }
280
281 fn type_str(j: &mut Jump, s: &str) {
282 for c in s.chars() {
283 j.handle_key(ch(c));
284 }
285 }
286
287 fn result_ids(j: &Jump) -> Vec<NodeId> {
288 j.results().iter().map(|m| m.id).collect()
289 }
290
291 #[test]
292 fn empty_query_lists_all_candidates_in_tree_order() {
293 let j = jump_over(&["a", "b", "c"]);
294 assert_eq!(result_ids(&j), vec![0, 1, 2]);
295 assert_eq!(j.total(), 3);
296 assert_eq!(j.matched(), 3);
297 }
298
299 #[test]
300 fn typing_filters_to_fuzzy_matches() {
301 let mut j = jump_over(&["src/app.rs", "apple/pie", "README"]);
302 type_str(&mut j, "apprs");
303 assert_eq!(result_ids(&j), vec![0]);
305 }
306
307 #[test]
308 fn ranking_puts_the_stronger_match_first() {
309 let mut j = jump_over(&["app.rs", "zz/app.rs"]);
310 type_str(&mut j, "app");
311 assert_eq!(result_ids(&j), vec![0, 1]);
313 }
314
315 #[test]
316 fn extended_syntax_prefix_anchor() {
317 let mut j = jump_over(&["src/a", "x/src"]);
318 type_str(&mut j, "^src");
319 assert_eq!(result_ids(&j), vec![0]);
320 }
321
322 #[test]
323 fn extended_syntax_negation_excludes() {
324 let mut j = jump_over(&["app.rs", "app_test.rs"]);
325 type_str(&mut j, "app !test");
326 assert_eq!(result_ids(&j), vec![0]);
327 }
328
329 #[test]
330 fn space_is_a_second_and_atom() {
331 let mut j = jump_over(&["src/app.rs", "src/xx"]);
332 type_str(&mut j, "src rs");
333 assert_eq!(result_ids(&j), vec![0]);
335 }
336
337 #[test]
338 fn match_indices_are_recorded_for_highlighting() {
339 let mut j = jump_over(&["abcd"]);
340 type_str(&mut j, "abc");
341 assert_eq!(j.results()[0].indices, vec![0, 1, 2]);
342 }
343
344 #[test]
345 fn navigation_keys_move_and_clamp_selection() {
346 let mut j = jump_over(&["a1", "a2", "a3"]);
347 type_str(&mut j, "a");
348 assert_eq!(j.selected(), 0);
349 j.handle_key(ctrl('n'));
350 assert_eq!(j.selected(), 1);
351 j.handle_key(key(KeyCode::Down));
352 assert_eq!(j.selected(), 2);
353 j.handle_key(ctrl('n')); assert_eq!(j.selected(), 2);
355 j.handle_key(ctrl('p'));
356 assert_eq!(j.selected(), 1);
357 j.handle_key(ctrl('k'));
358 assert_eq!(j.selected(), 0);
359 j.handle_key(key(KeyCode::Up)); assert_eq!(j.selected(), 0);
361 }
362
363 #[test]
364 fn enter_accepts_the_selected_result() {
365 let mut j = jump_over(&["a1", "a2", "a3"]);
366 type_str(&mut j, "a");
367 j.handle_key(ctrl('n')); assert_eq!(j.handle_key(key(KeyCode::Enter)), JumpOutcome::Accept(1));
369 }
370
371 #[test]
372 fn enter_with_no_matches_is_a_noop() {
373 let mut j = jump_over(&["a", "b"]);
374 type_str(&mut j, "zzzz");
375 assert_eq!(j.matched(), 0);
376 assert_eq!(j.handle_key(key(KeyCode::Enter)), JumpOutcome::Stay);
377 }
378
379 #[test]
380 fn esc_and_ctrl_c_cancel() {
381 let mut j = jump_over(&["a"]);
382 assert_eq!(j.handle_key(key(KeyCode::Esc)), JumpOutcome::Cancel);
383 assert_eq!(j.handle_key(ctrl('c')), JumpOutcome::Cancel);
384 }
385
386 #[test]
387 fn backspace_edits_the_query_and_reranks() {
388 let mut j = jump_over(&["app.rs", "api.rs"]);
389 type_str(&mut j, "app");
390 assert_eq!(result_ids(&j), vec![0]);
391 j.handle_key(key(KeyCode::Backspace)); assert_eq!(j.query(), "ap");
393 assert_eq!(result_ids(&j), vec![0, 1]);
394 }
395
396 #[test]
397 fn a_new_query_char_resets_the_selection() {
398 let mut j = jump_over(&["a1", "a2", "a3"]);
399 type_str(&mut j, "a");
400 j.handle_key(ctrl('n'));
401 j.handle_key(ctrl('n'));
402 assert_eq!(j.selected(), 2);
403 type_str(&mut j, "3"); assert_eq!(j.selected(), 0);
405 }
406
407 #[test]
408 fn scroll_follows_selection_within_a_small_viewport() {
409 let mut j = jump_over(&["a1", "a2", "a3", "a4", "a5"]);
410 type_str(&mut j, "a");
411 j.set_viewport(2); for _ in 0..4 {
413 j.handle_key(ctrl('n'));
414 }
415 assert_eq!(j.selected(), 4);
416 assert!(
418 j.scroll() <= 4 && 4 < j.scroll() + 2,
419 "scroll={}",
420 j.scroll()
421 );
422 }
423
424 #[test]
425 fn cursor_can_move_and_edit_mid_query() {
426 let mut j = jump_over(&["abc"]);
427 type_str(&mut j, "ac");
428 j.handle_key(key(KeyCode::Left)); assert_eq!(j.visual_cursor(), 1);
430 type_str(&mut j, "b"); assert_eq!(j.query(), "abc");
432 assert_eq!(result_ids(&j), vec![0]);
433 }
434
435 #[test]
436 fn home_end_move_the_caret_to_the_bounds() {
437 let mut j = jump_over(&["x"]);
438 type_str(&mut j, "abc");
439 j.handle_key(key(KeyCode::Home));
440 assert_eq!(j.visual_cursor(), 0);
441 j.handle_key(key(KeyCode::End));
442 assert_eq!(j.visual_cursor(), 3);
443 }
444
445 #[test]
446 fn ctrl_w_deletes_the_previous_word() {
447 let mut j = jump_over(&["foo", "bar"]);
448 type_str(&mut j, "foo bar");
449 j.handle_key(ctrl('w')); assert!(j.query().starts_with("foo"));
451 assert!(!j.query().contains("bar"), "query: {:?}", j.query());
452 }
453
454 #[test]
455 fn moving_the_caret_does_not_rerank_or_reset_selection() {
456 let mut j = jump_over(&["a1", "a2", "a3"]);
457 type_str(&mut j, "a");
458 j.handle_key(ctrl('n'));
459 assert_eq!(j.selected(), 1);
460 j.handle_key(key(KeyCode::Left)); assert_eq!(j.selected(), 1);
462 assert_eq!(result_ids(&j), vec![0, 1, 2]);
463 }
464}