返回 CodeWhale
list_nav.rs
根目录 / crates / tui / src / tui / list_nav.rs
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
232 lines RUST