| 1 | //! Shared list-selection navigation (#4755, #6290). |
| 2 | //! |
| 3 | //! Modal lists and config screens should wrap at the ends so Down on the last |
| 4 | //! row returns to the top and Up on the first row returns to the bottom. |
| 5 | //! Centralizing the arithmetic keeps that behavior consistent without each |
| 6 | //! picker inventing its own clamp. |
| 7 | //! |
| 8 | //! [`Motion`] extends that from the arithmetic to the *vocabulary*. `menu_style` |
| 9 | //! single-sources how a selected row looks; this single-sources what a key |
| 10 | //! means, which is the half that was still being reinvented per view: `h`/`l` |
| 11 | //! in the provider picker against `Left`/`Right` in the model picker one screen |
| 12 | //! later, `End` in exactly one of seven pickers, and no paging at all in |
| 13 | //! `fleet_detail` (#6290 has the tables). |
| 14 | //! |
| 15 | //! Two entry points, because the difference is load-bearing: |
| 16 | //! [`motion`] for a surface with no focused text input, and |
| 17 | //! [`motion_while_typing`] for one that is capturing characters. The letter |
| 18 | //! aliases exist only in the first. A picker with a live filter that routed |
| 19 | //! `j` to "move down" would eat the letter out of the user's query, so the |
| 20 | //! typing-safe set is arrow-and-page only. Surfaces choose by what they are |
| 21 | //! doing, not by what they are. |
| 22 | |
| 23 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
| 24 | |
| 25 | /// Move a 0-based selection by `delta`, wrapping at both ends. |
| 26 | /// |
| 27 | /// Empty lists leave the selection at `0`. A zero `len` is treated as empty. |
| 28 | #[must_use] |
| 29 | pub fn wrap_index(selected: usize, len: usize, delta: isize) -> usize { |
| 30 | if len == 0 { |
| 31 | return 0; |
| 32 | } |
| 33 | (selected as isize + delta).rem_euclid(len as isize) as usize |
| 34 | } |
| 35 | |
| 36 | /// One movement a selection surface can be asked to make. |
| 37 | /// |
| 38 | /// Horizontal motions are named for what they do — move between panes, |
| 39 | /// columns or tab strips — rather than for a key, so a surface that has no |
| 40 | /// second axis simply never asks for them. |
| 41 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 42 | pub enum Motion { |
| 43 | /// One row toward the start. |
| 44 | Prev, |
| 45 | /// One row toward the end. |
| 46 | Next, |
| 47 | /// One screenful toward the start. |
| 48 | PagePrev, |
| 49 | /// One screenful toward the end. |
| 50 | PageNext, |
| 51 | /// The first row. |
| 52 | First, |
| 53 | /// The last row. |
| 54 | Last, |
| 55 | /// The previous pane, column or tab. |
| 56 | RegionPrev, |
| 57 | /// The next pane, column or tab. |
| 58 | RegionNext, |
| 59 | } |
| 60 | |
| 61 | /// The motion a key asks for on a surface with **no focused text input**. |
| 62 | /// |
| 63 | /// `j`/`k` and `h`/`l` are aliases here and nowhere else; see the module doc. |
| 64 | /// A key carrying CONTROL or ALT is never a motion — those belong to the |
| 65 | /// surface's own shortcuts. |
| 66 | #[must_use] |
| 67 | pub fn motion(key: &KeyEvent) -> Option<Motion> { |
| 68 | if key |
| 69 | .modifiers |
| 70 | .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) |
| 71 | { |
| 72 | return None; |
| 73 | } |
| 74 | match key.code { |
| 75 | KeyCode::Char('k') => Some(Motion::Prev), |
| 76 | KeyCode::Char('j') => Some(Motion::Next), |
| 77 | KeyCode::Char('h') => Some(Motion::RegionPrev), |
| 78 | KeyCode::Char('l') => Some(Motion::RegionNext), |
| 79 | _ => motion_while_typing(key), |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | /// The motion a key asks for while the surface is **capturing typed text**. |
| 84 | /// |
| 85 | /// Arrows, paging and Home/End only, so no letter is ever taken out of a |
| 86 | /// query. Tab still moves between regions: it is not a character a filter |
| 87 | /// wants. |
| 88 | #[must_use] |
| 89 | pub fn motion_while_typing(key: &KeyEvent) -> Option<Motion> { |
| 90 | if key |
| 91 | .modifiers |
| 92 | .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) |
| 93 | { |
| 94 | return None; |
| 95 | } |
| 96 | match key.code { |
| 97 | KeyCode::Up => Some(Motion::Prev), |
| 98 | KeyCode::Down => Some(Motion::Next), |
| 99 | KeyCode::PageUp => Some(Motion::PagePrev), |
| 100 | KeyCode::PageDown => Some(Motion::PageNext), |
| 101 | KeyCode::Home => Some(Motion::First), |
| 102 | KeyCode::End => Some(Motion::Last), |
| 103 | KeyCode::Left => Some(Motion::RegionPrev), |
| 104 | KeyCode::Right => Some(Motion::RegionNext), |
| 105 | KeyCode::BackTab => Some(Motion::RegionPrev), |
| 106 | // Some terminals report shift+tab as Tab carrying SHIFT rather than |
| 107 | // as BackTab; both are the same "previous region" motion. |
| 108 | KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Motion::RegionPrev), |
| 109 | KeyCode::Tab => Some(Motion::RegionNext), |
| 110 | _ => None, |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | /// Apply a vertical [`Motion`] to a 0-based selection. |
| 115 | /// |
| 116 | /// `Prev`/`Next` wrap, matching [`wrap_index`]. Paging and `First`/`Last` |
| 117 | /// clamp: a user pressing PageDown is asking to travel, not to teleport to the |
| 118 | /// top. Returns `None` for a horizontal motion, which only the surface can |
| 119 | /// resolve. |
| 120 | #[must_use] |
| 121 | pub fn apply(selected: usize, len: usize, page: usize, motion: Motion) -> Option<usize> { |
| 122 | if len == 0 { |
| 123 | return Some(0); |
| 124 | } |
| 125 | let last = len - 1; |
| 126 | let page = page.max(1) as isize; |
| 127 | Some(match motion { |
| 128 | Motion::Prev => wrap_index(selected, len, -1), |
| 129 | Motion::Next => wrap_index(selected, len, 1), |
| 130 | Motion::PagePrev => (selected as isize - page).max(0) as usize, |
| 131 | Motion::PageNext => (selected as isize + page).min(last as isize) as usize, |
| 132 | Motion::First => 0, |
| 133 | Motion::Last => last, |
| 134 | Motion::RegionPrev | Motion::RegionNext => return None, |
| 135 | }) |
| 136 | } |
| 137 | |
| 138 | #[cfg(test)] |
| 139 | mod tests { |
| 140 | use super::{Motion, apply, motion, motion_while_typing, wrap_index}; |
| 141 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
| 142 | |
| 143 | fn key(code: KeyCode) -> KeyEvent { |
| 144 | KeyEvent::new(code, KeyModifiers::NONE) |
| 145 | } |
| 146 | |
| 147 | /// The reason there are two entry points. A picker with a live filter that |
| 148 | /// routed `j` to "move down" would silently eat the letter out of the |
| 149 | /// user's query (#6290). |
| 150 | #[test] |
| 151 | fn typing_safe_motions_never_claim_a_letter() { |
| 152 | for c in ['j', 'k', 'h', 'l', 'g', 'q'] { |
| 153 | assert_eq!( |
| 154 | motion_while_typing(&key(KeyCode::Char(c))), |
| 155 | None, |
| 156 | "`{c}` must stay available to a text filter" |
| 157 | ); |
| 158 | } |
| 159 | assert_eq!(motion_while_typing(&key(KeyCode::Down)), Some(Motion::Next)); |
| 160 | assert_eq!(motion_while_typing(&key(KeyCode::End)), Some(Motion::Last)); |
| 161 | } |
| 162 | |
| 163 | #[test] |
| 164 | fn letter_aliases_exist_only_where_nothing_is_being_typed() { |
| 165 | assert_eq!(motion(&key(KeyCode::Char('j'))), Some(Motion::Next)); |
| 166 | assert_eq!(motion(&key(KeyCode::Char('k'))), Some(Motion::Prev)); |
| 167 | assert_eq!(motion(&key(KeyCode::Char('h'))), Some(Motion::RegionPrev)); |
| 168 | assert_eq!(motion(&key(KeyCode::Char('l'))), Some(Motion::RegionNext)); |
| 169 | } |
| 170 | |
| 171 | /// `h`/`l` and `Left`/`Right` were two idioms for one motion in two pickers |
| 172 | /// one screen apart. They resolve to the same `Motion` now. |
| 173 | #[test] |
| 174 | fn the_two_horizontal_idioms_agree() { |
| 175 | assert_eq!( |
| 176 | motion(&key(KeyCode::Char('l'))), |
| 177 | motion(&key(KeyCode::Right)) |
| 178 | ); |
| 179 | assert_eq!( |
| 180 | motion(&key(KeyCode::Char('h'))), |
| 181 | motion(&key(KeyCode::Left)) |
| 182 | ); |
| 183 | assert_eq!(motion(&key(KeyCode::Tab)), Some(Motion::RegionNext)); |
| 184 | } |
| 185 | |
| 186 | /// A surface's own shortcuts keep their keys: Ctrl/Alt is never a motion. |
| 187 | #[test] |
| 188 | fn modified_keys_are_not_motions() { |
| 189 | let ctrl_down = KeyEvent::new(KeyCode::Down, KeyModifiers::CONTROL); |
| 190 | assert_eq!(motion(&ctrl_down), None); |
| 191 | assert_eq!(motion_while_typing(&ctrl_down), None); |
| 192 | } |
| 193 | |
| 194 | /// Rows wrap; pages clamp. PageDown is a request to travel, not to |
| 195 | /// teleport back to the top. |
| 196 | #[test] |
| 197 | fn rows_wrap_and_pages_clamp() { |
| 198 | assert_eq!(apply(2, 3, 10, Motion::Next), Some(0)); |
| 199 | assert_eq!(apply(0, 3, 10, Motion::Prev), Some(2)); |
| 200 | assert_eq!(apply(1, 40, 10, Motion::PageNext), Some(11)); |
| 201 | assert_eq!(apply(38, 40, 10, Motion::PageNext), Some(39)); |
| 202 | assert_eq!(apply(3, 40, 10, Motion::PagePrev), Some(0)); |
| 203 | assert_eq!(apply(7, 40, 10, Motion::Last), Some(39)); |
| 204 | } |
| 205 | |
| 206 | #[test] |
| 207 | fn horizontal_motion_is_the_surfaces_to_resolve() { |
| 208 | assert_eq!(apply(1, 5, 10, Motion::RegionNext), None); |
| 209 | assert_eq!(apply(1, 5, 10, Motion::RegionPrev), None); |
| 210 | } |
| 211 | |
| 212 | #[test] |
| 213 | fn an_empty_list_has_no_selection_to_move() { |
| 214 | assert_eq!(apply(0, 0, 10, Motion::Next), Some(0)); |
| 215 | assert_eq!(apply(0, 0, 10, Motion::Last), Some(0)); |
| 216 | } |
| 217 | |
| 218 | #[test] |
| 219 | fn wraps_forward_and_backward() { |
| 220 | assert_eq!(wrap_index(0, 3, -1), 2); |
| 221 | assert_eq!(wrap_index(2, 3, 1), 0); |
| 222 | assert_eq!(wrap_index(1, 3, 1), 2); |
| 223 | assert_eq!(wrap_index(1, 3, -1), 0); |
| 224 | } |
| 225 | |
| 226 | #[test] |
| 227 | fn empty_list_stays_at_zero() { |
| 228 | assert_eq!(wrap_index(5, 0, 1), 0); |
| 229 | assert_eq!(wrap_index(0, 0, -1), 0); |
| 230 | } |
| 231 | } |
| 232 |