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 custom_frames() {
85        static CUSTOM: &[char] = &['|', '/', '-', '\\'];
86        let mut spinner = Spinner::new(CUSTOM);
87        spinner.set_tick(1);
88        spinner.visible = true;
89        assert_eq!(spinner.current_frame(), '/');
90    }
91
92    #[test]
93    fn on_tick_advances_when_visible() {
94        let mut spinner = Spinner { visible: true, ..Spinner::default() };
95        spinner.on_tick();
96        assert_eq!(spinner.current_frame(), BRAILLE_FRAMES[1]);
97    }
98
99    #[test]
100    fn on_tick_noop_when_invisible() {
101        let mut spinner = Spinner::default();
102        spinner.on_tick();
103        assert_eq!(spinner.current_frame(), BRAILLE_FRAMES[0]);
104    }
105
106    #[test]
107    fn reset_sets_tick_zero_and_visible() {
108        let mut spinner = Spinner::default();
109        spinner.set_tick(5);
110        spinner.visible = false;
111        spinner.reset();
112        assert!(spinner.visible);
113        assert_eq!(spinner.current_frame(), BRAILLE_FRAMES[0]);
114    }
115}