1use std::time::Duration;
4
5#[derive(Debug, Clone)]
11pub struct SpinnerFrames {
12 pub frames: &'static [&'static str],
13 pub interval: f64, }
15
16pub const SPINNER_DOTS: SpinnerFrames = SpinnerFrames {
21 frames: &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
22 interval: 0.08,
23};
24
25pub const SPINNER_LINE: SpinnerFrames = SpinnerFrames {
26 frames: &["-", "\\", "|", "/"],
27 interval: 0.1,
28};
29
30pub const SPINNER_DOTS2: SpinnerFrames = SpinnerFrames {
31 frames: &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"],
32 interval: 0.08,
33};
34
35pub const SPINNER_DOTS3: SpinnerFrames = SpinnerFrames {
36 frames: &["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],
37 interval: 0.08,
38};
39
40pub const SPINNER_DOTS4: SpinnerFrames = SpinnerFrames {
41 frames: &["⠄", "⠆", "⠇", "⠋", "⠙", "⠸", "⠰", "⠠", "⠰", "⠸", "⠙", "⠋", "⠇", "⠆"],
42 interval: 0.08,
43};
44
45pub const SPINNER_DOTS5: SpinnerFrames = SpinnerFrames {
46 frames: &["⠋", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋"],
47 interval: 0.08,
48};
49
50pub const SPINNER_DOTS6: SpinnerFrames = SpinnerFrames {
51 frames: &[
52 "⠁", "⠉", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠤", "⠄", "⠄", "⠤", "⠴", "⠲", "⠒",
53 "⠂", "⠂", "⠒", "⠚", "⠙", "⠉", "⠁",
54 ],
55 interval: 0.08,
56};
57
58pub const SPINNER_DOTS7: SpinnerFrames = SpinnerFrames {
59 frames: &[
60 "⠈", "⠉", "⠋", "⠓", "⠒", "⠐", "⠐", "⠒", "⠖", "⠦", "⠤", "⠠", "⠠", "⠤", "⠦", "⠖", "⠒",
61 "⠐", "⠐", "⠒", "⠓", "⠋", "⠉", "⠈",
62 ],
63 interval: 0.08,
64};
65
66pub const SPINNER_DOTS8: SpinnerFrames = SpinnerFrames {
67 frames: &[
68 "⠁", "⠁", "⠉", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠤", "⠄", "⠄", "⠤", "⠠", "⠠",
69 "⠤", "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋", "⠉", "⠈", "⠈",
70 ],
71 interval: 0.08,
72};
73
74pub const SPINNER_DOTS9: SpinnerFrames = SpinnerFrames {
75 frames: &["⢹", "⢺", "⢼", "⣸", "⣇", "⡧", "⡗", "⡏"],
76 interval: 0.08,
77};
78
79pub const SPINNER_DOTS10: SpinnerFrames = SpinnerFrames {
80 frames: &["⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"],
81 interval: 0.08,
82};
83
84pub const SPINNER_DOTS11: SpinnerFrames = SpinnerFrames {
85 frames: &["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"],
86 interval: 0.1,
87};
88
89pub const SPINNER_SIMPLE_DOTS: SpinnerFrames = SpinnerFrames {
90 frames: &[". ", ".. ", "...", " ..", " .", " "],
91 interval: 0.2,
92};
93
94pub const SPINNER_MOON: SpinnerFrames = SpinnerFrames {
99 frames: &["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"],
100 interval: 0.08,
101};
102
103pub const SPINNER_SMILEY: SpinnerFrames = SpinnerFrames {
104 frames: &["😄", "😝"],
105 interval: 0.2,
106};
107
108pub const SPINNER_ARC: SpinnerFrames = SpinnerFrames {
113 frames: &["◜", "◠", "◝", "◞", "◡", "◟"],
114 interval: 0.1,
115};
116
117pub const SPINNER_ARROW: SpinnerFrames = SpinnerFrames {
118 frames: &["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"],
119 interval: 0.1,
120};
121
122pub const SPINNER_ARROW2: SpinnerFrames = SpinnerFrames {
123 frames: &["⬆️", "↗️", "➡️", "↘️", "⬇️", "↙️", "⬅️", "↖️"],
124 interval: 0.1,
125};
126
127pub const SPINNER_ARROW3: SpinnerFrames = SpinnerFrames {
128 frames: &["▹", "▸", "▹", "▸", "▹", "▸"],
129 interval: 0.1,
130};
131
132pub const SPINNER_BOUNCING_BAR: SpinnerFrames = SpinnerFrames {
133 frames: &["[ ]", "[= ]", "[== ]", "[=== ]", "[ ===]", "[ ==]", "[ =]", "[ ]"],
134 interval: 0.15,
135};
136
137pub const SPINNER_BOUNCING_BALL: SpinnerFrames = SpinnerFrames {
138 frames: &[
139 "( ● )", "( ● )", "( ● )", "( ● )", "( ●)", "( ● )", "( ● )",
140 "( ● )",
141 ],
142 interval: 0.15,
143};
144
145pub const SPINNER_CHRISTMAS: SpinnerFrames = SpinnerFrames {
146 frames: &["🌲", "🎄"],
147 interval: 0.4,
148};
149
150pub const SPINNER_CIRCLE: SpinnerFrames = SpinnerFrames {
151 frames: &["◐", "◓", "◑", "◒"],
152 interval: 0.1,
153};
154
155pub const SPINNER_CLOCK: SpinnerFrames = SpinnerFrames {
156 frames: &[
157 "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", "🕛",
158 ],
159 interval: 0.1,
160};
161
162pub const SPINNER_EARTH: SpinnerFrames = SpinnerFrames {
163 frames: &["🌍", "🌎", "🌏"],
164 interval: 0.2,
165};
166
167pub const SPINNER_GRENADE: SpinnerFrames = SpinnerFrames {
168 frames: &["، 💣 ", "۔ 💣 ", " ﹒ 💣 "],
169 interval: 0.1,
170};
171
172pub const SPINNER_GROW_HORIZONTAL: SpinnerFrames = SpinnerFrames {
173 frames: &["▏", "▎", "▍", "▌", "▋", "▊", "▉", "▉", "▊", "▋", "▌", "▍", "▎", "▏"],
174 interval: 0.08,
175};
176
177pub const SPINNER_GROW_VERTICAL: SpinnerFrames = SpinnerFrames {
178 frames: &["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▁"],
179 interval: 0.08,
180};
181
182pub const SPINNER_HAMBURGER: SpinnerFrames = SpinnerFrames {
183 frames: &["☱", "☲", "☴"],
184 interval: 0.12,
185};
186
187pub const SPINNER_HEARTS: SpinnerFrames = SpinnerFrames {
188 frames: &["🩷", "❤️", "🧡", "💛", "💚", "💙", "🩵", "💜", "🤎", "🖤", "🩶", "🤍"],
189 interval: 0.12,
190};
191
192pub const SPINNER_MONKEY: SpinnerFrames = SpinnerFrames {
193 frames: &["🐒", "🐒", "🐒", "🐒", "🙈", "🙉", "🙊", "🐒", "🐒", "🐒", "🐒"],
194 interval: 0.15,
195};
196
197pub const SPINNER_NOISE: SpinnerFrames = SpinnerFrames {
198 frames: &["▓", "▒", "░", "▓", "▒", "░", "▓", "▒", "░"],
199 interval: 0.08,
200};
201
202pub const SPINNER_PONG: SpinnerFrames = SpinnerFrames {
203 frames: &[
204 "▐⠂ ▌", "▐⠈ ▌", "▐ ⠂ ▌", "▐ ⠠ ▌",
205 "▐ ⡀ ▌", "▐ ⠠ ▌", "▐ ⠂ ▌", "▐ ⠈ ▌",
206 "▐ ⠂ ▌", "▐ ⠠ ▌", "▐ ⡀ ▌", "▐ ⠠ ▌",
207 "▐ ⠂ ▌", "▐ ⠈ ▌", "▐ ⠂▌", "▐ ⠠▌",
208 "▐ ⡀▌", "▐ ⠠ ▌", "▐ ⠂ ▌", "▐ ⠈ ▌",
209 "▐ ⠂ ▌", "▐ ⠠ ▌", "▐ ⡀ ▌", "▐ ⠠ ▌",
210 "▐ ⠂ ▌", "▐ ⠈ ▌", "▐ ⠂ ▌", "▐ ⠠ ▌",
211 "▐ ⠂ ▌", "▐⠈ ▌",
212 ],
213 interval: 0.08,
214};
215
216pub const SPINNER_RUNNER: SpinnerFrames = SpinnerFrames {
217 frames: &["🚶", "🏃", "🏃", "🏃", "🚶", "🚶"],
218 interval: 0.15,
219};
220
221pub const SPINNER_SHARK: SpinnerFrames = SpinnerFrames {
222 frames: &["🦈", "🌀", "🦈", "🌀", "🦈", "🌀"],
223 interval: 0.15,
224};
225
226pub const SPINNER_TOGGLE: SpinnerFrames = SpinnerFrames {
227 frames: &["⊶", "⊷"],
228 interval: 0.2,
229};
230
231pub const SPINNER_TRIANGLE: SpinnerFrames = SpinnerFrames {
232 frames: &["◢", "◣", "◤", "◥"],
233 interval: 0.1,
234};
235
236pub const SPINNER_VERTICAL_BARS: SpinnerFrames = SpinnerFrames {
237 frames: &["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂", "▁"],
238 interval: 0.08,
239};
240
241pub const DEFAULT_SPINNER: SpinnerFrames = SPINNER_DOTS;
243
244pub const SPINNERS: &[(&str, &SpinnerFrames)] = &[
250 ("arc", &SPINNER_ARC),
251 ("arrow", &SPINNER_ARROW),
252 ("arrow2", &SPINNER_ARROW2),
253 ("arrow3", &SPINNER_ARROW3),
254 ("bouncingBar", &SPINNER_BOUNCING_BAR),
255 ("bouncingBall", &SPINNER_BOUNCING_BALL),
256 ("christmas", &SPINNER_CHRISTMAS),
257 ("circle", &SPINNER_CIRCLE),
258 ("clock", &SPINNER_CLOCK),
259 ("dots", &SPINNER_DOTS),
260 ("dots2", &SPINNER_DOTS2),
261 ("dots3", &SPINNER_DOTS3),
262 ("dots4", &SPINNER_DOTS4),
263 ("dots5", &SPINNER_DOTS5),
264 ("dots6", &SPINNER_DOTS6),
265 ("dots7", &SPINNER_DOTS7),
266 ("dots8", &SPINNER_DOTS8),
267 ("dots9", &SPINNER_DOTS9),
268 ("dots10", &SPINNER_DOTS10),
269 ("dots11", &SPINNER_DOTS11),
270 ("earth", &SPINNER_EARTH),
271 ("grenade", &SPINNER_GRENADE),
272 ("growHorizontal", &SPINNER_GROW_HORIZONTAL),
273 ("growVertical", &SPINNER_GROW_VERTICAL),
274 ("hamburger", &SPINNER_HAMBURGER),
275 ("hearts", &SPINNER_HEARTS),
276 ("line", &SPINNER_LINE),
277 ("monkey", &SPINNER_MONKEY),
278 ("moon", &SPINNER_MOON),
279 ("noise", &SPINNER_NOISE),
280 ("pong", &SPINNER_PONG),
281 ("runner", &SPINNER_RUNNER),
282 ("shark", &SPINNER_SHARK),
283 ("simpleDots", &SPINNER_SIMPLE_DOTS),
284 ("smiley", &SPINNER_SMILEY),
285 ("toggle", &SPINNER_TOGGLE),
286 ("triangle", &SPINNER_TRIANGLE),
287 ("verticalBars", &SPINNER_VERTICAL_BARS),
288];
289
290pub fn get_spinner(name: &str) -> Option<&'static SpinnerFrames> {
303 for (key, spinner) in SPINNERS {
305 if key.eq_ignore_ascii_case(name) {
306 return Some(spinner);
307 }
308 }
309 let normalized: String = name.chars().filter(|c| !c.is_whitespace()).collect();
311 let normalized = normalized.to_lowercase();
312 for (key, spinner) in SPINNERS {
313 let key_normalized: String = key.chars().filter(|c| !c.is_whitespace()).collect();
314 let key_normalized = key_normalized.to_lowercase();
315 if key_normalized == normalized {
316 return Some(spinner);
317 }
318 }
319 None
320}
321
322#[derive(Debug, Clone)]
328pub struct Spinner {
329 pub frames: &'static [&'static str],
330 pub interval: f64,
331 pub text: String,
333 pub style: crate::style::Style,
335}
336
337impl Spinner {
338 pub fn new(spinner: &'static SpinnerFrames) -> Self {
340 Self {
341 frames: spinner.frames,
342 interval: spinner.interval,
343 text: String::new(),
344 style: crate::style::Style::new(),
345 }
346 }
347
348 pub fn text(mut self, text: impl Into<String>) -> Self {
350 self.text = text.into();
351 self
352 }
353
354 pub fn style(mut self, style: crate::style::Style) -> Self {
356 self.style = style;
357 self
358 }
359
360 pub fn frame_at(&self, elapsed: Duration) -> &'static str {
362 let idx = (elapsed.as_secs_f64() / self.interval) as usize % self.frames.len();
363 self.frames[idx]
364 }
365
366 pub fn render(&self, elapsed: Duration) -> String {
368 let frame = self.frame_at(elapsed);
369 let style_ansi = self.style.to_ansi();
370 let reset = if style_ansi.is_empty() { "" } else { "\x1b[0m" };
371 if self.text.is_empty() {
372 format!("{style_ansi}{frame}{reset}")
373 } else {
374 format!("{style_ansi}{frame}{reset} {}", self.text)
375 }
376 }
377}
378
379impl Default for Spinner {
380 fn default() -> Self {
381 Self::new(&DEFAULT_SPINNER)
382 }
383}
384
385#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn test_spinner_frame_at() {
395 let s = Spinner::new(&SPINNER_LINE);
396 let f = s.frame_at(Duration::from_millis(200));
397 assert!(f == "-" || f == "\\" || f == "|" || f == "/");
398 }
399
400 #[test]
401 fn test_get_spinner_found() {
402 let s = get_spinner("dots").unwrap();
403 assert!(!s.frames.is_empty());
404
405 let s = get_spinner("DOTS").unwrap();
406 assert!(!s.frames.is_empty());
407
408 let s = get_spinner("arc").unwrap();
409 assert_eq!(s.frames.len(), 6);
410 }
411
412 #[test]
413 fn test_get_spinner_not_found() {
414 assert!(get_spinner("nonexistent").is_none());
415 }
416
417 #[test]
418 fn test_get_spinner_case_insensitive() {
419 let s1 = get_spinner("ARC").unwrap();
420 let s2 = get_spinner("arc").unwrap();
421 assert_eq!(s1.frames, s2.frames);
422 }
423
424 #[test]
425 fn test_get_spinner_camel_case() {
426 let s = get_spinner("bouncingBar").unwrap();
427 assert!(!s.frames.is_empty());
428
429 let s = get_spinner("BOUNCINGBAR").unwrap();
430 assert!(!s.frames.is_empty());
431 }
432
433 #[test]
434 fn test_spinners_list_all_accessible() {
435 for (name, frames) in SPINNERS {
436 let found = get_spinner(name).unwrap();
437 assert!(!frames.frames.is_empty(), "spinner '{}' has no frames", name);
438 assert_eq!(
441 frames.frames, found.frames,
442 "spinner '{}' points to different frames than expected",
443 name
444 );
445 assert!(
446 (frames.interval - found.interval).abs() < f64::EPSILON,
447 "spinner '{}' interval mismatch",
448 name
449 );
450 }
451 }
452
453 #[test]
454 fn test_spinner_arc_frames() {
455 assert_eq!(SPINNER_ARC.frames.len(), 6);
456 assert!(SPINNER_ARC.interval > 0.0);
457 }
458
459 #[test]
460 fn test_spinner_arrow_frames() {
461 assert_eq!(SPINNER_ARROW.frames.len(), 8);
462 }
463
464 #[test]
465 fn test_spinner_arrow2_frames() {
466 assert_eq!(SPINNER_ARROW2.frames.len(), 8);
467 }
468
469 #[test]
470 fn test_spinner_arrow3_frames() {
471 assert_eq!(SPINNER_ARROW3.frames.len(), 6);
472 }
473
474 #[test]
475 fn test_spinner_bouncing_bar() {
476 assert_eq!(SPINNER_BOUNCING_BAR.frames.len(), 8);
477 }
478
479 #[test]
480 fn test_spinner_bouncing_ball() {
481 assert_eq!(SPINNER_BOUNCING_BALL.frames.len(), 8);
482 }
483
484 #[test]
485 fn test_spinner_christmas() {
486 assert_eq!(SPINNER_CHRISTMAS.frames.len(), 2);
487 }
488
489 #[test]
490 fn test_spinner_circle() {
491 assert_eq!(SPINNER_CIRCLE.frames.len(), 4);
492 }
493
494 #[test]
495 fn test_spinner_clock() {
496 assert_eq!(SPINNER_CLOCK.frames.len(), 12);
497 }
498
499 #[test]
500 fn test_spinner_earth() {
501 assert_eq!(SPINNER_EARTH.frames.len(), 3);
502 }
503
504 #[test]
505 fn test_spinner_grenade() {
506 assert_eq!(SPINNER_GRENADE.frames.len(), 3);
507 }
508
509 #[test]
510 fn test_spinner_grow_horizontal() {
511 assert_eq!(SPINNER_GROW_HORIZONTAL.frames.len(), 14);
512 }
513
514 #[test]
515 fn test_spinner_grow_vertical() {
516 assert_eq!(SPINNER_GROW_VERTICAL.frames.len(), 14);
517 }
518
519 #[test]
520 fn test_spinner_hamburger() {
521 assert_eq!(SPINNER_HAMBURGER.frames.len(), 3);
522 }
523
524 #[test]
525 fn test_spinner_hearts() {
526 assert_eq!(SPINNER_HEARTS.frames.len(), 12);
527 }
528
529 #[test]
530 fn test_spinner_monkey() {
531 assert_eq!(SPINNER_MONKEY.frames.len(), 11);
532 }
533
534 #[test]
535 fn test_spinner_noise() {
536 assert_eq!(SPINNER_NOISE.frames.len(), 9);
537 }
538
539 #[test]
540 fn test_spinner_pong() {
541 assert_eq!(SPINNER_PONG.frames.len(), 30);
542 }
543
544 #[test]
545 fn test_spinner_runner() {
546 assert_eq!(SPINNER_RUNNER.frames.len(), 6);
547 }
548
549 #[test]
550 fn test_spinner_shark() {
551 assert_eq!(SPINNER_SHARK.frames.len(), 6);
552 }
553
554 #[test]
555 fn test_spinner_toggle() {
556 assert_eq!(SPINNER_TOGGLE.frames.len(), 2);
557 }
558
559 #[test]
560 fn test_spinner_triangle() {
561 assert_eq!(SPINNER_TRIANGLE.frames.len(), 4);
562 }
563
564 #[test]
565 fn test_spinner_vertical_bars() {
566 assert_eq!(SPINNER_VERTICAL_BARS.frames.len(), 15);
567 }
568
569 #[test]
570 fn test_spinner_interval_positive() {
571 for (name, frames) in SPINNERS {
572 assert!(
573 frames.interval > 0.0,
574 "spinner '{}' has non-positive interval",
575 name
576 );
577 }
578 }
579
580 #[test]
581 fn test_default_spinner_is_dots() {
582 assert_eq!(DEFAULT_SPINNER.frames, SPINNER_DOTS.frames);
583 }
584}