Skip to main content

telar_ui_core/
keynav.rs

1//! Moving a selection through a list with the keyboard, the same way in every list that has one.
2//!
3//! It is deliberately *not* the focus system. That answers "which widget receives keys" — one focusable per
4//! widget, driven by Tab. This answers "which row of a list is selected", which is one focusable holding a
5//! cursor over N rows, and the two compose: a search field keeps focus while these keys drive the list
6//! underneath it.
7//!
8//! The important half of the contract is the negative one: **everything [`KeyNav::interpret`] returns `None`
9//! for must still reach a focused text field as typing.** A list that swallows `j` cannot also be searched,
10//! which is why the vim bindings are off unless a caller asks for them.
11
12use platform_core::{Key, NamedKey};
13
14/// A default vertical list: arrows, Home/End, Enter and Escape, and no vim bindings.
15impl Default for KeyNav {
16    fn default() -> Self {
17        Self {
18            vim: false,
19            horizontal: false,
20            grid: false,
21        }
22    }
23}
24
25/// What a key press means to a list.
26#[derive(Clone, Copy, PartialEq, Eq, Debug)]
27pub enum KeyNavMove {
28    Next,
29    Previous,
30    First,
31    Last,
32    /// One row down in a grid — a whole column count, not one tile. Same as [`KeyNavMove::Next`] in a single-column
33    /// list, which is what lets one `apply` serve both.
34    NextRow,
35    PreviousRow,
36    /// Run the selected row.
37    Activate,
38    /// Back out: dismiss the surface, or undo an armed confirmation.
39    Cancel,
40}
41
42/// How a list reads keys: the arrows always, and optionally the vim bindings on top.
43///
44/// `vim` is off by default because a list that swallows `j` cannot also be typed into, and hyprshell's biggest
45/// list — the launcher — is a search field. A surface with no text input can turn it on freely; one with a
46/// field should only do so if its user asked for it.
47#[derive(Clone, Copy)]
48pub struct KeyNav {
49    pub vim: bool,
50    /// The list runs along the screen's horizontal, so Left/Right move it rather than Up/Down.
51    pub horizontal: bool,
52    /// The list wraps into rows, so it uses *both* pairs of arrows: Left/Right for one tile and Up/Down for a
53    /// whole row. Only a grid can, which is why it is a mode rather than the default.
54    pub grid: bool,
55}
56
57impl KeyNav {
58    pub fn horizontal(mut self) -> Self {
59        self.horizontal = true;
60        self
61    }
62
63    /// A grid: tiles run along a row, so Left/Right step one and Up/Down step a row.
64    pub fn grid(mut self) -> Self {
65        self.horizontal = true;
66        self.grid = true;
67        self
68    }
69
70    /// What `key` asks the list to do, or `None` when it is not a navigation key — which is the important half
71    /// of the contract: everything this returns `None` for must still reach a focused text field as typing.
72    pub fn interpret(self, key: &Key) -> Option<KeyNavMove> {
73        let (forward, back) = if self.horizontal {
74            (NamedKey::ArrowRight, NamedKey::ArrowLeft)
75        } else {
76            (NamedKey::ArrowDown, NamedKey::ArrowUp)
77        };
78        if let Key::Named(named) = key {
79            if *named == forward {
80                return Some(KeyNavMove::Next);
81            }
82            if *named == back {
83                return Some(KeyNavMove::Previous);
84            }
85            if self.grid {
86                if *named == NamedKey::ArrowDown {
87                    return Some(KeyNavMove::NextRow);
88                }
89                if *named == NamedKey::ArrowUp {
90                    return Some(KeyNavMove::PreviousRow);
91                }
92            }
93            return match named {
94                NamedKey::Enter => Some(KeyNavMove::Activate),
95                NamedKey::Escape => Some(KeyNavMove::Cancel),
96                NamedKey::Home => Some(KeyNavMove::First),
97                NamedKey::End => Some(KeyNavMove::Last),
98                _ => None,
99            };
100        }
101        if !self.vim {
102            return None;
103        }
104        // Vim's own pairs, and the readline pair every terminal user already has in their fingers. `G` before `g` because the shift-key distinction is the whole difference between them.
105        let (down, up) = if self.grid {
106            (KeyNavMove::NextRow, KeyNavMove::PreviousRow)
107        } else {
108            (KeyNavMove::Next, KeyNavMove::Previous)
109        };
110        match key {
111            Key::Char(c) => match c {
112                'j' => Some(down),
113                'k' => Some(up),
114                'h' if self.grid => Some(KeyNavMove::Previous),
115                'l' if self.grid => Some(KeyNavMove::Next),
116                'g' => Some(KeyNavMove::First),
117                'G' => Some(KeyNavMove::Last),
118                '\u{e}' => Some(down), // Ctrl-N
119                '\u{10}' => Some(up),  // Ctrl-P
120                _ => None,
121            },
122            _ => None,
123        }
124    }
125}
126
127/// Where a move lands, given the current index and how many rows there are.
128///
129/// Wraps at both ends: a list short enough to see all of is faster to reach the bottom of by pressing up once,
130/// and a list too long to see wraps rather than sticking silently, which reads as the key not working.
131pub fn key_nav_apply(current: usize, count: usize, movement: KeyNavMove) -> usize {
132    key_nav_apply_grid(current, count, 1, movement)
133}
134
135/// Where a move lands in a grid `columns` tiles wide. A single column is a list, which is why [`key_nav_apply`] is this
136/// with `columns = 1` rather than a second implementation.
137///
138/// A row move off the bottom lands on the nearest tile below where there is one — a partial last row is still a
139/// row — and wraps to the same column otherwise, matching the horizontal rule.
140pub fn key_nav_apply_grid(
141    current: usize,
142    count: usize,
143    columns: usize,
144    movement: KeyNavMove,
145) -> usize {
146    if count == 0 {
147        return 0;
148    }
149    let columns = columns.max(1);
150    let current = current.min(count - 1);
151    let last = count - 1;
152    match movement {
153        KeyNavMove::Next => (current + 1) % count,
154        KeyNavMove::Previous => (current + count - 1) % count,
155        KeyNavMove::First => 0,
156        KeyNavMove::Last => last,
157        KeyNavMove::NextRow => {
158            let below = current + columns;
159            if below <= last {
160                below
161            } else if current / columns < last / columns {
162                // A shorter last row: down from the end of a full row still goes down, to its final tile.
163                last
164            } else {
165                current % columns
166            }
167        }
168        KeyNavMove::PreviousRow => {
169            if current >= columns {
170                current - columns
171            } else {
172                let bottom = (last / columns) * columns + current % columns;
173                if bottom > last {
174                    bottom - columns
175                } else {
176                    bottom
177                }
178            }
179        }
180        KeyNavMove::Activate | KeyNavMove::Cancel => current,
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn arrows() -> KeyNav {
189        KeyNav {
190            vim: false,
191            horizontal: false,
192            grid: false,
193        }
194    }
195
196    fn vim() -> KeyNav {
197        KeyNav {
198            vim: true,
199            horizontal: false,
200            grid: false,
201        }
202    }
203
204    fn named(key: NamedKey) -> Key {
205        Key::Named(key)
206    }
207
208    fn character(c: char) -> Key {
209        Key::Char(c)
210    }
211
212    #[test]
213    fn the_arrows_always_navigate_and_the_letters_only_do_in_vim_mode() {
214        assert_eq!(
215            arrows().interpret(&named(NamedKey::ArrowDown)),
216            Some(KeyNavMove::Next)
217        );
218        assert_eq!(
219            arrows().interpret(&named(NamedKey::ArrowUp)),
220            Some(KeyNavMove::Previous)
221        );
222        assert_eq!(
223            arrows().interpret(&named(NamedKey::Enter)),
224            Some(KeyNavMove::Activate)
225        );
226        assert_eq!(
227            arrows().interpret(&named(NamedKey::Escape)),
228            Some(KeyNavMove::Cancel)
229        );
230
231        // The half that matters: with vim off, a letter is typing and must reach the search field.
232        assert_eq!(arrows().interpret(&character('j')), None);
233        assert_eq!(arrows().interpret(&character('k')), None);
234        assert_eq!(arrows().interpret(&character('G')), None);
235
236        assert_eq!(vim().interpret(&character('j')), Some(KeyNavMove::Next));
237        assert_eq!(vim().interpret(&character('k')), Some(KeyNavMove::Previous));
238        assert_eq!(vim().interpret(&character('g')), Some(KeyNavMove::First));
239        assert_eq!(vim().interpret(&character('G')), Some(KeyNavMove::Last));
240        assert_eq!(
241            vim().interpret(&character('\u{e}')),
242            Some(KeyNavMove::Next),
243            "Ctrl-N"
244        );
245        assert_eq!(
246            vim().interpret(&character('\u{10}')),
247            Some(KeyNavMove::Previous),
248            "Ctrl-P"
249        );
250        assert_eq!(
251            vim().interpret(&character('q')),
252            None,
253            "an unbound letter is still typing"
254        );
255    }
256
257    #[test]
258    fn a_horizontal_list_reads_the_other_pair_of_arrows() {
259        let row = arrows().horizontal();
260        assert_eq!(
261            row.interpret(&named(NamedKey::ArrowRight)),
262            Some(KeyNavMove::Next)
263        );
264        assert_eq!(
265            row.interpret(&named(NamedKey::ArrowLeft)),
266            Some(KeyNavMove::Previous)
267        );
268        assert_eq!(
269            row.interpret(&named(NamedKey::ArrowDown)),
270            None,
271            "down is not along a row, so it stays available to whatever else wants it"
272        );
273    }
274
275    #[test]
276    fn the_selection_wraps_at_both_ends_and_survives_a_list_that_shrank() {
277        assert_eq!(key_nav_apply(0, 3, KeyNavMove::Next), 1);
278        assert_eq!(
279            key_nav_apply(2, 3, KeyNavMove::Next),
280            0,
281            "wraps past the end"
282        );
283        assert_eq!(
284            key_nav_apply(0, 3, KeyNavMove::Previous),
285            2,
286            "and back past the start"
287        );
288        assert_eq!(key_nav_apply(1, 3, KeyNavMove::First), 0);
289        assert_eq!(key_nav_apply(1, 3, KeyNavMove::Last), 2);
290        assert_eq!(
291            key_nav_apply(1, 3, KeyNavMove::Activate),
292            1,
293            "activating moves nothing"
294        );
295
296        assert_eq!(
297            key_nav_apply(0, 0, KeyNavMove::Next),
298            0,
299            "an empty list has nowhere to go"
300        );
301        // A selection left over from a longer list is clamped rather than wrapping off a stale index — the launcher's results shrink on every keystroke.
302        assert_eq!(key_nav_apply(9, 3, KeyNavMove::Next), 0);
303        assert_eq!(key_nav_apply(9, 3, KeyNavMove::Previous), 1);
304
305        // A list is a one-column grid, so a row move is a step: the launcher's rows and its wallpaper grid share
306        // one `apply` and must not need to know which they are.
307        assert_eq!(key_nav_apply(0, 3, KeyNavMove::NextRow), 1);
308        assert_eq!(key_nav_apply(2, 3, KeyNavMove::PreviousRow), 1);
309        assert_eq!(key_nav_apply(2, 3, KeyNavMove::NextRow), 0, "still wraps");
310    }
311
312    #[test]
313    fn a_grid_uses_both_pairs_of_arrows() {
314        let grid = arrows().grid();
315        assert_eq!(
316            grid.interpret(&named(NamedKey::ArrowRight)),
317            Some(KeyNavMove::Next)
318        );
319        assert_eq!(
320            grid.interpret(&named(NamedKey::ArrowLeft)),
321            Some(KeyNavMove::Previous)
322        );
323        assert_eq!(
324            grid.interpret(&named(NamedKey::ArrowDown)),
325            Some(KeyNavMove::NextRow)
326        );
327        assert_eq!(
328            grid.interpret(&named(NamedKey::ArrowUp)),
329            Some(KeyNavMove::PreviousRow)
330        );
331        assert_eq!(
332            grid.interpret(&named(NamedKey::Enter)),
333            Some(KeyNavMove::Activate)
334        );
335
336        // With vim off — the launcher's default, since the grid sits under a search field — a letter is typing.
337        assert_eq!(grid.interpret(&character('j')), None);
338        assert_eq!(grid.interpret(&character('h')), None);
339
340        let keys = vim().grid();
341        assert_eq!(keys.interpret(&character('j')), Some(KeyNavMove::NextRow));
342        assert_eq!(
343            keys.interpret(&character('k')),
344            Some(KeyNavMove::PreviousRow)
345        );
346        assert_eq!(keys.interpret(&character('l')), Some(KeyNavMove::Next));
347        assert_eq!(keys.interpret(&character('h')), Some(KeyNavMove::Previous));
348    }
349
350    /// Nine tiles in three columns, plus the awkward case a wallpaper folder always ends in: a partial last row.
351    #[test]
352    fn a_row_move_crosses_a_whole_row_and_a_partial_one_is_still_a_row() {
353        // 0 1 2
354        // 3 4 5
355        // 6 7 8
356        assert_eq!(key_nav_apply_grid(0, 9, 3, KeyNavMove::NextRow), 3);
357        assert_eq!(key_nav_apply_grid(4, 9, 3, KeyNavMove::PreviousRow), 1);
358        assert_eq!(
359            key_nav_apply_grid(1, 9, 3, KeyNavMove::Next),
360            2,
361            "along the row, not down it"
362        );
363        assert_eq!(
364            key_nav_apply_grid(7, 9, 3, KeyNavMove::NextRow),
365            1,
366            "wraps to the same column"
367        );
368        assert_eq!(
369            key_nav_apply_grid(1, 9, 3, KeyNavMove::PreviousRow),
370            7,
371            "and back to the bottom of it"
372        );
373
374        // 0 1 2
375        // 3 4
376        // Down from 2 has no tile of its own below, but there *is* a row: landing on 4 beats not moving.
377        assert_eq!(key_nav_apply_grid(2, 5, 3, KeyNavMove::NextRow), 4);
378        assert_eq!(key_nav_apply_grid(1, 5, 3, KeyNavMove::NextRow), 4);
379        assert_eq!(key_nav_apply_grid(0, 5, 3, KeyNavMove::NextRow), 3);
380        // Up from the top column 2 finds the bottom-most tile in that column, which is on the row above the gap.
381        assert_eq!(key_nav_apply_grid(2, 5, 3, KeyNavMove::PreviousRow), 2);
382        assert_eq!(key_nav_apply_grid(1, 5, 3, KeyNavMove::PreviousRow), 4);
383        assert_eq!(
384            key_nav_apply_grid(4, 5, 3, KeyNavMove::NextRow),
385            1,
386            "the last row wraps to the first"
387        );
388
389        // One row: a row move has nowhere to go and must stay put rather than run off the end.
390        assert_eq!(key_nav_apply_grid(1, 3, 3, KeyNavMove::NextRow), 1);
391        assert_eq!(key_nav_apply_grid(1, 3, 3, KeyNavMove::PreviousRow), 1);
392        assert_eq!(key_nav_apply_grid(0, 0, 4, KeyNavMove::NextRow), 0);
393        assert_eq!(
394            key_nav_apply_grid(9, 5, 3, KeyNavMove::NextRow),
395            1,
396            "a stale index is clamped first"
397        );
398    }
399}