Skip to main content

tui/components/
spinner.rs

1use crate::components::{Component, Event, ViewContext};
2use crate::line::Line;
3use crate::rendering::frame::Frame;
4
5pub const BRAILLE_FRAMES: &[char] = &['⠒', '⠮', '⠷', '⢷', '⡾', '⣯', '⣽', '⣿', '⣭', '⢯'];
6
7pub struct Spinner {
8    tick: u16,
9    pub visible: bool,
10    frames: &'static [char],
11}
12
13impl Spinner {
14    pub fn new(frames: &'static [char]) -> Self {
15        Self { tick: 0, visible: false, frames }
16    }
17
18    pub fn braille() -> Self {
19        Self::new(BRAILLE_FRAMES)
20    }
21
22    pub fn current_frame(&self) -> char {
23        self.frames[self.frame_index()]
24    }
25
26    pub fn frame_index(&self) -> usize {
27        self.tick as usize % self.frames.len()
28    }
29
30    pub fn reset(&mut self) {
31        self.tick = 0;
32        self.visible = true;
33    }
34
35    #[allow(dead_code)]
36    pub fn set_tick(&mut self, tick: u16) {
37        self.tick = tick;
38    }
39
40    /// Advance the animation state. Call this on tick events.
41    pub fn on_tick(&mut self) {
42        if self.visible {
43            self.tick = self.tick.wrapping_add(1);
44        }
45    }
46}
47
48impl Component for Spinner {
49    type Message = ();
50
51    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
52        match event {
53            Event::Tick => {
54                self.on_tick();
55                Some(vec![])
56            }
57            _ => None,
58        }
59    }
60
61    fn render(&mut self, context: &ViewContext) -> Frame {
62        if !self.visible {
63            return Frame::new(vec![]);
64        }
65
66        let ch = self.current_frame();
67        let mut line = Line::default();
68        line.push_styled(ch.to_string(), context.theme.info());
69        Frame::new(vec![line])
70    }
71}
72
73impl Default for Spinner {
74    fn default() -> Self {
75        Self::braille()
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn invisible_renders_empty() {
85        let mut spinner = Spinner::default();
86        let ctx = ViewContext::new((80, 24));
87        let frame = spinner.render(&ctx);
88        assert!(frame.lines().is_empty());
89    }
90
91    #[test]
92    fn visible_renders_one_line() {
93        let mut spinner = Spinner { visible: true, ..Spinner::default() };
94        let ctx = ViewContext::new((80, 24));
95        let frame = spinner.render(&ctx);
96        assert_eq!(frame.lines().len(), 1);
97    }
98
99    #[test]
100    fn different_ticks_produce_different_output() {
101        let ctx = ViewContext::new((80, 24));
102
103        let mut spinner_a = Spinner { visible: true, ..Spinner::default() };
104
105        let mut spinner_b = Spinner { visible: true, ..Spinner::default() };
106        spinner_b.set_tick(1);
107
108        let a = spinner_a.render(&ctx).lines()[0].plain_text();
109        let b = spinner_b.render(&ctx).lines()[0].plain_text();
110
111        assert_ne!(a, b);
112    }
113
114    #[test]
115    fn cycles_after_full_rotation() {
116        let ctx = ViewContext::new((80, 24));
117
118        let mut spinner_a = Spinner { visible: true, ..Spinner::default() };
119
120        let mut spinner_b = Spinner { visible: true, ..Spinner::default() };
121        #[allow(clippy::cast_possible_truncation)]
122        spinner_b.set_tick(BRAILLE_FRAMES.len() as u16);
123
124        let a = spinner_a.render(&ctx).lines()[0].plain_text();
125        let b = spinner_b.render(&ctx).lines()[0].plain_text();
126
127        assert_eq!(a, b);
128    }
129
130    #[test]
131    fn custom_frames() {
132        static CUSTOM: &[char] = &['|', '/', '-', '\\'];
133        let mut spinner = Spinner::new(CUSTOM);
134        spinner.set_tick(1);
135        spinner.visible = true;
136        assert_eq!(spinner.current_frame(), '/');
137    }
138
139    #[test]
140    fn on_tick_advances_when_visible() {
141        let mut spinner = Spinner { visible: true, ..Spinner::default() };
142        spinner.on_tick();
143        assert_eq!(spinner.current_frame(), BRAILLE_FRAMES[1]);
144    }
145
146    #[test]
147    fn on_tick_noop_when_invisible() {
148        let mut spinner = Spinner::default();
149        spinner.on_tick();
150        assert_eq!(spinner.current_frame(), BRAILLE_FRAMES[0]);
151    }
152
153    #[test]
154    fn reset_sets_tick_zero_and_visible() {
155        let mut spinner = Spinner::default();
156        spinner.set_tick(5);
157        spinner.visible = false;
158        spinner.reset();
159        assert!(spinner.visible);
160        assert_eq!(spinner.current_frame(), BRAILLE_FRAMES[0]);
161    }
162}