Skip to main content

ui/popover/
nav.rs

1//! Pure reducers: the menu cursor, query matching and key classification.
2
3use super::*;
4
5/// Step the active row of a menu: wraps at both ends; `None` enters at the
6/// edge matching the direction. Empty menus stay `None`.
7pub fn menu_step(active: Option<usize>, count: usize, delta: isize) -> Option<usize> {
8    if count == 0 {
9        return None;
10    }
11    let count_i = count as isize;
12    let next = match active {
13        None => {
14            if delta >= 0 {
15                0
16            } else {
17                count_i - 1
18            }
19        }
20        Some(at) => (at as isize + delta).rem_euclid(count_i),
21    };
22    Some(next as usize)
23}
24
25/// Match rank of a label against a query: `0` prefix match, `1` substring,
26/// `None` no match. Case-insensitive; an empty query matches everything at
27/// rank 1 (input order preserved).
28pub fn match_rank(query: &str, label: &str) -> Option<usize> {
29    let query = query.trim().to_lowercase();
30    if query.is_empty() {
31        return Some(1);
32    }
33    let label = label.to_lowercase();
34    if label.starts_with(&query) {
35        Some(0)
36    } else if label.contains(&query) {
37        Some(1)
38    } else {
39        None
40    }
41}
42
43/// Filter + rank labels for a search query: prefix matches first, then
44/// substring matches, stable within each rank. Returns indices into `labels`.
45pub fn filter_indices<S: AsRef<str>>(query: &str, labels: &[S]) -> Vec<usize> {
46    let mut ranked: Vec<(usize, usize)> = labels
47        .iter()
48        .enumerate()
49        .filter_map(|(ix, label)| match_rank(query, label.as_ref()).map(|rank| (rank, ix)))
50        .collect();
51    ranked.sort_by_key(|&(rank, ix)| (rank, ix));
52    ranked.into_iter().map(|(_, ix)| ix).collect()
53}
54
55/// The state behind a searchable list: the items, the ranked view of them, and
56/// which row of that view is active. Shared by every picker — the palette, the
57/// combobox — so the mapping below is written and tested once.
58pub struct Filter {
59    items: Vec<SharedString>,
60    /// Indices into `items`, ranked by [`filter_indices`].
61    filtered: Vec<usize>,
62    /// Position within `filtered`, not within `items`.
63    active: Option<usize>,
64}
65
66impl Filter {
67    pub fn new(items: Vec<SharedString>) -> Self {
68        let filtered: Vec<usize> = (0..items.len()).collect();
69        let active = (!filtered.is_empty()).then_some(0);
70        Self {
71            items,
72            filtered,
73            active,
74        }
75    }
76
77    pub fn items(&self) -> &[SharedString] {
78        &self.items
79    }
80
81    /// The ranked view: indices into [`Self::items`], in display order.
82    pub fn filtered(&self) -> &[usize] {
83        &self.filtered
84    }
85
86    /// The highlighted row's position in the FILTERED view — what a renderer
87    /// compares each row against.
88    pub fn active(&self) -> Option<usize> {
89        self.active
90    }
91
92    /// Re-rank against `query`, re-entering the list at the top: after
93    /// narrowing, the best match should be one Enter away.
94    pub fn refilter(&mut self, query: &str) {
95        self.filtered = filter_indices(query, &self.items);
96        self.active = (!self.filtered.is_empty()).then_some(0);
97    }
98
99    pub fn step(&mut self, delta: isize) {
100        self.active = menu_step(self.active, self.filtered.len(), delta);
101    }
102
103    /// Put the cursor on a position in the FILTERED view — what the mouse
104    /// calls as it crosses a row, so a menu never shows a mouse cursor and a
105    /// keyboard cursor at once.
106    pub fn set_active(&mut self, position: usize) {
107        if position < self.filtered.len() {
108            self.active = Some(position);
109        }
110    }
111
112    /// The item confirming right now would pick — an index into
113    /// [`Self::items`], never into the filtered view. Confusing the two is the
114    /// defining bug of a filtered list: it only appears once a query narrows
115    /// the rows, and then every selection picks the wrong thing.
116    pub fn active_item(&self) -> Option<usize> {
117        self.active
118            .and_then(|position| self.filtered.get(position))
119            .copied()
120    }
121}
122
123/// Keys the pickers care about, classified from a raw keystroke.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum MenuKey {
126    Up,
127    Down,
128    /// Plain Enter — activate the highlighted row.
129    Enter,
130    /// Cmd/Ctrl+Enter — the "pick this folder" accelerator in the browser.
131    ModEnter,
132    Escape,
133    Backspace,
134    Other,
135}
136
137pub fn classify_key(key: &str, cmd: bool, ctrl: bool) -> MenuKey {
138    match key {
139        "up" => MenuKey::Up,
140        "down" => MenuKey::Down,
141        // Readline/emacs motion: ctrl-n/ctrl-p mirror ↓/↑ in every picker.
142        // Safe to claim frame-wide — neither chord is a text-editing binding
143        // in the palette keymaps, so they always bubble here unconsumed.
144        "n" if ctrl => MenuKey::Down,
145        "p" if ctrl => MenuKey::Up,
146        "enter" if cmd || ctrl => MenuKey::ModEnter,
147        "enter" => MenuKey::Enter,
148        "escape" => MenuKey::Escape,
149        "backspace" => MenuKey::Backspace,
150        _ => MenuKey::Other,
151    }
152}