//! The focus ring — the one piece ratatui does not provide. //! //! Rendering is immediate-mode, but input is event-driven, so something has to //! remember which pane the next keystroke belongs to. Per //! docs/COMPONENT-LIBRARY.md that model lives here, and it is deliberately //! minimal: an index into a fixed number of focusable slots, wrapping in both //! directions. Apps map their own pane enum onto the index. /// A wrapping cursor over `len` focusable slots. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct FocusRing { len: usize, index: usize, } impl FocusRing { /// A ring over `len` slots, focused on the first. /// /// A zero-length ring is legal and inert: `current()` reports 0 and the /// movers do nothing, so a view that has not yet loaded its panes does not /// have to special-case navigation. pub const fn new(len: usize) -> Self { Self { len, index: 0 } } pub const fn current(&self) -> usize { self.index } pub const fn is_focused(&self, slot: usize) -> bool { self.len > 0 && self.index == slot } pub const fn next(&mut self) { if self.len > 0 { self.index = (self.index + 1) % self.len; } } pub const fn prev(&mut self) { if self.len > 0 { self.index = (self.index + self.len - 1) % self.len; } } /// Focus a specific slot. Out-of-range indices are ignored rather than /// clamped — silently landing on a neighbouring pane is worse than not /// moving. pub const fn focus(&mut self, slot: usize) { if slot < self.len { self.index = slot; } } /// Resize the ring, keeping focus in range. Used when a view's pane count /// changes (a detail pane appearing, a section collapsing). pub const fn resize(&mut self, len: usize) { self.len = len; if self.index >= len { self.index = if len == 0 { 0 } else { len - 1 }; } } } #[cfg(test)] mod tests { use super::*; #[test] fn wraps_in_both_directions() { let mut ring = FocusRing::new(3); ring.prev(); assert_eq!( ring.current(), 2, "prev from the first slot wraps to the last" ); ring.next(); assert_eq!( ring.current(), 0, "next from the last slot wraps to the first" ); } // An empty ring is what a view has before its panes load. The movers must be // no-ops rather than panicking on a modulo by zero. #[test] fn empty_ring_is_inert() { let mut ring = FocusRing::new(0); ring.next(); ring.prev(); assert_eq!(ring.current(), 0); assert!( !ring.is_focused(0), "nothing is focused when there are no slots" ); } #[test] fn resize_pulls_focus_back_into_range() { let mut ring = FocusRing::new(4); ring.focus(3); ring.resize(2); assert_eq!(ring.current(), 1, "focus clamps to the new last slot"); } #[test] fn focus_ignores_out_of_range_slots() { let mut ring = FocusRing::new(2); ring.focus(5); assert_eq!( ring.current(), 0, "an out-of-range focus leaves the ring where it was" ); } }