Skip to main content

ite_cli/
jump.rs

1//! The jump picker: a full-screen fuzzy finder over every node's path.
2//!
3//! Pressing `/` opens a [`Jump`] over the current tree. Typing filters a flat,
4//! score-ranked list of candidate paths; accepting one asks the app to move
5//! focus to that node (expanding the containers along the way). The picker owns
6//! no I/O and no rendering — it is a pure state machine, so it is unit-tested
7//! exactly like the rest of `app.rs`. Where it is drawn is the renderer's
8//! concern (see `ui::render_jump` and ADR-0002); the picker only exposes state.
9
10use 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
19/// A node offered to the picker. Matched on `path` (the node's `relpath`: a
20/// root-relative path for a dir scan, a JSON Pointer for `--json`).
21struct Candidate {
22    id: NodeId,
23    path: String,
24}
25
26/// One ranked result: the matched node, its score, and the character indices in
27/// its path that matched (sorted + deduplicated, for highlighting).
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct Match {
30    pub id: NodeId,
31    pub score: u32,
32    pub indices: Vec<u32>,
33}
34
35/// What the caller must do after handing a key to the picker.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub enum JumpOutcome {
38    /// Stay in the picker and redraw.
39    Stay,
40    /// Move focus to this node (expanding its ancestors) and close the picker.
41    Accept(NodeId),
42    /// Close the picker; focus is untouched, so the caller returns to it.
43    Cancel,
44}
45
46/// The jump picker's state: the query line (an editable `tui_input::Input`), the
47/// candidate set (built once on open), the ranked results, and view state
48/// (selection + scroll).
49pub struct Jump {
50    input: Input,
51    candidates: Vec<Candidate>,
52    results: Vec<Match>,
53    /// Index into `results` of the highlighted row.
54    selected: usize,
55    /// First `results` row shown; kept so the selection stays visible.
56    scroll: usize,
57    /// Result rows the viewport can show; the renderer updates it each frame.
58    page_height: usize,
59    matcher: Matcher,
60}
61
62impl Jump {
63    /// Open a picker over every node in `tree`, ranked for the empty query
64    /// (i.e. all candidates in tree order).
65    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    /// Route a key to the picker. Result-navigation, accept, and cancel keys are
86    /// handled here; everything else is offered to the query line's `tui_input`
87    /// editor. All of these are fixed (not the user's configurable keymap — see
88    /// the v1 boundary in `app.rs`).
89    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            // Query editing: repackage the key as a crossterm event and let
105            // tui-input's own backend classify and apply it — we don't duplicate
106            // its map of which keys are edits. Re-rank only when the value
107            // actually changed (cursor-only moves don't re-filter).
108            _ => {
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    // Fuzzy scoring recomputes over the full candidate list on every keystroke.
123    // Incremental narrowing (re-scoring only the previous keystroke's survivors)
124    // was considered and deliberately deferred: it speeds up only keystrokes
125    // 2..N, never the first full scan, and it adds a survivor cache plus
126    // correctness fallbacks for negation (`!atom`) and non-append edits. At
127    // ite's realistic sizes a full recompute is imperceptible, and parallel
128    // scoring is likewise deferred. See docs/adr/0001-fuzzy-jump-matching.md.
129    fn rank(&mut self) {
130        self.selected = 0;
131        self.scroll = 0;
132        self.results.clear();
133        if self.input.value().is_empty() {
134            // Empty query: every candidate, in tree order, unhighlighted.
135            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` are appended per atom, unsorted; sort+dedup so the
157                // renderer can group matched runs.
158                indices.sort_unstable();
159                indices.dedup();
160                self.results.push(Match {
161                    id: c.id,
162                    score,
163                    indices,
164                });
165            }
166        }
167        // Rank: score desc, then shorter path, then tree order — deterministic.
168        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    /// Scroll the minimum amount to keep the selection inside the viewport.
192    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    // --- Accessors for the renderer ---
202
203    pub fn query(&self) -> &str {
204        self.input.value()
205    }
206
207    /// The caret's display column within the query text (accounts for wide
208    /// characters); pairs with [`Jump::visual_scroll`] for rendering.
209    pub fn visual_cursor(&self) -> usize {
210        self.input.visual_cursor()
211    }
212
213    /// The horizontal scroll offset (in display columns) that keeps the caret
214    /// visible in a query field `width` columns wide.
215    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    /// Number of candidates matched by the current query.
232    pub fn matched(&self) -> usize {
233        self.results.len()
234    }
235
236    /// Total number of candidates (every node).
237    pub fn total(&self) -> usize {
238        self.candidates.len()
239    }
240
241    /// The path text for a node id (what was matched, and what is displayed).
242    pub fn path(&self, id: NodeId) -> &str {
243        &self.candidates[id].path
244    }
245
246    /// Tell the picker how many result rows the viewport can show, keeping the
247    /// selection visible. Called by the renderer each frame.
248    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    /// A flat tree whose nodes carry the given relpaths (hierarchy is irrelevant
260    /// to ranking, which matches on `relpath` alone). Node ids are `0..paths.len`.
261    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        // Only "src/app.rs" contains a-p-p-r-s as a subsequence.
304        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        // Both match; the shorter, prefix-anchored path outranks the buried one.
312        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        // Needs both "src" AND "rs"; only src/app.rs has both.
334        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')); // clamp at end
354        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)); // clamp at start
360        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')); // select id 1
368        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)); // query now "ap"
392        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"); // query "a3" reranks
404        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); // only two rows visible
412        for _ in 0..4 {
413            j.handle_key(ctrl('n'));
414        }
415        assert_eq!(j.selected(), 4);
416        // Selection must be within [scroll, scroll + 2).
417        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)); // caret between 'a' and 'c'
429        assert_eq!(j.visual_cursor(), 1);
430        type_str(&mut j, "b"); // insert at the caret
431        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')); // kill "bar"
450        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)); // cursor-only: no re-rank
461        assert_eq!(j.selected(), 1);
462        assert_eq!(result_ids(&j), vec![0, 1, 2]);
463    }
464}