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 {
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 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}