1use std::sync::atomic::{AtomicU64, Ordering};
21use std::time::Duration;
22
23use bubbletea::{Cmd, Message, Model};
24use lipgloss::Style;
25
26static NEXT_ID: AtomicU64 = AtomicU64::new(1);
28
29fn next_id() -> u64 {
30 NEXT_ID.fetch_add(1, Ordering::Relaxed)
31}
32
33#[derive(Debug, Clone)]
35pub struct Spinner {
36 pub frames: Vec<String>,
38 pub fps: u32,
40}
41
42impl Spinner {
43 #[must_use]
45 pub fn new(frames: Vec<&str>, fps: u32) -> Self {
46 Self {
47 frames: frames.into_iter().map(String::from).collect(),
48 fps,
49 }
50 }
51
52 #[must_use]
54 pub fn frame_duration(&self) -> Duration {
55 if self.fps == 0 {
56 Duration::from_secs(1)
57 } else {
58 Duration::from_secs_f64(1.0 / f64::from(self.fps))
59 }
60 }
61}
62
63pub mod spinners {
65 use super::Spinner;
66
67 #[must_use]
69 pub fn line() -> Spinner {
70 Spinner::new(vec!["|", "/", "-", "\\"], 10)
71 }
72
73 #[must_use]
75 pub fn dot() -> Spinner {
76 Spinner::new(vec!["⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "], 10)
77 }
78
79 #[must_use]
81 pub fn mini_dot() -> Spinner {
82 Spinner::new(vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], 12)
83 }
84
85 #[must_use]
87 pub fn jump() -> Spinner {
88 Spinner::new(vec!["⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"], 10)
89 }
90
91 #[must_use]
93 pub fn pulse() -> Spinner {
94 Spinner::new(vec!["█", "▓", "▒", "░"], 8)
95 }
96
97 #[must_use]
99 pub fn points() -> Spinner {
100 Spinner::new(vec!["∙∙∙", "●∙∙", "∙●∙", "∙∙●"], 7)
101 }
102
103 #[must_use]
105 pub fn globe() -> Spinner {
106 Spinner::new(vec!["🌍", "🌎", "🌏"], 4)
107 }
108
109 #[must_use]
111 pub fn moon() -> Spinner {
112 Spinner::new(vec!["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"], 8)
113 }
114
115 #[must_use]
117 pub fn monkey() -> Spinner {
118 Spinner::new(vec!["🙈", "🙉", "🙊"], 3)
119 }
120
121 #[must_use]
123 pub fn meter() -> Spinner {
124 Spinner::new(vec!["▱▱▱", "▰▱▱", "▰▰▱", "▰▰▰", "▰▰▱", "▰▱▱", "▱▱▱"], 7)
125 }
126
127 #[must_use]
129 pub fn hamburger() -> Spinner {
130 Spinner::new(vec!["☱", "☲", "☴", "☲"], 3)
131 }
132
133 #[must_use]
135 pub fn ellipsis() -> Spinner {
136 Spinner::new(vec!["", ".", "..", "..."], 3)
137 }
138}
139
140#[derive(Debug, Clone)]
142pub struct TickMsg {
143 pub id: u64,
145 tag: u64,
147}
148
149#[derive(Debug, Clone)]
151pub struct SpinnerModel {
152 pub spinner: Spinner,
154 pub style: Style,
156
157 frame: usize,
158 id: u64,
159 tag: u64,
160}
161
162impl Default for SpinnerModel {
163 fn default() -> Self {
164 Self::new()
165 }
166}
167
168impl SpinnerModel {
169 #[must_use]
171 pub fn new() -> Self {
172 Self {
173 spinner: spinners::line(),
174 style: Style::new(),
175 frame: 0,
176 id: next_id(),
177 tag: 0,
178 }
179 }
180
181 #[must_use]
183 pub fn with_spinner(spinner: Spinner) -> Self {
184 Self {
185 spinner,
186 style: Style::new(),
187 frame: 0,
188 id: next_id(),
189 tag: 0,
190 }
191 }
192
193 #[must_use]
195 pub fn spinner(mut self, spinner: Spinner) -> Self {
196 self.spinner = spinner;
197 self
198 }
199
200 #[must_use]
202 pub fn style(mut self, style: Style) -> Self {
203 self.style = style;
204 self
205 }
206
207 #[must_use]
209 pub fn id(&self) -> u64 {
210 self.id
211 }
212
213 #[must_use]
218 pub fn tick(&self) -> Message {
219 Message::new(TickMsg {
220 id: self.id,
221 tag: self.tag,
222 })
223 }
224
225 fn tick_cmd(&self) -> Cmd {
227 let id = self.id;
228 let tag = self.tag;
229 let duration = self.spinner.frame_duration();
230
231 Cmd::new(move || {
232 std::thread::sleep(duration);
233 Message::new(TickMsg { id, tag })
234 })
235 }
236
237 pub fn update(&mut self, msg: Message) -> Option<Cmd> {
241 if let Some(tick) = msg.downcast_ref::<TickMsg>() {
242 if tick.id > 0 && tick.id != self.id {
244 return None;
245 }
246
247 if tick.tag != self.tag {
249 return None;
250 }
251
252 self.frame += 1;
254 if self.frame >= self.spinner.frames.len() {
255 self.frame = 0;
256 }
257
258 self.tag = self.tag.wrapping_add(1);
260 return Some(self.tick_cmd());
261 }
262
263 None
264 }
265
266 #[must_use]
268 pub fn view(&self) -> String {
269 if self.frame >= self.spinner.frames.len() {
270 return "(error)".to_string();
271 }
272
273 self.style.render(&self.spinner.frames[self.frame])
274 }
275}
276
277impl Model for SpinnerModel {
279 fn init(&self) -> Option<Cmd> {
280 let id = self.id;
282 let tag = self.tag;
283 let duration = self.spinner.frame_duration();
284
285 Some(Cmd::new(move || {
286 std::thread::sleep(duration);
287 Message::new(TickMsg { id, tag })
288 }))
289 }
290
291 fn update(&mut self, msg: Message) -> Option<Cmd> {
292 SpinnerModel::update(self, msg)
293 }
294
295 fn view(&self) -> String {
296 SpinnerModel::view(self)
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn test_spinner_new() {
306 let spinner = SpinnerModel::new();
307 assert!(!spinner.spinner.frames.is_empty());
308 assert!(spinner.id() > 0);
309 }
310
311 #[test]
312 fn test_spinner_unique_ids() {
313 let s1 = SpinnerModel::new();
314 let s2 = SpinnerModel::new();
315 assert_ne!(s1.id(), s2.id());
316 }
317
318 #[test]
319 fn test_spinner_with_style() {
320 let spinner = SpinnerModel::with_spinner(spinners::dot());
321 assert_eq!(spinner.spinner.frames.len(), 8);
322 }
323
324 #[test]
325 fn test_spinner_view() {
326 let spinner = SpinnerModel::new();
327 let view = spinner.view();
328 assert!(!view.is_empty());
329 }
330
331 #[test]
332 fn test_spinner_frame_advance() {
333 let mut spinner = SpinnerModel::new();
334 let initial_frame = spinner.frame;
335
336 let tick = Message::new(TickMsg {
338 id: spinner.id(),
339 tag: spinner.tag,
340 });
341 spinner.update(tick);
342
343 assert_eq!(spinner.frame, initial_frame + 1);
344 }
345
346 #[test]
347 fn test_spinner_frame_wrap() {
348 let mut spinner = SpinnerModel::with_spinner(Spinner::new(vec!["a", "b"], 10));
349 spinner.frame = 1;
350 spinner.tag = 0;
351
352 let tick = Message::new(TickMsg {
353 id: spinner.id(),
354 tag: 0,
355 });
356 spinner.update(tick);
357
358 assert_eq!(spinner.frame, 0); }
360
361 #[test]
362 fn test_spinner_ignores_other_ids() {
363 let mut spinner = SpinnerModel::new();
364 let initial_frame = spinner.frame;
365
366 let tick = Message::new(TickMsg { id: 9999, tag: 0 });
368 spinner.update(tick);
369
370 assert_eq!(spinner.frame, initial_frame); }
372
373 #[test]
374 fn test_spinner_ignores_old_tags() {
375 let mut spinner = SpinnerModel::new();
376 spinner.tag = 5;
377 let initial_frame = spinner.frame;
378
379 let tick = Message::new(TickMsg {
381 id: spinner.id(),
382 tag: 3,
383 });
384 spinner.update(tick);
385
386 assert_eq!(spinner.frame, initial_frame); }
388
389 #[test]
390 fn test_spinner_rejects_stale_zero_tag() {
391 let mut spinner = SpinnerModel::new();
392 spinner.tag = 1;
393 let initial_frame = spinner.frame;
394
395 let tick = Message::new(TickMsg {
396 id: spinner.id(),
397 tag: 0,
398 });
399 spinner.update(tick);
400
401 assert_eq!(spinner.frame, initial_frame);
402 }
403
404 #[test]
405 fn test_predefined_spinners() {
406 let _ = spinners::line();
408 let _ = spinners::dot();
409 let _ = spinners::mini_dot();
410 let _ = spinners::jump();
411 let _ = spinners::pulse();
412 let _ = spinners::points();
413 let _ = spinners::globe();
414 let _ = spinners::moon();
415 let _ = spinners::monkey();
416 let _ = spinners::meter();
417 let _ = spinners::hamburger();
418 let _ = spinners::ellipsis();
419 }
420
421 #[test]
422 fn test_spinner_frame_duration() {
423 let spinner = Spinner::new(vec!["a"], 10);
424 assert_eq!(spinner.frame_duration(), Duration::from_millis(100));
425
426 let spinner = Spinner::new(vec!["a"], 0);
427 assert_eq!(spinner.frame_duration(), Duration::from_secs(1));
428 }
429
430 #[test]
431 fn test_model_init_returns_tick_cmd() {
432 let spinner = SpinnerModel::new();
433 let cmd = Model::init(&spinner);
434 assert!(cmd.is_some());
435 }
436
437 #[test]
438 fn test_model_update_advances_frame() {
439 let mut spinner = SpinnerModel::new();
440 let initial_frame = spinner.frame;
441 let tick = Message::new(TickMsg {
442 id: spinner.id(),
443 tag: spinner.tag,
444 });
445
446 let cmd = Model::update(&mut spinner, tick);
447
448 assert!(cmd.is_some());
449 assert_eq!(spinner.frame, initial_frame + 1);
450 }
451
452 #[test]
453 fn test_model_view_matches_view() {
454 let spinner = SpinnerModel::new();
455 assert_eq!(Model::view(&spinner), spinner.view());
456 }
457}