Skip to main content

ite_cli/
jump.rs

1//! Pure state machine for the `/` jump picker: a fuzzy finder over every node
2//! in the current tree.
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//! Query editing is delegated to `tui_input`; fuzzy ranking belongs here.
11
12use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
13use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
14use nucleo_matcher::{Config, Matcher, Utf32Str};
15use tui_input::Input;
16use tui_input::backend::crossterm::EventHandler;
17
18use crate::keys::Key;
19use crate::tree::{NodeId, Tree};
20
21/// A node path indexed by node id. `active` records whether the node belongs to
22/// the current narrowed view without breaking dense id-based lookup.
23struct Candidate {
24    path: String,
25    active: bool,
26}
27
28/// One ranked result: the matched node, its score, and the character indices in
29/// its path that matched (sorted + deduplicated, for highlighting).
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct Match {
32    pub id: NodeId,
33    pub score: u32,
34    pub indices: Vec<u32>,
35}
36
37/// What the caller must do after handing a key to the picker.
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub enum JumpOutcome {
40    /// Stay in the picker and redraw.
41    Stay,
42    /// Move focus to this node (expanding its ancestors) and close the picker.
43    Accept(NodeId),
44    /// Close the picker; focus is untouched, so the caller returns to it.
45    Cancel,
46}
47
48/// The jump picker's state: the query line (an editable `tui_input::Input`), the
49/// candidate set (built once on open), the ranked results, and view state
50/// (selection + scroll).
51pub struct Jump {
52    input: Input,
53    /// Dense by node id; inactive entries sit outside the current view root.
54    candidates: Vec<Candidate>,
55    candidate_count: usize,
56    results: Vec<Match>,
57    /// Index into `results` of the highlighted row.
58    selected: usize,
59    /// First `results` row shown; kept so the selection stays visible.
60    scroll: usize,
61    /// Result rows the viewport can show; the renderer updates it each frame.
62    page_height: usize,
63    matcher: Matcher,
64}
65
66impl Jump {
67    /// Open a picker over every node in the current tree view, ranked for the
68    /// empty query (i.e. all candidates in tree order).
69    pub fn open(tree: &Tree) -> Self {
70        let candidates: Vec<_> = (0..tree.len())
71            .map(|id| Candidate {
72                path: tree.jump_key(id),
73                active: tree.is_in_view(id),
74            })
75            .collect();
76        let candidate_count = candidates
77            .iter()
78            .filter(|candidate| candidate.active)
79            .count();
80        let mut jump = Self {
81            input: Input::default(),
82            candidates,
83            candidate_count,
84            results: Vec::new(),
85            selected: 0,
86            scroll: 0,
87            page_height: 10,
88            matcher: Matcher::new(Config::DEFAULT.match_paths()),
89        };
90        jump.rank();
91        jump
92    }
93
94    /// Route a key to the picker. Result-navigation, accept, and cancel keys are
95    /// handled here; everything else is offered to the query line's `tui_input`
96    /// editor. All of these are fixed (not the user's configurable keymap — see
97    /// the v1 boundary in `app.rs`).
98    pub fn handle_key(&mut self, key: Key) -> JumpOutcome {
99        let ctrl = key.mods == KeyModifiers::CONTROL;
100        match key.code {
101            KeyCode::Esc => return JumpOutcome::Cancel,
102            KeyCode::Char('c') if ctrl => return JumpOutcome::Cancel,
103            KeyCode::Enter => {
104                return match self.results.get(self.selected) {
105                    Some(m) => JumpOutcome::Accept(m.id),
106                    None => JumpOutcome::Stay,
107                };
108            }
109            KeyCode::Down => self.move_selection(1),
110            KeyCode::Up => self.move_selection(-1),
111            KeyCode::Char('j' | 'n') if ctrl => self.move_selection(1),
112            KeyCode::Char('k' | 'p') if ctrl => self.move_selection(-1),
113            // Query editing: repackage the key as a crossterm event and let
114            // tui-input's own backend classify and apply it — we don't duplicate
115            // its map of which keys are edits. Re-rank only when the value
116            // actually changed (cursor-only moves don't re-filter).
117            _ => {
118                let event = Event::Key(KeyEvent::new(key.code, key.mods));
119                if self
120                    .input
121                    .handle_event(&event)
122                    .is_some_and(|change| change.value)
123                {
124                    self.rank();
125                }
126            }
127        }
128        JumpOutcome::Stay
129    }
130
131    // Fuzzy scoring recomputes over the full candidate list on every keystroke.
132    // Incremental narrowing (re-scoring only the previous keystroke's survivors)
133    // was considered and deliberately deferred: it speeds up only keystrokes
134    // 2..N, never the first full scan, and it adds a survivor cache plus
135    // correctness fallbacks for negation (`!atom`) and non-append edits. At
136    // ite's realistic sizes a full recompute is imperceptible, and parallel
137    // scoring is likewise deferred. See docs/adr/0001-fuzzy-jump-matching.md.
138    fn rank(&mut self) {
139        self.selected = 0;
140        self.scroll = 0;
141        self.results.clear();
142        if self.input.value().is_empty() {
143            // Empty query: every candidate, in tree order, unhighlighted.
144            self.results = self
145                .candidates
146                .iter()
147                .enumerate()
148                .filter(|(_, candidate)| candidate.active)
149                .map(|(id, _)| Match {
150                    id,
151                    score: 0,
152                    indices: Vec::new(),
153                })
154                .collect();
155            return;
156        }
157        let pattern = Pattern::parse(
158            self.input.value(),
159            CaseMatching::Smart,
160            Normalization::Smart,
161        );
162        let mut buf = Vec::new();
163        for (id, candidate) in self
164            .candidates
165            .iter()
166            .enumerate()
167            .filter(|(_, candidate)| candidate.active)
168        {
169            let mut indices = Vec::new();
170            let haystack = Utf32Str::new(&candidate.path, &mut buf);
171            if let Some(score) = pattern.indices(haystack, &mut self.matcher, &mut indices) {
172                // `indices` are appended per atom, unsorted; sort+dedup so the
173                // renderer can group matched runs.
174                indices.sort_unstable();
175                indices.dedup();
176                self.results.push(Match { id, score, indices });
177            }
178        }
179        // Rank: score desc, then shorter path, then tree order — deterministic.
180        let candidates = &self.candidates;
181        self.results.sort_by(|a, b| {
182            b.score
183                .cmp(&a.score)
184                .then_with(|| {
185                    candidates[a.id]
186                        .path
187                        .len()
188                        .cmp(&candidates[b.id].path.len())
189                })
190                .then_with(|| a.id.cmp(&b.id))
191        });
192    }
193
194    fn move_selection(&mut self, delta: isize) {
195        if self.results.is_empty() {
196            return;
197        }
198        let last = self.results.len() as isize - 1;
199        self.selected = (self.selected as isize + delta).clamp(0, last) as usize;
200        self.follow_selection();
201    }
202
203    /// Scroll the minimum amount to keep the selection inside the viewport.
204    fn follow_selection(&mut self) {
205        let height = self.page_height.max(1);
206        if self.selected < self.scroll {
207            self.scroll = self.selected;
208        } else if self.selected >= self.scroll + height {
209            self.scroll = self.selected + 1 - height;
210        }
211    }
212
213    // --- Accessors for the renderer ---
214
215    pub fn query(&self) -> &str {
216        self.input.value()
217    }
218
219    /// The caret's display column within the query text (accounts for wide
220    /// characters); pairs with [`Jump::visual_scroll`] for rendering.
221    pub fn visual_cursor(&self) -> usize {
222        self.input.visual_cursor()
223    }
224
225    /// The horizontal scroll offset (in display columns) that keeps the caret
226    /// visible in a query field `width` columns wide.
227    pub fn visual_scroll(&self, width: usize) -> usize {
228        self.input.visual_scroll(width)
229    }
230
231    pub fn results(&self) -> &[Match] {
232        &self.results
233    }
234
235    pub fn selected(&self) -> usize {
236        self.selected
237    }
238
239    pub fn scroll(&self) -> usize {
240        self.scroll
241    }
242
243    /// Number of candidates matched by the current query.
244    pub fn matched(&self) -> usize {
245        self.results.len()
246    }
247
248    /// Total number of candidates in the current tree view.
249    pub fn total(&self) -> usize {
250        self.candidate_count
251    }
252
253    /// The path text for a node id (what was matched, and what is displayed).
254    pub fn path(&self, id: NodeId) -> &str {
255        &self.candidates[id].path
256    }
257
258    /// Tell the picker how many result rows the viewport can show, keeping the
259    /// selection visible. Called by the renderer each frame.
260    pub fn set_viewport(&mut self, rows: usize) {
261        self.page_height = rows.max(1);
262        self.follow_selection();
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::tree::ActionValues;
270
271    /// A flat tree whose nodes carry the given relpaths (hierarchy is irrelevant
272    /// to ranking, which matches on `relpath` alone). Node ids are `0..paths.len`.
273    fn jump_over(paths: &[&str]) -> Jump {
274        let mut tree = Tree::new();
275        for p in paths {
276            tree.push(None, *p, false, ActionValues::new(*p, *p, *p));
277        }
278        Jump::open(&tree)
279    }
280
281    fn ch(c: char) -> Key {
282        Key::new(KeyCode::Char(c), KeyModifiers::NONE)
283    }
284
285    fn ctrl(c: char) -> Key {
286        Key::new(KeyCode::Char(c), KeyModifiers::CONTROL)
287    }
288
289    fn key(code: KeyCode) -> Key {
290        Key::new(code, KeyModifiers::NONE)
291    }
292
293    fn type_str(j: &mut Jump, s: &str) {
294        for c in s.chars() {
295            j.handle_key(ch(c));
296        }
297    }
298
299    fn result_ids(j: &Jump) -> Vec<NodeId> {
300        j.results().iter().map(|m| m.id).collect()
301    }
302
303    #[test]
304    fn empty_query_lists_all_candidates_in_tree_order() {
305        let j = jump_over(&["a", "b", "c"]);
306        assert_eq!(result_ids(&j), vec![0, 1, 2]);
307        assert_eq!(j.total(), 3);
308        assert_eq!(j.matched(), 3);
309    }
310
311    #[test]
312    fn narrowed_tree_only_offers_nodes_under_the_view_root() {
313        let mut tree = Tree::new();
314        tree.push(None, "outside", false, ActionValues::new("", "", "outside"));
315        let root = tree.push(None, "scope", true, ActionValues::new("", "", "scope"));
316        let child = tree.push(
317            Some(root),
318            "child",
319            false,
320            ActionValues::new("", "", "scope/child"),
321        );
322        tree.set_view_root(Some(root));
323
324        let jump = Jump::open(&tree);
325
326        assert_eq!(result_ids(&jump), vec![root, child]);
327        assert_eq!(jump.total(), 2);
328        assert_eq!(jump.path(child), "scope/child");
329    }
330
331    #[test]
332    fn typing_filters_to_fuzzy_matches() {
333        let mut j = jump_over(&["src/app.rs", "apple/pie", "README"]);
334        type_str(&mut j, "apprs");
335        // Only "src/app.rs" contains a-p-p-r-s as a subsequence.
336        assert_eq!(result_ids(&j), vec![0]);
337    }
338
339    #[test]
340    fn ranking_puts_the_stronger_match_first() {
341        let mut j = jump_over(&["app.rs", "zz/app.rs"]);
342        type_str(&mut j, "app");
343        // Both match; the shorter, prefix-anchored path outranks the buried one.
344        assert_eq!(result_ids(&j), vec![0, 1]);
345    }
346
347    #[test]
348    fn extended_syntax_prefix_anchor() {
349        let mut j = jump_over(&["src/a", "x/src"]);
350        type_str(&mut j, "^src");
351        assert_eq!(result_ids(&j), vec![0]);
352    }
353
354    #[test]
355    fn extended_syntax_negation_excludes() {
356        let mut j = jump_over(&["app.rs", "app_test.rs"]);
357        type_str(&mut j, "app !test");
358        assert_eq!(result_ids(&j), vec![0]);
359    }
360
361    #[test]
362    fn space_is_a_second_and_atom() {
363        let mut j = jump_over(&["src/app.rs", "src/xx"]);
364        type_str(&mut j, "src rs");
365        // Needs both "src" AND "rs"; only src/app.rs has both.
366        assert_eq!(result_ids(&j), vec![0]);
367    }
368
369    #[test]
370    fn match_indices_are_recorded_for_highlighting() {
371        let mut j = jump_over(&["abcd"]);
372        type_str(&mut j, "abc");
373        assert_eq!(j.results()[0].indices, vec![0, 1, 2]);
374    }
375
376    #[test]
377    fn navigation_keys_move_and_clamp_selection() {
378        let mut j = jump_over(&["a1", "a2", "a3"]);
379        type_str(&mut j, "a");
380        assert_eq!(j.selected(), 0);
381        j.handle_key(ctrl('n'));
382        assert_eq!(j.selected(), 1);
383        j.handle_key(key(KeyCode::Down));
384        assert_eq!(j.selected(), 2);
385        j.handle_key(ctrl('n')); // clamp at end
386        assert_eq!(j.selected(), 2);
387        j.handle_key(ctrl('p'));
388        assert_eq!(j.selected(), 1);
389        j.handle_key(ctrl('k'));
390        assert_eq!(j.selected(), 0);
391        j.handle_key(key(KeyCode::Up)); // clamp at start
392        assert_eq!(j.selected(), 0);
393    }
394
395    #[test]
396    fn enter_accepts_the_selected_result() {
397        let mut j = jump_over(&["a1", "a2", "a3"]);
398        type_str(&mut j, "a");
399        j.handle_key(ctrl('n')); // select id 1
400        assert_eq!(j.handle_key(key(KeyCode::Enter)), JumpOutcome::Accept(1));
401    }
402
403    #[test]
404    fn enter_with_no_matches_is_a_noop() {
405        let mut j = jump_over(&["a", "b"]);
406        type_str(&mut j, "zzzz");
407        assert_eq!(j.matched(), 0);
408        assert_eq!(j.handle_key(key(KeyCode::Enter)), JumpOutcome::Stay);
409    }
410
411    #[test]
412    fn esc_and_ctrl_c_cancel() {
413        let mut j = jump_over(&["a"]);
414        assert_eq!(j.handle_key(key(KeyCode::Esc)), JumpOutcome::Cancel);
415        assert_eq!(j.handle_key(ctrl('c')), JumpOutcome::Cancel);
416    }
417
418    #[test]
419    fn backspace_edits_the_query_and_reranks() {
420        let mut j = jump_over(&["app.rs", "api.rs"]);
421        type_str(&mut j, "app");
422        assert_eq!(result_ids(&j), vec![0]);
423        j.handle_key(key(KeyCode::Backspace)); // query now "ap"
424        assert_eq!(j.query(), "ap");
425        assert_eq!(result_ids(&j), vec![0, 1]);
426    }
427
428    #[test]
429    fn a_new_query_char_resets_the_selection() {
430        let mut j = jump_over(&["a1", "a2", "a3"]);
431        type_str(&mut j, "a");
432        j.handle_key(ctrl('n'));
433        j.handle_key(ctrl('n'));
434        assert_eq!(j.selected(), 2);
435        type_str(&mut j, "3"); // query "a3" reranks
436        assert_eq!(j.selected(), 0);
437    }
438
439    #[test]
440    fn scroll_follows_selection_within_a_small_viewport() {
441        let mut j = jump_over(&["a1", "a2", "a3", "a4", "a5"]);
442        type_str(&mut j, "a");
443        j.set_viewport(2); // only two rows visible
444        for _ in 0..4 {
445            j.handle_key(ctrl('n'));
446        }
447        assert_eq!(j.selected(), 4);
448        // Selection must be within [scroll, scroll + 2).
449        assert!(
450            j.scroll() <= 4 && 4 < j.scroll() + 2,
451            "scroll={}",
452            j.scroll()
453        );
454    }
455
456    #[test]
457    fn cursor_can_move_and_edit_mid_query() {
458        let mut j = jump_over(&["abc"]);
459        type_str(&mut j, "ac");
460        j.handle_key(key(KeyCode::Left)); // caret between 'a' and 'c'
461        assert_eq!(j.visual_cursor(), 1);
462        type_str(&mut j, "b"); // insert at the caret
463        assert_eq!(j.query(), "abc");
464        assert_eq!(result_ids(&j), vec![0]);
465    }
466
467    #[test]
468    fn home_end_move_the_caret_to_the_bounds() {
469        let mut j = jump_over(&["x"]);
470        type_str(&mut j, "abc");
471        j.handle_key(key(KeyCode::Home));
472        assert_eq!(j.visual_cursor(), 0);
473        j.handle_key(key(KeyCode::End));
474        assert_eq!(j.visual_cursor(), 3);
475    }
476
477    #[test]
478    fn ctrl_w_deletes_the_previous_word() {
479        let mut j = jump_over(&["foo", "bar"]);
480        type_str(&mut j, "foo bar");
481        j.handle_key(ctrl('w')); // kill "bar"
482        assert!(j.query().starts_with("foo"));
483        assert!(!j.query().contains("bar"), "query: {:?}", j.query());
484    }
485
486    #[test]
487    fn moving_the_caret_does_not_rerank_or_reset_selection() {
488        let mut j = jump_over(&["a1", "a2", "a3"]);
489        type_str(&mut j, "a");
490        j.handle_key(ctrl('n'));
491        assert_eq!(j.selected(), 1);
492        j.handle_key(key(KeyCode::Left)); // cursor-only: no re-rank
493        assert_eq!(j.selected(), 1);
494        assert_eq!(result_ids(&j), vec![0, 1, 2]);
495    }
496}