Skip to main content

rusty_rich/
spinner.rs

1//! Spinner — animated spinner. Equivalent to Rich's `spinner.py`.
2
3use std::time::Duration;
4
5// ---------------------------------------------------------------------------
6// Spinner frames
7// ---------------------------------------------------------------------------
8
9/// Predefined spinner animations (matching Rich's spinner set).
10#[derive(Debug, Clone)]
11pub struct SpinnerFrames {
12    pub frames: &'static [&'static str],
13    pub interval: f64, // seconds per frame
14}
15
16// ===========================================================================
17// Classic / dots spinners
18// ===========================================================================
19
20pub 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
94// ===========================================================================
95// Icon / theme spinners
96// ===========================================================================
97
98pub 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
108// ===========================================================================
109// New spinner styles (20+ from Python Rich)
110// ===========================================================================
111
112pub 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
241// ===========================================================================
242// Additional spinners from Python Rich (bringing total to 55+)
243// ===========================================================================
244
245/// Braille dots spinner: vertical bar grows upward then collapses.
246pub const SPINNER_DOTS12: SpinnerFrames = SpinnerFrames {
247    frames: &["⣀", "⣤", "⣶", "⣿", "⣶", "⣤"],
248    interval: 0.08,
249};
250
251/// Braille dots spinner: complex diagonal crawl pattern.
252pub const SPINNER_DOTS13: SpinnerFrames = SpinnerFrames {
253    frames: &["⣼", "⣹", "⢻", "⠿", "⡟", "⣏", "⣧", "⣶"],
254    interval: 0.08,
255};
256
257/// Braille dots 8-bit style: fills from empty to full block.
258pub const SPINNER_DOTS8_BIT: SpinnerFrames = SpinnerFrames {
259    frames: &["⠀", "⠁", "⠂", "⠃", "⠇", "⠏", "⠟", "⠿", "⡿", "⣿", "⣿", "⣿"],
260    interval: 0.08,
261};
262
263/// Simple ASCII dots in a back-and-forth scrolling pattern.
264pub const SPINNER_SIMPLE_DOTS_SCROLLING: SpinnerFrames = SpinnerFrames {
265    frames: &[".  ", ".. ", "...", " ..", "  .", " ..", "...", ".. "],
266    interval: 0.2,
267};
268
269/// Star-spangled spinner using star-shaped Unicode characters.
270pub const SPINNER_STAR: SpinnerFrames = SpinnerFrames {
271    frames: &["✶", "✸", "✹", "✺", "✹", "✷"],
272    interval: 0.1,
273};
274
275/// Minimal star spinner: plus, cross, asterisk.
276pub const SPINNER_STAR2: SpinnerFrames = SpinnerFrames {
277    frames: &["+", "x", "*"],
278    interval: 0.12,
279};
280
281/// Underscore / tick / overbar flipping animation.
282pub const SPINNER_FLIP: SpinnerFrames = SpinnerFrames {
283    frames: &["_", "_", "_", "-", "`", "`", "'", "¯", "_", "_", "_", "-"],
284    interval: 0.1,
285};
286
287/// Balloon inflating: dot, small o, O, at-sign, star, then empty.
288pub const SPINNER_BALLOON: SpinnerFrames = SpinnerFrames {
289    frames: &[". ", "o ", "O ", "@ ", "* ", " "],
290    interval: 0.12,
291};
292
293/// Balloon inflate-deflate: dot, o, O, degree symbol, O, o, dot.
294pub const SPINNER_BALLOON2: SpinnerFrames = SpinnerFrames {
295    frames: &[".", "o", "O", "°", "O", "o", "."],
296    interval: 0.12,
297};
298
299/// Pipe-junction spinner: box-drawing corners cycle clockwise.
300pub const SPINNER_PIPE: SpinnerFrames = SpinnerFrames {
301    frames: &["┤", "┘", "┴", "└", "├", "┌", "┬", "┐"],
302    interval: 0.1,
303};
304
305/// Square corners cycling through four quadrant-filled states.
306pub const SPINNER_SQUARE_CORNERS: SpinnerFrames = SpinnerFrames {
307    frames: &["◰", "◳", "◲", "◱"],
308    interval: 0.12,
309};
310
311/// Circle quadrants rotating counter-clockwise.
312pub const SPINNER_CIRCLE_QUARTERS: SpinnerFrames = SpinnerFrames {
313    frames: &["◴", "◷", "◶", "◵"],
314    interval: 0.12,
315};
316
317/// Circle halves rotating through four semi-filled states.
318pub const SPINNER_CIRCLE_HALVES: SpinnerFrames = SpinnerFrames {
319    frames: &["◐", "◓", "◑", "◒"],
320    interval: 0.12,
321};
322
323/// Minimal two-frame aesthetic: filled and empty block.
324pub const SPINNER_AESTHETIC: SpinnerFrames = SpinnerFrames {
325    frames: &["▰", "▱"],
326    interval: 0.15,
327};
328
329/// Braille dot pattern: single dots travel from top-left to bottom-right.
330pub const SPINNER_BRAILLE_LONG: SpinnerFrames = SpinnerFrames {
331    frames: &["⠁", "⠂", "⠄", "⠠", "⠐", "⠈", "⡀", "⢀"],
332    interval: 0.08,
333};
334
335/// Braille dot crawl: left column fills then merges into full block.
336pub const SPINNER_BRAILLE_CRAWL: SpinnerFrames = SpinnerFrames {
337    frames: &["⡀", "⡄", "⡆", "⡇", "⡏", "⡟", "⡿", "⣿"],
338    interval: 0.08,
339};
340
341/// Pulse animation: full block fades through dithering to empty.
342pub const SPINNER_PULSE: SpinnerFrames = SpinnerFrames {
343    frames: &["█", "▓", "▒", "░"],
344    interval: 0.1,
345};
346
347/// Short Braille bounce: dot moves right then back left.
348pub const SPINNER_BOUNCE: SpinnerFrames = SpinnerFrames {
349    frames: &["⠁", "⠂", "⠄", "⠂"],
350    interval: 0.12,
351};
352
353/// Material Design-style circle halves (fast rotation).
354pub const SPINNER_MATERIAL: SpinnerFrames = SpinnerFrames {
355    frames: &["◐", "◓", "◑", "◒"],
356    interval: 0.08,
357};
358
359/// Classic Windows command-line style rotating line.
360pub const SPINNER_WINDOWS: SpinnerFrames = SpinnerFrames {
361    frames: &["/", "-", "\\", "|"],
362    interval: 0.1,
363};
364
365/// Shaded blocks: empty-to-full through dithering, then back.
366pub const SPINNER_SHADED_BLOCKS: SpinnerFrames = SpinnerFrames {
367    frames: &["░", "▒", "▓", "█", "▓", "▒"],
368    interval: 0.08,
369};
370
371/// Default spinner.
372pub const DEFAULT_SPINNER: SpinnerFrames = SPINNER_DOTS;
373
374// ===========================================================================
375// Name-based lookup
376// =========================================================================--
377
378/// All known spinners mapped by name (lowercase) for runtime lookup.
379pub const SPINNERS: &[(&str, &SpinnerFrames)] = &[
380    ("arc", &SPINNER_ARC),
381    ("arrow", &SPINNER_ARROW),
382    ("arrow2", &SPINNER_ARROW2),
383    ("arrow3", &SPINNER_ARROW3),
384    ("bouncingBar", &SPINNER_BOUNCING_BAR),
385    ("bouncingBall", &SPINNER_BOUNCING_BALL),
386    ("christmas", &SPINNER_CHRISTMAS),
387    ("circle", &SPINNER_CIRCLE),
388    ("clock", &SPINNER_CLOCK),
389    ("dots", &SPINNER_DOTS),
390    ("dots2", &SPINNER_DOTS2),
391    ("dots3", &SPINNER_DOTS3),
392    ("dots4", &SPINNER_DOTS4),
393    ("dots5", &SPINNER_DOTS5),
394    ("dots6", &SPINNER_DOTS6),
395    ("dots7", &SPINNER_DOTS7),
396    ("dots8", &SPINNER_DOTS8),
397    ("dots9", &SPINNER_DOTS9),
398    ("dots10", &SPINNER_DOTS10),
399    ("dots11", &SPINNER_DOTS11),
400    ("earth", &SPINNER_EARTH),
401    ("grenade", &SPINNER_GRENADE),
402    ("growHorizontal", &SPINNER_GROW_HORIZONTAL),
403    ("growVertical", &SPINNER_GROW_VERTICAL),
404    ("hamburger", &SPINNER_HAMBURGER),
405    ("hearts", &SPINNER_HEARTS),
406    ("line", &SPINNER_LINE),
407    ("monkey", &SPINNER_MONKEY),
408    ("moon", &SPINNER_MOON),
409    ("noise", &SPINNER_NOISE),
410    ("pong", &SPINNER_PONG),
411    ("runner", &SPINNER_RUNNER),
412    ("shark", &SPINNER_SHARK),
413    ("simpleDots", &SPINNER_SIMPLE_DOTS),
414    ("smiley", &SPINNER_SMILEY),
415    ("toggle", &SPINNER_TOGGLE),
416    ("triangle", &SPINNER_TRIANGLE),
417    ("verticalBars", &SPINNER_VERTICAL_BARS),
418    ("dots12", &SPINNER_DOTS12),
419    ("dots13", &SPINNER_DOTS13),
420    ("dots8Bit", &SPINNER_DOTS8_BIT),
421    ("simpleDotsScrolling", &SPINNER_SIMPLE_DOTS_SCROLLING),
422    ("star", &SPINNER_STAR),
423    ("star2", &SPINNER_STAR2),
424    ("flip", &SPINNER_FLIP),
425    ("balloon", &SPINNER_BALLOON),
426    ("balloon2", &SPINNER_BALLOON2),
427    ("pipe", &SPINNER_PIPE),
428    ("squareCorners", &SPINNER_SQUARE_CORNERS),
429    ("circleQuarters", &SPINNER_CIRCLE_QUARTERS),
430    ("circleHalves", &SPINNER_CIRCLE_HALVES),
431    ("aesthetic", &SPINNER_AESTHETIC),
432    ("brailleLong", &SPINNER_BRAILLE_LONG),
433    ("brailleCrawl", &SPINNER_BRAILLE_CRAWL),
434    ("pulse", &SPINNER_PULSE),
435    ("bounce", &SPINNER_BOUNCE),
436    ("material", &SPINNER_MATERIAL),
437    ("windows", &SPINNER_WINDOWS),
438    ("shadedBlocks", &SPINNER_SHADED_BLOCKS),
439];
440
441/// Get a spinner by name (case-insensitive).
442///
443/// Returns `None` if no spinner with the given name exists.
444///
445/// # Example
446///
447/// ```rust
448/// use rusty_rich::get_spinner;
449///
450/// let s = get_spinner("arc").unwrap();
451/// assert_eq!(s.frames.len(), 6);
452/// ```
453pub fn get_spinner(name: &str) -> Option<&'static SpinnerFrames> {
454    // First try direct lowercase match
455    for (key, spinner) in SPINNERS {
456        if key.eq_ignore_ascii_case(name) {
457            return Some(spinner);
458        }
459    }
460    // Fallback: try stripping spaces and hyphens, matching lowercase
461    let normalized: String = name.chars().filter(|c| !c.is_whitespace()).collect();
462    let normalized = normalized.to_lowercase();
463    for (key, spinner) in SPINNERS {
464        let key_normalized: String = key.chars().filter(|c| !c.is_whitespace()).collect();
465        let key_normalized = key_normalized.to_lowercase();
466        if key_normalized == normalized {
467            return Some(spinner);
468        }
469    }
470    None
471}
472
473// ---------------------------------------------------------------------------
474// Spinner
475// ---------------------------------------------------------------------------
476
477/// An animated spinner renderable.
478#[derive(Debug, Clone)]
479pub struct Spinner {
480    pub frames: &'static [&'static str],
481    pub interval: f64,
482    /// Text displayed alongside the spinner.
483    pub text: String,
484    /// Style for the spinner.
485    pub style: crate::style::Style,
486}
487
488impl Spinner {
489    /// Create a new spinner.
490    pub fn new(spinner: &'static SpinnerFrames) -> Self {
491        Self {
492            frames: spinner.frames,
493            interval: spinner.interval,
494            text: String::new(),
495            style: crate::style::Style::new(),
496        }
497    }
498
499    /// Builder: set the text.
500    pub fn text(mut self, text: impl Into<String>) -> Self {
501        self.text = text.into();
502        self
503    }
504
505    /// Builder: set the style.
506    pub fn style(mut self, style: crate::style::Style) -> Self {
507        self.style = style;
508        self
509    }
510
511    /// Get the frame at the given elapsed time.
512    pub fn frame_at(&self, elapsed: Duration) -> &'static str {
513        let idx = (elapsed.as_secs_f64() / self.interval) as usize % self.frames.len();
514        self.frames[idx]
515    }
516
517    /// Get the display string for the current time.
518    pub fn render(&self, elapsed: Duration) -> String {
519        let frame = self.frame_at(elapsed);
520        let style_ansi = self.style.to_ansi();
521        let reset = if style_ansi.is_empty() { "" } else { "\x1b[0m" };
522        if self.text.is_empty() {
523            format!("{style_ansi}{frame}{reset}")
524        } else {
525            format!("{style_ansi}{frame}{reset} {}", self.text)
526        }
527    }
528}
529
530impl Default for Spinner {
531    fn default() -> Self {
532        Self::new(&DEFAULT_SPINNER)
533    }
534}
535
536// ---------------------------------------------------------------------------
537// Tests
538// ---------------------------------------------------------------------------
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    #[test]
545    fn test_spinner_frame_at() {
546        let s = Spinner::new(&SPINNER_LINE);
547        let f = s.frame_at(Duration::from_millis(200));
548        assert!(f == "-" || f == "\\" || f == "|" || f == "/");
549    }
550
551    #[test]
552    fn test_get_spinner_found() {
553        let s = get_spinner("dots").unwrap();
554        assert!(!s.frames.is_empty());
555
556        let s = get_spinner("DOTS").unwrap();
557        assert!(!s.frames.is_empty());
558
559        let s = get_spinner("arc").unwrap();
560        assert_eq!(s.frames.len(), 6);
561    }
562
563    #[test]
564    fn test_get_spinner_not_found() {
565        assert!(get_spinner("nonexistent").is_none());
566    }
567
568    #[test]
569    fn test_get_spinner_case_insensitive() {
570        let s1 = get_spinner("ARC").unwrap();
571        let s2 = get_spinner("arc").unwrap();
572        assert_eq!(s1.frames, s2.frames);
573    }
574
575    #[test]
576    fn test_get_spinner_camel_case() {
577        let s = get_spinner("bouncingBar").unwrap();
578        assert!(!s.frames.is_empty());
579
580        let s = get_spinner("BOUNCINGBAR").unwrap();
581        assert!(!s.frames.is_empty());
582    }
583
584    #[test]
585    fn test_spinners_list_all_accessible() {
586        for (name, frames) in SPINNERS {
587            let found = get_spinner(name).unwrap();
588            assert!(!frames.frames.is_empty(), "spinner '{}' has no frames", name);
589            // Compare frame content rather than raw pointers, since `const`
590            // values may be inlined at different addresses by the compiler.
591            assert_eq!(
592                frames.frames, found.frames,
593                "spinner '{}' points to different frames than expected",
594                name
595            );
596            assert!(
597                (frames.interval - found.interval).abs() < f64::EPSILON,
598                "spinner '{}' interval mismatch",
599                name
600            );
601        }
602    }
603
604    #[test]
605    fn test_spinner_arc_frames() {
606        assert_eq!(SPINNER_ARC.frames.len(), 6);
607        assert!(SPINNER_ARC.interval > 0.0);
608    }
609
610    #[test]
611    fn test_spinner_arrow_frames() {
612        assert_eq!(SPINNER_ARROW.frames.len(), 8);
613    }
614
615    #[test]
616    fn test_spinner_arrow2_frames() {
617        assert_eq!(SPINNER_ARROW2.frames.len(), 8);
618    }
619
620    #[test]
621    fn test_spinner_arrow3_frames() {
622        assert_eq!(SPINNER_ARROW3.frames.len(), 6);
623    }
624
625    #[test]
626    fn test_spinner_bouncing_bar() {
627        assert_eq!(SPINNER_BOUNCING_BAR.frames.len(), 8);
628    }
629
630    #[test]
631    fn test_spinner_bouncing_ball() {
632        assert_eq!(SPINNER_BOUNCING_BALL.frames.len(), 8);
633    }
634
635    #[test]
636    fn test_spinner_christmas() {
637        assert_eq!(SPINNER_CHRISTMAS.frames.len(), 2);
638    }
639
640    #[test]
641    fn test_spinner_circle() {
642        assert_eq!(SPINNER_CIRCLE.frames.len(), 4);
643    }
644
645    #[test]
646    fn test_spinner_clock() {
647        assert_eq!(SPINNER_CLOCK.frames.len(), 12);
648    }
649
650    #[test]
651    fn test_spinner_earth() {
652        assert_eq!(SPINNER_EARTH.frames.len(), 3);
653    }
654
655    #[test]
656    fn test_spinner_grenade() {
657        assert_eq!(SPINNER_GRENADE.frames.len(), 3);
658    }
659
660    #[test]
661    fn test_spinner_grow_horizontal() {
662        assert_eq!(SPINNER_GROW_HORIZONTAL.frames.len(), 14);
663    }
664
665    #[test]
666    fn test_spinner_grow_vertical() {
667        assert_eq!(SPINNER_GROW_VERTICAL.frames.len(), 14);
668    }
669
670    #[test]
671    fn test_spinner_hamburger() {
672        assert_eq!(SPINNER_HAMBURGER.frames.len(), 3);
673    }
674
675    #[test]
676    fn test_spinner_hearts() {
677        assert_eq!(SPINNER_HEARTS.frames.len(), 12);
678    }
679
680    #[test]
681    fn test_spinner_monkey() {
682        assert_eq!(SPINNER_MONKEY.frames.len(), 11);
683    }
684
685    #[test]
686    fn test_spinner_noise() {
687        assert_eq!(SPINNER_NOISE.frames.len(), 9);
688    }
689
690    #[test]
691    fn test_spinner_pong() {
692        assert_eq!(SPINNER_PONG.frames.len(), 30);
693    }
694
695    #[test]
696    fn test_spinner_runner() {
697        assert_eq!(SPINNER_RUNNER.frames.len(), 6);
698    }
699
700    #[test]
701    fn test_spinner_shark() {
702        assert_eq!(SPINNER_SHARK.frames.len(), 6);
703    }
704
705    #[test]
706    fn test_spinner_toggle() {
707        assert_eq!(SPINNER_TOGGLE.frames.len(), 2);
708    }
709
710    #[test]
711    fn test_spinner_triangle() {
712        assert_eq!(SPINNER_TRIANGLE.frames.len(), 4);
713    }
714
715    #[test]
716    fn test_spinner_vertical_bars() {
717        assert_eq!(SPINNER_VERTICAL_BARS.frames.len(), 15);
718    }
719
720    #[test]
721    fn test_spinner_interval_positive() {
722        for (name, frames) in SPINNERS {
723            assert!(
724                frames.interval > 0.0,
725                "spinner '{}' has non-positive interval",
726                name
727            );
728        }
729    }
730
731    #[test]
732    fn test_default_spinner_is_dots() {
733        assert_eq!(DEFAULT_SPINNER.frames, SPINNER_DOTS.frames);
734    }
735
736    #[test]
737    fn test_spinner_dots12() { assert!(!SPINNER_DOTS12.frames.is_empty()); assert!(SPINNER_DOTS12.interval > 0.0); }
738    #[test]
739    fn test_spinner_dots13() { assert!(!SPINNER_DOTS13.frames.is_empty()); assert!(SPINNER_DOTS13.interval > 0.0); }
740    #[test]
741    fn test_spinner_star() { assert!(!SPINNER_STAR.frames.is_empty()); assert!(SPINNER_STAR.interval > 0.0); }
742    #[test]
743    fn test_spinner_flip() { assert!(!SPINNER_FLIP.frames.is_empty()); assert!(SPINNER_FLIP.interval > 0.0); }
744    #[test]
745    fn test_spinner_balloon() { assert!(!SPINNER_BALLOON.frames.is_empty()); assert!(SPINNER_BALLOON.interval > 0.0); }
746    #[test]
747    fn test_spinner_pipe() { assert!(!SPINNER_PIPE.frames.is_empty()); assert!(SPINNER_PIPE.interval > 0.0); }
748    #[test]
749    fn test_spinner_pulse() { assert!(!SPINNER_PULSE.frames.is_empty()); assert!(SPINNER_PULSE.interval > 0.0); }
750    #[test]
751    fn test_spinner_windows() { assert!(!SPINNER_WINDOWS.frames.is_empty()); assert!(SPINNER_WINDOWS.interval > 0.0); }
752    #[test]
753    fn test_spinner_shaded_blocks() { assert!(!SPINNER_SHADED_BLOCKS.frames.is_empty()); assert!(SPINNER_SHADED_BLOCKS.interval > 0.0); }
754}