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