Skip to main content

max / alloy_tui

3.3 KB · 119 lines History Blame Raw
1 //! The focus ring — the one piece ratatui does not provide.
2 //!
3 //! Rendering is immediate-mode, but input is event-driven, so something has to
4 //! remember which pane the next keystroke belongs to. Per
5 //! docs/COMPONENT-LIBRARY.md that model lives here, and it is deliberately
6 //! minimal: an index into a fixed number of focusable slots, wrapping in both
7 //! directions. Apps map their own pane enum onto the index.
8
9 /// A wrapping cursor over `len` focusable slots.
10 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
11 pub struct FocusRing {
12 len: usize,
13 index: usize,
14 }
15
16 impl FocusRing {
17 /// A ring over `len` slots, focused on the first.
18 ///
19 /// A zero-length ring is legal and inert: `current()` reports 0 and the
20 /// movers do nothing, so a view that has not yet loaded its panes does not
21 /// have to special-case navigation.
22 pub const fn new(len: usize) -> Self {
23 Self { len, index: 0 }
24 }
25
26 pub const fn current(&self) -> usize {
27 self.index
28 }
29
30 pub const fn is_focused(&self, slot: usize) -> bool {
31 self.len > 0 && self.index == slot
32 }
33
34 pub const fn next(&mut self) {
35 if self.len > 0 {
36 self.index = (self.index + 1) % self.len;
37 }
38 }
39
40 pub const fn prev(&mut self) {
41 if self.len > 0 {
42 self.index = (self.index + self.len - 1) % self.len;
43 }
44 }
45
46 /// Focus a specific slot. Out-of-range indices are ignored rather than
47 /// clamped — silently landing on a neighbouring pane is worse than not
48 /// moving.
49 pub const fn focus(&mut self, slot: usize) {
50 if slot < self.len {
51 self.index = slot;
52 }
53 }
54
55 /// Resize the ring, keeping focus in range. Used when a view's pane count
56 /// changes (a detail pane appearing, a section collapsing).
57 pub const fn resize(&mut self, len: usize) {
58 self.len = len;
59 if self.index >= len {
60 self.index = if len == 0 { 0 } else { len - 1 };
61 }
62 }
63 }
64
65 #[cfg(test)]
66 mod tests {
67 use super::*;
68
69 #[test]
70 fn wraps_in_both_directions() {
71 let mut ring = FocusRing::new(3);
72 ring.prev();
73 assert_eq!(
74 ring.current(),
75 2,
76 "prev from the first slot wraps to the last"
77 );
78 ring.next();
79 assert_eq!(
80 ring.current(),
81 0,
82 "next from the last slot wraps to the first"
83 );
84 }
85
86 // An empty ring is what a view has before its panes load. The movers must be
87 // no-ops rather than panicking on a modulo by zero.
88 #[test]
89 fn empty_ring_is_inert() {
90 let mut ring = FocusRing::new(0);
91 ring.next();
92 ring.prev();
93 assert_eq!(ring.current(), 0);
94 assert!(
95 !ring.is_focused(0),
96 "nothing is focused when there are no slots"
97 );
98 }
99
100 #[test]
101 fn resize_pulls_focus_back_into_range() {
102 let mut ring = FocusRing::new(4);
103 ring.focus(3);
104 ring.resize(2);
105 assert_eq!(ring.current(), 1, "focus clamps to the new last slot");
106 }
107
108 #[test]
109 fn focus_ignores_out_of_range_slots() {
110 let mut ring = FocusRing::new(2);
111 ring.focus(5);
112 assert_eq!(
113 ring.current(),
114 0,
115 "an out-of-range focus leaves the ring where it was"
116 );
117 }
118 }
119