tui/components/
spinner.rs1use 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 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}