Skip to main content

tui/
focus.rs

1use crossterm::event::{KeyCode, KeyEvent};
2
3/// Tracks which child in a list of focusable items is currently focused.
4///
5/// A `FocusRing` is a simple index tracker with wrap-around cycling. Parent
6/// components own it and use it to:
7/// - Track which child is focused
8/// - Handle Tab/BackTab navigation
9/// - Query focus state for rendering (e.g. `ring.is_focused(i)`)
10///
11/// # Example
12///
13/// ```
14/// use tui::FocusRing;
15///
16/// let mut ring = FocusRing::new(3);
17/// assert_eq!(ring.focused(), 0);
18///
19/// ring.focus_next();
20/// assert_eq!(ring.focused(), 1);
21///
22/// ring.focus_next();
23/// ring.focus_next();
24/// assert_eq!(ring.focused(), 0); // wraps around
25/// ```
26pub struct FocusRing {
27    focused: usize,
28    len: usize,
29    wrap: bool,
30}
31
32/// The result of [`FocusRing::handle_key`].
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum FocusOutcome {
35    /// Focus moved to a different index.
36    FocusChanged,
37    /// The key was recognized (Tab/BackTab) but focus didn't move (e.g. at boundary without wrap).
38    Unchanged,
39    /// The key was not a focus-navigation key and was ignored.
40    Ignored,
41}
42
43impl FocusRing {
44    /// Create a new `FocusRing` with wrapping enabled.
45    ///
46    /// Focus starts at index 0. If `len` is 0, all navigation is a no-op.
47    pub fn new(len: usize) -> Self {
48        Self {
49            focused: 0,
50            len,
51            wrap: true,
52        }
53    }
54
55    /// Disable wrap-around: `focus_next` at the last item and `focus_prev` at
56    /// the first item will not cycle.
57    pub fn without_wrap(mut self) -> Self {
58        self.wrap = false;
59        self
60    }
61
62    /// The currently focused index.
63    pub fn focused(&self) -> usize {
64        self.focused
65    }
66
67    /// Returns `true` if `index` is the currently focused index.
68    pub fn is_focused(&self, index: usize) -> bool {
69        self.focused == index
70    }
71
72    /// The number of focusable items.
73    pub fn len(&self) -> usize {
74        self.len
75    }
76
77    /// Returns `true` if there are no focusable items.
78    pub fn is_empty(&self) -> bool {
79        self.len == 0
80    }
81
82    /// Update the number of focusable items. Clamps `focused` if it would be
83    /// out of bounds.
84    pub fn set_len(&mut self, len: usize) {
85        self.len = len;
86        if len == 0 {
87            self.focused = 0;
88        } else if self.focused >= len {
89            self.focused = len - 1;
90        }
91    }
92
93    /// Programmatically set focus to `index`. Returns `false` if `index` is
94    /// out of bounds (focus unchanged).
95    pub fn focus(&mut self, index: usize) -> bool {
96        if index < self.len {
97            self.focused = index;
98            true
99        } else {
100            false
101        }
102    }
103
104    /// Move focus to the next item. Returns `true` if focus changed.
105    pub fn focus_next(&mut self) -> bool {
106        if self.len == 0 {
107            return false;
108        }
109        if self.focused + 1 < self.len {
110            self.focused += 1;
111            true
112        } else if self.wrap {
113            self.focused = 0;
114            true
115        } else {
116            false
117        }
118    }
119
120    /// Move focus to the previous item. Returns `true` if focus changed.
121    pub fn focus_prev(&mut self) -> bool {
122        if self.len == 0 {
123            return false;
124        }
125        if self.focused > 0 {
126            self.focused -= 1;
127            true
128        } else if self.wrap {
129            self.focused = self.len - 1;
130            true
131        } else {
132            false
133        }
134    }
135
136    /// Handle Tab (next) and `BackTab` (previous) key events.
137    ///
138    /// Returns [`FocusOutcome::FocusChanged`] if focus moved,
139    /// [`FocusOutcome::Unchanged`] if a focus key was pressed but focus didn't
140    /// move, or [`FocusOutcome::Ignored`] for all other keys.
141    pub fn handle_key(&mut self, key_event: KeyEvent) -> FocusOutcome {
142        match key_event.code {
143            KeyCode::Tab => {
144                if self.focus_next() {
145                    FocusOutcome::FocusChanged
146                } else {
147                    FocusOutcome::Unchanged
148                }
149            }
150            KeyCode::BackTab => {
151                if self.focus_prev() {
152                    FocusOutcome::FocusChanged
153                } else {
154                    FocusOutcome::Unchanged
155                }
156            }
157            _ => FocusOutcome::Ignored,
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
166
167    fn key(code: KeyCode) -> KeyEvent {
168        KeyEvent {
169            code,
170            modifiers: KeyModifiers::empty(),
171            kind: KeyEventKind::Press,
172            state: KeyEventState::empty(),
173        }
174    }
175
176    #[test]
177    fn new_starts_at_zero() {
178        let ring = FocusRing::new(3);
179        assert_eq!(ring.focused(), 0);
180        assert!(ring.is_focused(0));
181        assert!(!ring.is_focused(1));
182        assert_eq!(ring.len(), 3);
183        assert!(!ring.is_empty());
184    }
185
186    #[test]
187    fn cycle_forward_wraps() {
188        let mut ring = FocusRing::new(3);
189        assert!(ring.focus_next());
190        assert_eq!(ring.focused(), 1);
191        assert!(ring.focus_next());
192        assert_eq!(ring.focused(), 2);
193        assert!(ring.focus_next());
194        assert_eq!(ring.focused(), 0); // wrapped
195    }
196
197    #[test]
198    fn cycle_backward_wraps() {
199        let mut ring = FocusRing::new(3);
200        assert!(ring.focus_prev());
201        assert_eq!(ring.focused(), 2); // wrapped to end
202        assert!(ring.focus_prev());
203        assert_eq!(ring.focused(), 1);
204        assert!(ring.focus_prev());
205        assert_eq!(ring.focused(), 0);
206    }
207
208    #[test]
209    fn no_wrap_stops_at_boundaries() {
210        let mut ring = FocusRing::new(3).without_wrap();
211
212        // At start, can't go prev
213        assert!(!ring.focus_prev());
214        assert_eq!(ring.focused(), 0);
215
216        // Go to end
217        assert!(ring.focus_next());
218        assert!(ring.focus_next());
219        assert_eq!(ring.focused(), 2);
220
221        // At end, can't go next
222        assert!(!ring.focus_next());
223        assert_eq!(ring.focused(), 2);
224    }
225
226    #[test]
227    fn empty_ring_is_noop() {
228        let mut ring = FocusRing::new(0);
229        assert!(ring.is_empty());
230        assert_eq!(ring.focused(), 0);
231        assert!(!ring.focus_next());
232        assert!(!ring.focus_prev());
233        assert!(!ring.focus(0));
234    }
235
236    #[test]
237    fn programmatic_focus() {
238        let mut ring = FocusRing::new(5);
239        assert!(ring.focus(3));
240        assert_eq!(ring.focused(), 3);
241        assert!(ring.is_focused(3));
242
243        // Out of bounds
244        assert!(!ring.focus(5));
245        assert_eq!(ring.focused(), 3); // unchanged
246    }
247
248    #[test]
249    fn set_len_clamps_focused() {
250        let mut ring = FocusRing::new(5);
251        ring.focus(4);
252        assert_eq!(ring.focused(), 4);
253
254        ring.set_len(3);
255        assert_eq!(ring.len(), 3);
256        assert_eq!(ring.focused(), 2); // clamped
257
258        ring.set_len(0);
259        assert_eq!(ring.focused(), 0);
260        assert!(ring.is_empty());
261    }
262
263    #[test]
264    fn set_len_preserves_focused_when_in_range() {
265        let mut ring = FocusRing::new(5);
266        ring.focus(2);
267        ring.set_len(4);
268        assert_eq!(ring.focused(), 2); // still valid
269    }
270
271    #[test]
272    fn handle_key_tab_cycles_forward() {
273        let mut ring = FocusRing::new(3);
274        assert_eq!(
275            ring.handle_key(key(KeyCode::Tab)),
276            FocusOutcome::FocusChanged
277        );
278        assert_eq!(ring.focused(), 1);
279    }
280
281    #[test]
282    fn handle_key_backtab_cycles_backward() {
283        let mut ring = FocusRing::new(3);
284        ring.focus(1);
285        assert_eq!(
286            ring.handle_key(key(KeyCode::BackTab)),
287            FocusOutcome::FocusChanged
288        );
289        assert_eq!(ring.focused(), 0);
290    }
291
292    #[test]
293    fn handle_key_other_keys_ignored() {
294        let mut ring = FocusRing::new(3);
295        assert_eq!(ring.handle_key(key(KeyCode::Enter)), FocusOutcome::Ignored);
296        assert_eq!(
297            ring.handle_key(key(KeyCode::Char('a'))),
298            FocusOutcome::Ignored
299        );
300        assert_eq!(ring.focused(), 0); // unchanged
301    }
302
303    #[test]
304    fn handle_key_no_wrap_returns_unchanged() {
305        let mut ring = FocusRing::new(2).without_wrap();
306        // At index 0, BackTab can't go further
307        assert_eq!(
308            ring.handle_key(key(KeyCode::BackTab)),
309            FocusOutcome::Unchanged
310        );
311        assert_eq!(ring.focused(), 0);
312
313        // Go to end
314        ring.focus(1);
315        assert_eq!(ring.handle_key(key(KeyCode::Tab)), FocusOutcome::Unchanged);
316        assert_eq!(ring.focused(), 1);
317    }
318
319    #[test]
320    fn single_item_wrap_returns_true() {
321        // With wrap enabled and len=1, focus_next wraps to 0 (same index).
322        // This still "changed" in the sense that the cycle completed.
323        let mut ring = FocusRing::new(1);
324        assert!(ring.focus_next());
325        assert_eq!(ring.focused(), 0);
326        assert!(ring.focus_prev());
327        assert_eq!(ring.focused(), 0);
328    }
329
330    #[test]
331    fn single_item_no_wrap() {
332        let mut ring = FocusRing::new(1).without_wrap();
333        assert!(!ring.focus_next());
334        assert!(!ring.focus_prev());
335        assert_eq!(ring.focused(), 0);
336    }
337}