Skip to main content

ite_cli/
jump.rs

1//! The jump picker: a full-screen fuzzy finder over every node in the current
2//! tree view.
3//!
4//! Pressing `/` opens a [`Jump`] over the current tree. Typing filters a flat,
5//! score-ranked list of candidate paths; accepting one asks the app to move
6//! focus to that node (expanding the containers along the way). The picker owns
7//! no I/O and no rendering — it is a pure state machine, so it is unit-tested
8//! exactly like the rest of `app.rs`. Where it is drawn is the renderer's
9//! concern (see `ui::render_jump` and ADR-0002); the picker only exposes state.
10
11use 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
20/// A node path indexed by node id. `active` records whether the node belongs to
21/// the current narrowed view without breaking dense id-based lookup.
22struct Candidate {
23    path: String,
24    active: bool,
25}
26
27/// One ranked result: the matched node, its score, and the character indices in
28/// its path that matched (sorted + deduplicated, for highlighting).
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct Match {
31    pub id: NodeId,
32    pub score: u32,
33    pub indices: Vec<u32>,
34}
35
36/// What the caller must do after handing a key to the picker.
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub enum JumpOutcome {
39    /// Stay in the picker and redraw.
40    Stay,
41    /// Move focus to this node (expanding its ancestors) and close the picker.
42    Accept(NodeId),
43    /// Close the picker; focus is untouched, so the caller returns to it.
44    Cancel,
45}
46
47/// The jump picker's state: the query line (an editable `tui_input::Input`), the
48/// candidate set (built once on open), the ranked results, and view state
49/// (selection + scroll).
50pub struct Jump {
51    input: Input,
52    /// Dense by node id; inactive entries sit outside the current view root.
53    candidates: Vec<Candidate>,
54    candidate_count: usize,
55    results: Vec<Match>,
56    /// Index into `results` of the highlighted row.
57    selected: usize,
58    /// First `results` row shown; kept so the selection stays visible.
59    scroll: usize,
60    /// Result rows the viewport can show; the renderer updates it each frame.
61    page_height: usize,
62    matcher: Matcher,
63}
64
65impl Jump {
66    /// Open a picker over every node in the current tree view, ranked for the
67    /// empty query (i.e. all candidates in tree order).
68    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    /// Route a key to the picker. Result-navigation, accept, and cancel keys are
94    /// handled here; everything else is offered to the query line's `tui_input`
95    /// editor. All of these are fixed (not the user's configurable keymap — see
96    /// the v1 boundary in `app.rs`).
97    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            // Query editing: repackage the key as a crossterm event and let
113            // tui-input's own backend classify and apply it — we don't duplicate
114            // its map of which keys are edits. Re-rank only when the value
115            // actually changed (cursor-only moves don't re-filter).
116            _ => {
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    // Fuzzy scoring recomputes over the full candidate list on every keystroke.
131    // Incremental narrowing (re-scoring only the previous keystroke's survivors)
132    // was considered and deliberately deferred: it speeds up only keystrokes
133    // 2..N, never the first full scan, and it adds a survivor cache plus
134    // correctness fallbacks for negation (`!atom`) and non-append edits. At
135    // ite's realistic sizes a full recompute is imperceptible, and parallel
136    // scoring is likewise deferred. See docs/adr/0001-fuzzy-jump-matching.md.
137    fn rank(&mut self) {
138        self.selected = 0;
139        self.scroll = 0;
140        self.results.clear();
141        if self.input.value().is_empty() {
142            // Empty query: every candidate, in tree order, unhighlighted.
143            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` are appended per atom, unsorted; sort+dedup so the
172                // renderer can group matched runs.
173                indices.sort_unstable();
174                indices.dedup();
175                self.results.push(Match { id, score, indices });
176            }
177        }
178        // Rank: score desc, then shorter path, then tree order — deterministic.
179        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    /// Scroll the minimum amount to keep the selection inside the viewport.
203    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    // --- Accessors for the renderer ---
213
214    pub fn query(&self) -> &str {
215        self.input.value()
216    }
217
218    /// The caret's display column within the query text (accounts for wide
219    /// characters); pairs with [`Jump::visual_scroll`] for rendering.
220    pub fn visual_cursor(&self) -> usize {
221        self.input.visual_cursor()
222    }
223
224    /// The horizontal scroll offset (in display columns) that keeps the caret
225    /// visible in a query field `width` columns wide.
226    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    /// Number of candidates matched by the current query.
243    pub fn matched(&self) -> usize {
244        self.results.len()
245    }
246
247    /// Total number of candidates in the current tree view.
248    pub fn total(&self) -> usize {
249        self.candidate_count
250    }
251
252    /// The path text for a node id (what was matched, and what is displayed).
253    pub fn path(&self, id: NodeId) -> &str {
254        &self.candidates[id].path
255    }
256
257    /// Tell the picker how many result rows the viewport can show, keeping the
258    /// selection visible. Called by the renderer each frame.
259    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    /// A flat tree whose nodes carry the given relpaths (hierarchy is irrelevant
271    /// to ranking, which matches on `relpath` alone). Node ids are `0..paths.len`.
272    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        // Only "src/app.rs" contains a-p-p-r-s as a subsequence.
335        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        // Both match; the shorter, prefix-anchored path outranks the buried one.
343        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        // Needs both "src" AND "rs"; only src/app.rs has both.
365        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')); // clamp at end
385        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)); // clamp at start
391        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')); // select id 1
399        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)); // query now "ap"
423        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"); // query "a3" reranks
435        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); // only two rows visible
443        for _ in 0..4 {
444            j.handle_key(ctrl('n'));
445        }
446        assert_eq!(j.selected(), 4);
447        // Selection must be within [scroll, scroll + 2).
448        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)); // caret between 'a' and 'c'
460        assert_eq!(j.visual_cursor(), 1);
461        type_str(&mut j, "b"); // insert at the caret
462        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')); // kill "bar"
481        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)); // cursor-only: no re-rank
492        assert_eq!(j.selected(), 1);
493        assert_eq!(result_ids(&j), vec![0, 1, 2]);
494    }
495}