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        "⠄", "⠆", "⠇", "⠋", "⠙", "⠸", "⠰", "⠠", "⠰", "⠸", "⠙", "⠋", "⠇", "⠆",
43    ],
44    interval: 0.08,
45};
46
47pub const SPINNER_DOTS5: SpinnerFrames = SpinnerFrames {
48    frames: &[
49        "⠋", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋",
50    ],
51    interval: 0.08,
52};
53
54pub const SPINNER_DOTS6: SpinnerFrames = SpinnerFrames {
55    frames: &[
56        "⠁", "⠉", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠤", "⠄", "⠄", "⠤", "⠴", "⠲", "⠒", "⠂",
57        "⠂", "⠒", "⠚", "⠙", "⠉", "⠁",
58    ],
59    interval: 0.08,
60};
61
62pub const SPINNER_DOTS7: SpinnerFrames = SpinnerFrames {
63    frames: &[
64        "⠈", "⠉", "⠋", "⠓", "⠒", "⠐", "⠐", "⠒", "⠖", "⠦", "⠤", "⠠", "⠠", "⠤", "⠦", "⠖", "⠒", "⠐",
65        "⠐", "⠒", "⠓", "⠋", "⠉", "⠈",
66    ],
67    interval: 0.08,
68};
69
70pub const SPINNER_DOTS8: SpinnerFrames = SpinnerFrames {
71    frames: &[
72        "⠁", "⠁", "⠉", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠤", "⠄", "⠄", "⠤", "⠠", "⠠", "⠤",
73        "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋", "⠉", "⠈", "⠈",
74    ],
75    interval: 0.08,
76};
77
78pub const SPINNER_DOTS9: SpinnerFrames = SpinnerFrames {
79    frames: &["⢹", "⢺", "⢼", "⣸", "⣇", "⡧", "⡗", "⡏"],
80    interval: 0.08,
81};
82
83pub const SPINNER_DOTS10: SpinnerFrames = SpinnerFrames {
84    frames: &["⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"],
85    interval: 0.08,
86};
87
88pub const SPINNER_DOTS11: SpinnerFrames = SpinnerFrames {
89    frames: &["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"],
90    interval: 0.1,
91};
92
93pub const SPINNER_SIMPLE_DOTS: SpinnerFrames = SpinnerFrames {
94    frames: &[".  ", ".. ", "...", " ..", "  .", "   "],
95    interval: 0.2,
96};
97
98// ===========================================================================
99// Icon / theme spinners
100// ===========================================================================
101
102pub const SPINNER_MOON: SpinnerFrames = SpinnerFrames {
103    frames: &["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"],
104    interval: 0.08,
105};
106
107pub const SPINNER_SMILEY: SpinnerFrames = SpinnerFrames {
108    frames: &["😄", "😝"],
109    interval: 0.2,
110};
111
112// ===========================================================================
113// New spinner styles (20+ from Python Rich)
114// ===========================================================================
115
116pub const SPINNER_ARC: SpinnerFrames = SpinnerFrames {
117    frames: &["◜", "◠", "◝", "◞", "◡", "◟"],
118    interval: 0.1,
119};
120
121pub const SPINNER_ARROW: SpinnerFrames = SpinnerFrames {
122    frames: &["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"],
123    interval: 0.1,
124};
125
126pub const SPINNER_ARROW2: SpinnerFrames = SpinnerFrames {
127    frames: &["⬆️", "↗️", "➡️", "↘️", "⬇️", "↙️", "⬅️", "↖️"],
128    interval: 0.1,
129};
130
131pub const SPINNER_ARROW3: SpinnerFrames = SpinnerFrames {
132    frames: &["▹", "▸", "▹", "▸", "▹", "▸"],
133    interval: 0.1,
134};
135
136pub const SPINNER_BOUNCING_BAR: SpinnerFrames = SpinnerFrames {
137    frames: &[
138        "[    ]", "[=   ]", "[==  ]", "[=== ]", "[ ===]", "[  ==]", "[   =]", "[    ]",
139    ],
140    interval: 0.15,
141};
142
143pub const SPINNER_BOUNCING_BALL: SpinnerFrames = SpinnerFrames {
144    frames: &[
145        "( ●    )",
146        "(  ●   )",
147        "(   ●  )",
148        "(    ● )",
149        "(     ●)",
150        "(    ● )",
151        "(   ●  )",
152        "(  ●   )",
153    ],
154    interval: 0.15,
155};
156
157pub const SPINNER_CHRISTMAS: SpinnerFrames = SpinnerFrames {
158    frames: &["🌲", "🎄"],
159    interval: 0.4,
160};
161
162pub const SPINNER_CIRCLE: SpinnerFrames = SpinnerFrames {
163    frames: &["◐", "◓", "◑", "◒"],
164    interval: 0.1,
165};
166
167pub const SPINNER_CLOCK: SpinnerFrames = SpinnerFrames {
168    frames: &[
169        "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", "🕛",
170    ],
171    interval: 0.1,
172};
173
174pub const SPINNER_EARTH: SpinnerFrames = SpinnerFrames {
175    frames: &["🌍", "🌎", "🌏"],
176    interval: 0.2,
177};
178
179pub const SPINNER_GRENADE: SpinnerFrames = SpinnerFrames {
180    frames: &["،  💣  ", "۔  💣  ", " ﹒ 💣  "],
181    interval: 0.1,
182};
183
184pub const SPINNER_GROW_HORIZONTAL: SpinnerFrames = SpinnerFrames {
185    frames: &[
186        "▏", "▎", "▍", "▌", "▋", "▊", "▉", "▉", "▊", "▋", "▌", "▍", "▎", "▏",
187    ],
188    interval: 0.08,
189};
190
191pub const SPINNER_GROW_VERTICAL: SpinnerFrames = SpinnerFrames {
192    frames: &[
193        "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▁",
194    ],
195    interval: 0.08,
196};
197
198pub const SPINNER_HAMBURGER: SpinnerFrames = SpinnerFrames {
199    frames: &["☱", "☲", "☴"],
200    interval: 0.12,
201};
202
203pub const SPINNER_HEARTS: SpinnerFrames = SpinnerFrames {
204    frames: &[
205        "🩷", "❤️", "🧡", "💛", "💚", "💙", "🩵", "💜", "🤎", "🖤", "🩶", "🤍",
206    ],
207    interval: 0.12,
208};
209
210pub const SPINNER_MONKEY: SpinnerFrames = SpinnerFrames {
211    frames: &[
212        "🐒", "🐒", "🐒", "🐒", "🙈", "🙉", "🙊", "🐒", "🐒", "🐒", "🐒",
213    ],
214    interval: 0.15,
215};
216
217pub const SPINNER_NOISE: SpinnerFrames = SpinnerFrames {
218    frames: &["▓", "▒", "░", "▓", "▒", "░", "▓", "▒", "░"],
219    interval: 0.08,
220};
221
222pub const SPINNER_PONG: SpinnerFrames = SpinnerFrames {
223    frames: &[
224        "▐⠂       ▌",
225        "▐⠈       ▌",
226        "▐ ⠂      ▌",
227        "▐ ⠠      ▌",
228        "▐  ⡀     ▌",
229        "▐  ⠠     ▌",
230        "▐   ⠂    ▌",
231        "▐   ⠈    ▌",
232        "▐    ⠂   ▌",
233        "▐    ⠠   ▌",
234        "▐     ⡀  ▌",
235        "▐     ⠠  ▌",
236        "▐      ⠂ ▌",
237        "▐      ⠈ ▌",
238        "▐       ⠂▌",
239        "▐       ⠠▌",
240        "▐       ⡀▌",
241        "▐      ⠠ ▌",
242        "▐      ⠂ ▌",
243        "▐     ⠈  ▌",
244        "▐     ⠂  ▌",
245        "▐    ⠠   ▌",
246        "▐    ⡀   ▌",
247        "▐   ⠠    ▌",
248        "▐   ⠂    ▌",
249        "▐  ⠈     ▌",
250        "▐  ⠂     ▌",
251        "▐ ⠠      ▌",
252        "▐ ⠂      ▌",
253        "▐⠈       ▌",
254    ],
255    interval: 0.08,
256};
257
258pub const SPINNER_RUNNER: SpinnerFrames = SpinnerFrames {
259    frames: &["🚶", "🏃", "🏃", "🏃", "🚶", "🚶"],
260    interval: 0.15,
261};
262
263pub const SPINNER_SHARK: SpinnerFrames = SpinnerFrames {
264    frames: &["🦈", "🌀", "🦈", "🌀", "🦈", "🌀"],
265    interval: 0.15,
266};
267
268pub const SPINNER_TOGGLE: SpinnerFrames = SpinnerFrames {
269    frames: &["⊶", "⊷"],
270    interval: 0.2,
271};
272
273pub const SPINNER_TRIANGLE: SpinnerFrames = SpinnerFrames {
274    frames: &["◢", "◣", "◤", "◥"],
275    interval: 0.1,
276};
277
278pub const SPINNER_VERTICAL_BARS: SpinnerFrames = SpinnerFrames {
279    frames: &[
280        "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂", "▁",
281    ],
282    interval: 0.08,
283};
284
285// ===========================================================================
286// Additional spinners from Python Rich (bringing total to 55+)
287// ===========================================================================
288
289/// Braille dots spinner: vertical bar grows upward then collapses.
290pub const SPINNER_DOTS12: SpinnerFrames = SpinnerFrames {
291    frames: &["⣀", "⣤", "⣶", "⣿", "⣶", "⣤"],
292    interval: 0.08,
293};
294
295/// Braille dots spinner: complex diagonal crawl pattern.
296pub const SPINNER_DOTS13: SpinnerFrames = SpinnerFrames {
297    frames: &["⣼", "⣹", "⢻", "⠿", "⡟", "⣏", "⣧", "⣶"],
298    interval: 0.08,
299};
300
301/// Braille dots 8-bit style: fills from empty to full block.
302pub const SPINNER_DOTS8_BIT: SpinnerFrames = SpinnerFrames {
303    frames: &["⠀", "⠁", "⠂", "⠃", "⠇", "⠏", "⠟", "⠿", "⡿", "⣿", "⣿", "⣿"],
304    interval: 0.08,
305};
306
307/// Simple ASCII dots in a back-and-forth scrolling pattern.
308pub const SPINNER_SIMPLE_DOTS_SCROLLING: SpinnerFrames = SpinnerFrames {
309    frames: &[".  ", ".. ", "...", " ..", "  .", " ..", "...", ".. "],
310    interval: 0.2,
311};
312
313/// Star-spangled spinner using star-shaped Unicode characters.
314pub const SPINNER_STAR: SpinnerFrames = SpinnerFrames {
315    frames: &["✶", "✸", "✹", "✺", "✹", "✷"],
316    interval: 0.1,
317};
318
319/// Minimal star spinner: plus, cross, asterisk.
320pub const SPINNER_STAR2: SpinnerFrames = SpinnerFrames {
321    frames: &["+", "x", "*"],
322    interval: 0.12,
323};
324
325/// Underscore / tick / overbar flipping animation.
326pub const SPINNER_FLIP: SpinnerFrames = SpinnerFrames {
327    frames: &["_", "_", "_", "-", "`", "`", "'", "¯", "_", "_", "_", "-"],
328    interval: 0.1,
329};
330
331/// Balloon inflating: dot, small o, O, at-sign, star, then empty.
332pub const SPINNER_BALLOON: SpinnerFrames = SpinnerFrames {
333    frames: &[". ", "o ", "O ", "@ ", "* ", " "],
334    interval: 0.12,
335};
336
337/// Balloon inflate-deflate: dot, o, O, degree symbol, O, o, dot.
338pub const SPINNER_BALLOON2: SpinnerFrames = SpinnerFrames {
339    frames: &[".", "o", "O", "°", "O", "o", "."],
340    interval: 0.12,
341};
342
343/// Pipe-junction spinner: box-drawing corners cycle clockwise.
344pub const SPINNER_PIPE: SpinnerFrames = SpinnerFrames {
345    frames: &["┤", "┘", "┴", "└", "├", "┌", "┬", "┐"],
346    interval: 0.1,
347};
348
349/// Square corners cycling through four quadrant-filled states.
350pub const SPINNER_SQUARE_CORNERS: SpinnerFrames = SpinnerFrames {
351    frames: &["◰", "◳", "◲", "◱"],
352    interval: 0.12,
353};
354
355/// Circle quadrants rotating counter-clockwise.
356pub const SPINNER_CIRCLE_QUARTERS: SpinnerFrames = SpinnerFrames {
357    frames: &["◴", "◷", "◶", "◵"],
358    interval: 0.12,
359};
360
361/// Circle halves rotating through four semi-filled states.
362pub const SPINNER_CIRCLE_HALVES: SpinnerFrames = SpinnerFrames {
363    frames: &["◐", "◓", "◑", "◒"],
364    interval: 0.12,
365};
366
367/// Minimal two-frame aesthetic: filled and empty block.
368pub const SPINNER_AESTHETIC: SpinnerFrames = SpinnerFrames {
369    frames: &["▰", "▱"],
370    interval: 0.15,
371};
372
373/// Braille dot pattern: single dots travel from top-left to bottom-right.
374pub const SPINNER_BRAILLE_LONG: SpinnerFrames = SpinnerFrames {
375    frames: &["⠁", "⠂", "⠄", "⠠", "⠐", "⠈", "⡀", "⢀"],
376    interval: 0.08,
377};
378
379/// Braille dot crawl: left column fills then merges into full block.
380pub const SPINNER_BRAILLE_CRAWL: SpinnerFrames = SpinnerFrames {
381    frames: &["⡀", "⡄", "⡆", "⡇", "⡏", "⡟", "⡿", "⣿"],
382    interval: 0.08,
383};
384
385/// Pulse animation: full block fades through dithering to empty.
386pub const SPINNER_PULSE: SpinnerFrames = SpinnerFrames {
387    frames: &["█", "▓", "▒", "░"],
388    interval: 0.1,
389};
390
391/// Short Braille bounce: dot moves right then back left.
392pub const SPINNER_BOUNCE: SpinnerFrames = SpinnerFrames {
393    frames: &["⠁", "⠂", "⠄", "⠂"],
394    interval: 0.12,
395};
396
397/// Material Design-style circle halves (fast rotation).
398pub const SPINNER_MATERIAL: SpinnerFrames = SpinnerFrames {
399    frames: &["◐", "◓", "◑", "◒"],
400    interval: 0.08,
401};
402
403/// Classic Windows command-line style rotating line.
404pub const SPINNER_WINDOWS: SpinnerFrames = SpinnerFrames {
405    frames: &["/", "-", "\\", "|"],
406    interval: 0.1,
407};
408
409/// Shaded blocks: empty-to-full through dithering, then back.
410pub const SPINNER_SHADED_BLOCKS: SpinnerFrames = SpinnerFrames {
411    frames: &["░", "▒", "▓", "█", "▓", "▒"],
412    interval: 0.08,
413};
414
415/// Default spinner.
416pub const DEFAULT_SPINNER: SpinnerFrames = SPINNER_DOTS;
417
418// ===========================================================================
419// Name-based lookup
420// =========================================================================--
421
422/// All known spinners mapped by name (lowercase) for runtime lookup.
423pub const SPINNERS: &[(&str, &SpinnerFrames)] = &[
424    ("arc", &SPINNER_ARC),
425    ("arrow", &SPINNER_ARROW),
426    ("arrow2", &SPINNER_ARROW2),
427    ("arrow3", &SPINNER_ARROW3),
428    ("bouncingBar", &SPINNER_BOUNCING_BAR),
429    ("bouncingBall", &SPINNER_BOUNCING_BALL),
430    ("christmas", &SPINNER_CHRISTMAS),
431    ("circle", &SPINNER_CIRCLE),
432    ("clock", &SPINNER_CLOCK),
433    ("dots", &SPINNER_DOTS),
434    ("dots2", &SPINNER_DOTS2),
435    ("dots3", &SPINNER_DOTS3),
436    ("dots4", &SPINNER_DOTS4),
437    ("dots5", &SPINNER_DOTS5),
438    ("dots6", &SPINNER_DOTS6),
439    ("dots7", &SPINNER_DOTS7),
440    ("dots8", &SPINNER_DOTS8),
441    ("dots9", &SPINNER_DOTS9),
442    ("dots10", &SPINNER_DOTS10),
443    ("dots11", &SPINNER_DOTS11),
444    ("earth", &SPINNER_EARTH),
445    ("grenade", &SPINNER_GRENADE),
446    ("growHorizontal", &SPINNER_GROW_HORIZONTAL),
447    ("growVertical", &SPINNER_GROW_VERTICAL),
448    ("hamburger", &SPINNER_HAMBURGER),
449    ("hearts", &SPINNER_HEARTS),
450    ("line", &SPINNER_LINE),
451    ("monkey", &SPINNER_MONKEY),
452    ("moon", &SPINNER_MOON),
453    ("noise", &SPINNER_NOISE),
454    ("pong", &SPINNER_PONG),
455    ("runner", &SPINNER_RUNNER),
456    ("shark", &SPINNER_SHARK),
457    ("simpleDots", &SPINNER_SIMPLE_DOTS),
458    ("smiley", &SPINNER_SMILEY),
459    ("toggle", &SPINNER_TOGGLE),
460    ("triangle", &SPINNER_TRIANGLE),
461    ("verticalBars", &SPINNER_VERTICAL_BARS),
462    ("dots12", &SPINNER_DOTS12),
463    ("dots13", &SPINNER_DOTS13),
464    ("dots8Bit", &SPINNER_DOTS8_BIT),
465    ("simpleDotsScrolling", &SPINNER_SIMPLE_DOTS_SCROLLING),
466    ("star", &SPINNER_STAR),
467    ("star2", &SPINNER_STAR2),
468    ("flip", &SPINNER_FLIP),
469    ("balloon", &SPINNER_BALLOON),
470    ("balloon2", &SPINNER_BALLOON2),
471    ("pipe", &SPINNER_PIPE),
472    ("squareCorners", &SPINNER_SQUARE_CORNERS),
473    ("circleQuarters", &SPINNER_CIRCLE_QUARTERS),
474    ("circleHalves", &SPINNER_CIRCLE_HALVES),
475    ("aesthetic", &SPINNER_AESTHETIC),
476    ("brailleLong", &SPINNER_BRAILLE_LONG),
477    ("brailleCrawl", &SPINNER_BRAILLE_CRAWL),
478    ("pulse", &SPINNER_PULSE),
479    ("bounce", &SPINNER_BOUNCE),
480    ("material", &SPINNER_MATERIAL),
481    ("windows", &SPINNER_WINDOWS),
482    ("shadedBlocks", &SPINNER_SHADED_BLOCKS),
483];
484
485/// Get a spinner by name (case-insensitive).
486///
487/// Returns `None` if no spinner with the given name exists.
488///
489/// # Example
490///
491/// ```rust
492/// use rusty_rich::get_spinner;
493///
494/// let s = get_spinner("arc").unwrap();
495/// assert_eq!(s.frames.len(), 6);
496/// ```
497pub fn get_spinner(name: &str) -> Option<&'static SpinnerFrames> {
498    // First try direct lowercase match
499    for (key, spinner) in SPINNERS {
500        if key.eq_ignore_ascii_case(name) {
501            return Some(spinner);
502        }
503    }
504    // Fallback: try stripping spaces and hyphens, matching lowercase
505    let normalized: String = name.chars().filter(|c| !c.is_whitespace()).collect();
506    let normalized = normalized.to_lowercase();
507    for (key, spinner) in SPINNERS {
508        let key_normalized: String = key.chars().filter(|c| !c.is_whitespace()).collect();
509        let key_normalized = key_normalized.to_lowercase();
510        if key_normalized == normalized {
511            return Some(spinner);
512        }
513    }
514    None
515}
516
517// ---------------------------------------------------------------------------
518// Spinner
519// ---------------------------------------------------------------------------
520
521/// An animated spinner renderable.
522#[derive(Debug, Clone)]
523pub struct Spinner {
524    pub frames: &'static [&'static str],
525    pub interval: f64,
526    /// Text displayed alongside the spinner.
527    pub text: String,
528    /// Style for the spinner.
529    pub style: crate::style::Style,
530}
531
532impl Spinner {
533    /// Create a new spinner.
534    pub fn new(spinner: &'static SpinnerFrames) -> Self {
535        Self {
536            frames: spinner.frames,
537            interval: spinner.interval,
538            text: String::new(),
539            style: crate::style::Style::new(),
540        }
541    }
542
543    /// Builder: set the text.
544    pub fn text(mut self, text: impl Into<String>) -> Self {
545        self.text = text.into();
546        self
547    }
548
549    /// Builder: set the style.
550    pub fn style(mut self, style: crate::style::Style) -> Self {
551        self.style = style;
552        self
553    }
554
555    /// Get the frame at the given elapsed time.
556    pub fn frame_at(&self, elapsed: Duration) -> &'static str {
557        let idx = (elapsed.as_secs_f64() / self.interval) as usize % self.frames.len();
558        self.frames[idx]
559    }
560
561    /// Get the display string for the current time.
562    pub fn render(&self, elapsed: Duration) -> String {
563        let frame = self.frame_at(elapsed);
564        let style_ansi = self.style.to_ansi();
565        let reset = if style_ansi.is_empty() { "" } else { "\x1b[0m" };
566        if self.text.is_empty() {
567            format!("{style_ansi}{frame}{reset}")
568        } else {
569            format!("{style_ansi}{frame}{reset} {}", self.text)
570        }
571    }
572}
573
574impl Default for Spinner {
575    fn default() -> Self {
576        Self::new(&DEFAULT_SPINNER)
577    }
578}
579
580// ---------------------------------------------------------------------------
581// Tests
582// ---------------------------------------------------------------------------
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    #[test]
589    fn test_spinner_frame_at() {
590        let s = Spinner::new(&SPINNER_LINE);
591        let f = s.frame_at(Duration::from_millis(200));
592        assert!(f == "-" || f == "\\" || f == "|" || f == "/");
593    }
594
595    #[test]
596    fn test_get_spinner_found() {
597        let s = get_spinner("dots").unwrap();
598        assert!(!s.frames.is_empty());
599
600        let s = get_spinner("DOTS").unwrap();
601        assert!(!s.frames.is_empty());
602
603        let s = get_spinner("arc").unwrap();
604        assert_eq!(s.frames.len(), 6);
605    }
606
607    #[test]
608    fn test_get_spinner_not_found() {
609        assert!(get_spinner("nonexistent").is_none());
610    }
611
612    #[test]
613    fn test_get_spinner_case_insensitive() {
614        let s1 = get_spinner("ARC").unwrap();
615        let s2 = get_spinner("arc").unwrap();
616        assert_eq!(s1.frames, s2.frames);
617    }
618
619    #[test]
620    fn test_get_spinner_camel_case() {
621        let s = get_spinner("bouncingBar").unwrap();
622        assert!(!s.frames.is_empty());
623
624        let s = get_spinner("BOUNCINGBAR").unwrap();
625        assert!(!s.frames.is_empty());
626    }
627
628    #[test]
629    fn test_spinners_list_all_accessible() {
630        for (name, frames) in SPINNERS {
631            let found = get_spinner(name).unwrap();
632            assert!(
633                !frames.frames.is_empty(),
634                "spinner '{}' has no frames",
635                name
636            );
637            // Compare frame content rather than raw pointers, since `const`
638            // values may be inlined at different addresses by the compiler.
639            assert_eq!(
640                frames.frames, found.frames,
641                "spinner '{}' points to different frames than expected",
642                name
643            );
644            assert!(
645                (frames.interval - found.interval).abs() < f64::EPSILON,
646                "spinner '{}' interval mismatch",
647                name
648            );
649        }
650    }
651
652    #[test]
653    fn test_spinner_arc_frames() {
654        assert_eq!(SPINNER_ARC.frames.len(), 6);
655        assert!(SPINNER_ARC.interval > 0.0);
656    }
657
658    #[test]
659    fn test_spinner_arrow_frames() {
660        assert_eq!(SPINNER_ARROW.frames.len(), 8);
661    }
662
663    #[test]
664    fn test_spinner_arrow2_frames() {
665        assert_eq!(SPINNER_ARROW2.frames.len(), 8);
666    }
667
668    #[test]
669    fn test_spinner_arrow3_frames() {
670        assert_eq!(SPINNER_ARROW3.frames.len(), 6);
671    }
672
673    #[test]
674    fn test_spinner_bouncing_bar() {
675        assert_eq!(SPINNER_BOUNCING_BAR.frames.len(), 8);
676    }
677
678    #[test]
679    fn test_spinner_bouncing_ball() {
680        assert_eq!(SPINNER_BOUNCING_BALL.frames.len(), 8);
681    }
682
683    #[test]
684    fn test_spinner_christmas() {
685        assert_eq!(SPINNER_CHRISTMAS.frames.len(), 2);
686    }
687
688    #[test]
689    fn test_spinner_circle() {
690        assert_eq!(SPINNER_CIRCLE.frames.len(), 4);
691    }
692
693    #[test]
694    fn test_spinner_clock() {
695        assert_eq!(SPINNER_CLOCK.frames.len(), 12);
696    }
697
698    #[test]
699    fn test_spinner_earth() {
700        assert_eq!(SPINNER_EARTH.frames.len(), 3);
701    }
702
703    #[test]
704    fn test_spinner_grenade() {
705        assert_eq!(SPINNER_GRENADE.frames.len(), 3);
706    }
707
708    #[test]
709    fn test_spinner_grow_horizontal() {
710        assert_eq!(SPINNER_GROW_HORIZONTAL.frames.len(), 14);
711    }
712
713    #[test]
714    fn test_spinner_grow_vertical() {
715        assert_eq!(SPINNER_GROW_VERTICAL.frames.len(), 14);
716    }
717
718    #[test]
719    fn test_spinner_hamburger() {
720        assert_eq!(SPINNER_HAMBURGER.frames.len(), 3);
721    }
722
723    #[test]
724    fn test_spinner_hearts() {
725        assert_eq!(SPINNER_HEARTS.frames.len(), 12);
726    }
727
728    #[test]
729    fn test_spinner_monkey() {
730        assert_eq!(SPINNER_MONKEY.frames.len(), 11);
731    }
732
733    #[test]
734    fn test_spinner_noise() {
735        assert_eq!(SPINNER_NOISE.frames.len(), 9);
736    }
737
738    #[test]
739    fn test_spinner_pong() {
740        assert_eq!(SPINNER_PONG.frames.len(), 30);
741    }
742
743    #[test]
744    fn test_spinner_runner() {
745        assert_eq!(SPINNER_RUNNER.frames.len(), 6);
746    }
747
748    #[test]
749    fn test_spinner_shark() {
750        assert_eq!(SPINNER_SHARK.frames.len(), 6);
751    }
752
753    #[test]
754    fn test_spinner_toggle() {
755        assert_eq!(SPINNER_TOGGLE.frames.len(), 2);
756    }
757
758    #[test]
759    fn test_spinner_triangle() {
760        assert_eq!(SPINNER_TRIANGLE.frames.len(), 4);
761    }
762
763    #[test]
764    fn test_spinner_vertical_bars() {
765        assert_eq!(SPINNER_VERTICAL_BARS.frames.len(), 15);
766    }
767
768    #[test]
769    fn test_spinner_interval_positive() {
770        for (name, frames) in SPINNERS {
771            assert!(
772                frames.interval > 0.0,
773                "spinner '{}' has non-positive interval",
774                name
775            );
776        }
777    }
778
779    #[test]
780    fn test_default_spinner_is_dots() {
781        assert_eq!(DEFAULT_SPINNER.frames, SPINNER_DOTS.frames);
782    }
783
784    #[test]
785    fn test_spinner_dots12() {
786        assert!(!SPINNER_DOTS12.frames.is_empty());
787        assert!(SPINNER_DOTS12.interval > 0.0);
788    }
789    #[test]
790    fn test_spinner_dots13() {
791        assert!(!SPINNER_DOTS13.frames.is_empty());
792        assert!(SPINNER_DOTS13.interval > 0.0);
793    }
794    #[test]
795    fn test_spinner_star() {
796        assert!(!SPINNER_STAR.frames.is_empty());
797        assert!(SPINNER_STAR.interval > 0.0);
798    }
799    #[test]
800    fn test_spinner_flip() {
801        assert!(!SPINNER_FLIP.frames.is_empty());
802        assert!(SPINNER_FLIP.interval > 0.0);
803    }
804    #[test]
805    fn test_spinner_balloon() {
806        assert!(!SPINNER_BALLOON.frames.is_empty());
807        assert!(SPINNER_BALLOON.interval > 0.0);
808    }
809    #[test]
810    fn test_spinner_pipe() {
811        assert!(!SPINNER_PIPE.frames.is_empty());
812        assert!(SPINNER_PIPE.interval > 0.0);
813    }
814    #[test]
815    fn test_spinner_pulse() {
816        assert!(!SPINNER_PULSE.frames.is_empty());
817        assert!(SPINNER_PULSE.interval > 0.0);
818    }
819    #[test]
820    fn test_spinner_windows() {
821        assert!(!SPINNER_WINDOWS.frames.is_empty());
822        assert!(SPINNER_WINDOWS.interval > 0.0);
823    }
824    #[test]
825    fn test_spinner_shaded_blocks() {
826        assert!(!SPINNER_SHADED_BLOCKS.frames.is_empty());
827        assert!(SPINNER_SHADED_BLOCKS.interval > 0.0);
828    }
829}