Skip to main content

herogpui_components/
list_nav.rs

1//! Arrow-key navigation shared by every list-shaped control.
2//!
3//! v3 sits on React Aria, so a listbox, a select's popover, a dropdown menu and
4//! a combo box's suggestions all answer the same four keys the same way. Keeping
5//! one implementation means they cannot disagree — and a list that only answers
6//! the pointer is not the same control.
7
8/// What a keystroke does to the cursor.
9#[derive(Clone, Copy, PartialEq, Eq, Debug)]
10pub enum Move {
11    /// Put the cursor on this stop.
12    To(usize),
13    /// Activate the row the cursor is on.
14    Activate,
15    /// Not a navigation key.
16    Ignore,
17}
18
19/// Where `key` moves the cursor, over the rows a keyboard may land on.
20///
21/// `stops` holds the indices of the selectable rows, in order — sections,
22/// separators and disabled rows are left out, so the cursor never stops on
23/// something that cannot be chosen. `from` is the cursor's current index into
24/// the *item* list, not into `stops`.
25///
26/// With nothing focused, `down` starts at the top and `up` at the bottom, which
27/// is what React Aria does. `wrap` is `shouldFocusWrap`: without it the ends
28/// hold instead of joining up.
29pub fn resolve(stops: &[usize], from: Option<usize>, key: &str, wrap: bool) -> Move {
30    if stops.is_empty() {
31        return Move::Ignore;
32    }
33    let last = stops.len() as i32 - 1;
34    let here = from.and_then(|i| stops.iter().position(|s| *s == i));
35
36    let step = |delta: i32| -> Move {
37        let next = match here {
38            None if delta > 0 => 0,
39            None => last,
40            Some(pos) => {
41                let raw = pos as i32 + delta;
42                if raw < 0 {
43                    if wrap {
44                        last
45                    } else {
46                        0
47                    }
48                } else if raw > last {
49                    if wrap {
50                        0
51                    } else {
52                        last
53                    }
54                } else {
55                    raw
56                }
57            }
58        };
59        stops
60            .get(next as usize)
61            .copied()
62            .map_or(Move::Ignore, Move::To)
63    };
64
65    match key {
66        "down" => step(1),
67        "up" => step(-1),
68        "home" => Move::To(stops[0]),
69        "end" => Move::To(stops[stops.len() - 1]),
70        "enter" | "space" => Move::Activate,
71        _ => Move::Ignore,
72    }
73}
74
75/// How long a typeahead buffer survives without a keystroke.
76///
77/// React Aria clears after a second of quiet, which is what makes "de" find
78/// "Denmark" while a later "n" starts again at "Netherlands" rather than looking
79/// for "den".
80pub const TYPEAHEAD_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(1000);
81
82/// The letters typed so far, and when the last one arrived.
83///
84/// Held in the component's keyed state, because a search that reset every frame
85/// could only ever match one letter.
86#[derive(Clone, Debug, Default)]
87pub struct Typeahead {
88    query: String,
89    last: Option<web_time::Instant>,
90}
91
92impl Typeahead {
93    /// Adds a keystroke and returns the search it makes.
94    ///
95    /// Repeating one letter is not a two-letter search: React Aria treats
96    /// `aa` as "the next row starting with a", which is how a list of names is
97    /// walked by initial.
98    pub fn push(&mut self, key: &str, now: web_time::Instant) -> String {
99        let stale = self
100            .last
101            .is_none_or(|last| now.duration_since(last) > TYPEAHEAD_TIMEOUT);
102        if stale {
103            self.query.clear();
104        }
105        self.last = Some(now);
106        let repeat = !self.query.is_empty() && self.query.chars().all(|c| c.to_string() == key);
107        if repeat {
108            self.query = key.to_owned();
109        } else {
110            self.query.push_str(key);
111        }
112        self.query.clone()
113    }
114
115    /// Whether the last keystroke repeated a single letter, in which case the
116    /// search starts *after* the cursor rather than at it.
117    pub fn is_repeat(&self) -> bool {
118        self.query.chars().count() == 1
119    }
120
121    /// The characters typed so far.
122    pub fn query(&self) -> &str {
123        &self.query
124    }
125}
126
127/// Whether `key` is a character a typeahead should collect.
128///
129/// One printable character, and not a space: a space activates the focused row
130/// in every one of these controls, which is why React Aria only takes it into a
131/// search that has already started.
132pub fn is_typeahead_key(key: &str) -> bool {
133    let mut chars = key.chars();
134    match (chars.next(), chars.next()) {
135        (Some(c), None) => c.is_alphanumeric(),
136        _ => false,
137    }
138}
139
140/// The row `query` finds, searching from the cursor.
141///
142/// `labels` is every row's text, indexed like the item list -- a row that cannot
143/// be landed on has an empty label, so it is never a match. The search wraps
144/// once, so typing the initial of a row above the cursor still finds it.
145pub fn typeahead(
146    labels: &[String],
147    stops: &[usize],
148    from: Option<usize>,
149    query: &str,
150    repeat: bool,
151) -> Option<usize> {
152    let needle = query.to_lowercase();
153    if needle.is_empty() || stops.is_empty() {
154        return None;
155    }
156    // A repeated letter walks to the *next* match; a growing query re-tests the
157    // row the cursor is on, so "d" then "e" does not skip "Denmark".
158    let start = match (from, repeat) {
159        (Some(at), true) => stops.iter().position(|s| *s == at).map_or(0, |p| p + 1),
160        (Some(at), false) => stops.iter().position(|s| *s == at).unwrap_or(0),
161        (None, _) => 0,
162    };
163    for step in 0..stops.len() {
164        let index = stops[(start + step) % stops.len()];
165        let label = labels.get(index).map(String::as_str).unwrap_or_default();
166        if label.to_lowercase().starts_with(&needle) {
167            return Some(index);
168        }
169    }
170    None
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn down_from_nothing_starts_at_the_top() {
179        assert_eq!(resolve(&[1, 2, 4], None, "down", false), Move::To(1));
180    }
181
182    #[test]
183    fn up_from_nothing_starts_at_the_bottom() {
184        assert_eq!(resolve(&[1, 2, 4], None, "up", false), Move::To(4));
185    }
186
187    #[test]
188    fn skips_the_rows_that_are_not_stops() {
189        // 3 is a separator, so Down from 2 lands on 4.
190        assert_eq!(resolve(&[1, 2, 4], Some(2), "down", false), Move::To(4));
191    }
192
193    #[test]
194    fn the_ends_hold_without_wrap() {
195        assert_eq!(resolve(&[1, 2, 4], Some(4), "down", false), Move::To(4));
196        assert_eq!(resolve(&[1, 2, 4], Some(1), "up", false), Move::To(1));
197    }
198
199    #[test]
200    fn the_ends_join_up_with_wrap() {
201        assert_eq!(resolve(&[1, 2, 4], Some(4), "down", true), Move::To(1));
202        assert_eq!(resolve(&[1, 2, 4], Some(1), "up", true), Move::To(4));
203    }
204
205    #[test]
206    fn home_and_end_jump() {
207        assert_eq!(resolve(&[1, 2, 4], Some(2), "home", false), Move::To(1));
208        assert_eq!(resolve(&[1, 2, 4], Some(2), "end", false), Move::To(4));
209    }
210
211    #[test]
212    fn enter_and_space_activate() {
213        assert_eq!(resolve(&[1], Some(1), "enter", false), Move::Activate);
214        assert_eq!(resolve(&[1], Some(1), "space", false), Move::Activate);
215    }
216
217    #[test]
218    fn anything_else_is_ignored() {
219        assert_eq!(resolve(&[1], Some(1), "a", false), Move::Ignore);
220        assert_eq!(resolve(&[], None, "down", false), Move::Ignore);
221    }
222
223    #[test]
224    fn typeahead_finds_the_first_match_from_the_cursor() {
225        let labels: Vec<String> = ["Argentina", "Belgium", "Denmark", "Brazil"]
226            .iter()
227            .map(|s| (*s).to_owned())
228            .collect();
229        let stops = [0, 1, 2, 3];
230        assert_eq!(typeahead(&labels, &stops, None, "b", true), Some(1));
231        // Case does not matter, and a growing query re-tests the current row.
232        assert_eq!(typeahead(&labels, &stops, Some(2), "DEN", false), Some(2));
233    }
234
235    #[test]
236    fn a_repeated_letter_walks_the_matches() {
237        let labels: Vec<String> = ["Belgium", "Brazil", "Denmark"]
238            .iter()
239            .map(|s| (*s).to_owned())
240            .collect();
241        let stops = [0, 1, 2];
242        assert_eq!(typeahead(&labels, &stops, Some(0), "b", true), Some(1));
243        // And wraps back round.
244        assert_eq!(typeahead(&labels, &stops, Some(1), "b", true), Some(0));
245    }
246
247    #[test]
248    fn typeahead_skips_rows_that_are_not_stops() {
249        let labels: Vec<String> = ["Section", "Belgium"]
250            .iter()
251            .map(|s| (*s).to_owned())
252            .collect();
253        // 0 is a heading, so "s" matches nothing.
254        assert_eq!(typeahead(&labels, &[1], None, "s", true), None);
255    }
256
257    #[test]
258    fn nothing_matches_nothing() {
259        let labels = vec!["Belgium".to_owned()];
260        assert_eq!(typeahead(&labels, &[0], None, "", true), None);
261        assert_eq!(typeahead(&labels, &[0], None, "z", true), None);
262        assert_eq!(typeahead(&[], &[], None, "b", true), None);
263    }
264
265    #[test]
266    fn the_buffer_clears_after_the_timeout() {
267        let mut ta = Typeahead::default();
268        let t0 = web_time::Instant::now();
269        assert_eq!(ta.push("d", t0), "d");
270        assert_eq!(
271            ta.push("e", t0 + std::time::Duration::from_millis(200)),
272            "de"
273        );
274        // A second of quiet starts a new search.
275        assert_eq!(ta.push("n", t0 + std::time::Duration::from_secs(3)), "n");
276    }
277
278    #[test]
279    fn a_repeated_letter_is_not_a_two_letter_search() {
280        let mut ta = Typeahead::default();
281        let t0 = web_time::Instant::now();
282        assert_eq!(ta.push("b", t0), "b");
283        assert_eq!(
284            ta.push("b", t0 + std::time::Duration::from_millis(100)),
285            "b"
286        );
287        assert!(ta.is_repeat());
288        assert_eq!(
289            ta.push("r", t0 + std::time::Duration::from_millis(200)),
290            "br"
291        );
292        assert!(!ta.is_repeat());
293    }
294
295    #[test]
296    fn only_single_printable_characters_are_collected() {
297        assert!(is_typeahead_key("a"));
298        assert!(is_typeahead_key("7"));
299        assert!(!is_typeahead_key("space"));
300        assert!(!is_typeahead_key("enter"));
301        assert!(!is_typeahead_key("-"));
302        assert!(!is_typeahead_key(""));
303    }
304}