Skip to main content

ratatui_interact/components/
spinner.rs

1//! Spinner widget for loading/processing indicators
2//!
3//! An animated spinner with multiple styles and optional label support.
4//!
5//! # Example
6//!
7//! ```rust
8//! use ratatui_interact::components::{Spinner, SpinnerState, SpinnerStyle, SpinnerFrames};
9//! use ratatui::layout::Rect;
10//! use ratatui::buffer::Buffer;
11//! use ratatui::widgets::Widget;
12//!
13//! // Create state and advance each frame
14//! let mut state = SpinnerState::new();
15//!
16//! // Simple spinner
17//! let spinner = Spinner::new(&state);
18//!
19//! // With label
20//! let spinner = Spinner::new(&state)
21//!     .label("Loading...");
22//!
23//! // Different spinner styles
24//! let spinner = Spinner::new(&state)
25//!     .frames(SpinnerFrames::Braille)
26//!     .label("Processing");
27//!
28//! // In your event loop, advance the animation
29//! state.tick();
30//! ```
31
32use std::time::{Duration, Instant};
33
34use ratatui::{
35    buffer::Buffer,
36    layout::Rect,
37    style::{Color, Modifier, Style},
38    widgets::Widget,
39};
40use unicode_width::UnicodeWidthStr;
41
42/// Predefined spinner frame sets
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum SpinnerFrames {
45    /// Classic dots: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏
46    #[default]
47    Dots,
48    /// Braille pattern: ⣾ ⣽ ⣻ ⢿ ⡿ ⣟ ⣯ ⣷
49    Braille,
50    /// Line spinner: | / - \
51    Line,
52    /// Circle: ◐ ◓ ◑ ◒
53    Circle,
54    /// Box: ▖ ▘ ▝ ▗
55    Box,
56    /// Arrow: ← ↖ ↑ ↗ → ↘ ↓ ↙
57    Arrow,
58    /// Bounce: ⠁ ⠂ ⠄ ⠂
59    Bounce,
60    /// Grow: ▁ ▃ ▄ ▅ ▆ ▇ █ ▇ ▆ ▅ ▄ ▃
61    Grow,
62    /// Clock: 🕐 🕑 🕒 🕓 🕔 🕕 🕖 🕗 🕘 🕙 🕚 🕛
63    Clock,
64    /// Moon: 🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘
65    Moon,
66    /// Simple ASCII: . o O @ *
67    Ascii,
68    /// Toggle: ⊶ ⊷
69    Toggle,
70}
71
72impl SpinnerFrames {
73    /// Get the frames for this spinner style
74    pub fn frames(&self) -> &'static [&'static str] {
75        match self {
76            SpinnerFrames::Dots => &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
77            SpinnerFrames::Braille => &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"],
78            SpinnerFrames::Line => &["|", "/", "-", "\\"],
79            SpinnerFrames::Circle => &["◐", "◓", "◑", "◒"],
80            SpinnerFrames::Box => &["▖", "▘", "▝", "▗"],
81            SpinnerFrames::Arrow => &["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"],
82            SpinnerFrames::Bounce => &["⠁", "⠂", "⠄", "⠂"],
83            SpinnerFrames::Grow => &["▁", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃"],
84            SpinnerFrames::Clock => &[
85                "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", "🕛",
86            ],
87            SpinnerFrames::Moon => &["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"],
88            SpinnerFrames::Ascii => &[".", "o", "O", "@", "*"],
89            SpinnerFrames::Toggle => &["⊶", "⊷"],
90        }
91    }
92
93    /// Get the recommended interval for this spinner style (in milliseconds)
94    pub fn interval_ms(&self) -> u64 {
95        match self {
96            SpinnerFrames::Dots => 80,
97            SpinnerFrames::Braille => 80,
98            SpinnerFrames::Line => 100,
99            SpinnerFrames::Circle => 100,
100            SpinnerFrames::Box => 100,
101            SpinnerFrames::Arrow => 100,
102            SpinnerFrames::Bounce => 120,
103            SpinnerFrames::Grow => 80,
104            SpinnerFrames::Clock => 100,
105            SpinnerFrames::Moon => 150,
106            SpinnerFrames::Ascii => 150,
107            SpinnerFrames::Toggle => 200,
108        }
109    }
110}
111
112/// State for the spinner animation
113#[derive(Debug, Clone)]
114pub struct SpinnerState {
115    /// Current frame index
116    pub frame: usize,
117    /// Last tick time
118    last_tick: Option<Instant>,
119    /// Frame interval
120    interval: Duration,
121    /// Whether the spinner is active
122    pub active: bool,
123}
124
125impl Default for SpinnerState {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131impl SpinnerState {
132    /// Create a new spinner state
133    pub fn new() -> Self {
134        Self {
135            frame: 0,
136            last_tick: None,
137            interval: Duration::from_millis(80),
138            active: true,
139        }
140    }
141
142    /// Create a new spinner state with a specific interval
143    pub fn with_interval(interval_ms: u64) -> Self {
144        Self {
145            frame: 0,
146            last_tick: None,
147            interval: Duration::from_millis(interval_ms),
148            active: true,
149        }
150    }
151
152    /// Create a new spinner state configured for specific frames
153    pub fn for_frames(frames: SpinnerFrames) -> Self {
154        Self::with_interval(frames.interval_ms())
155    }
156
157    /// Set the frame interval
158    pub fn set_interval(&mut self, interval_ms: u64) {
159        self.interval = Duration::from_millis(interval_ms);
160    }
161
162    /// Advance to the next frame if enough time has passed
163    ///
164    /// Returns true if the frame changed
165    pub fn tick(&mut self) -> bool {
166        self.tick_with_frames(10) // Default frame count
167    }
168
169    /// Advance to the next frame with a specific frame count
170    ///
171    /// Returns true if the frame changed
172    pub fn tick_with_frames(&mut self, frame_count: usize) -> bool {
173        if !self.active || frame_count == 0 {
174            return false;
175        }
176
177        let now = Instant::now();
178
179        match self.last_tick {
180            Some(last) if now.duration_since(last) >= self.interval => {
181                self.frame = (self.frame + 1) % frame_count;
182                self.last_tick = Some(now);
183                true
184            }
185            None => {
186                self.last_tick = Some(now);
187                false
188            }
189            _ => false,
190        }
191    }
192
193    /// Force advance to the next frame
194    pub fn next_frame(&mut self, frame_count: usize) {
195        if frame_count > 0 {
196            self.frame = (self.frame + 1) % frame_count;
197        }
198    }
199
200    /// Reset to the first frame
201    pub fn reset(&mut self) {
202        self.frame = 0;
203        self.last_tick = None;
204    }
205
206    /// Start the spinner
207    pub fn start(&mut self) {
208        self.active = true;
209    }
210
211    /// Stop the spinner
212    pub fn stop(&mut self) {
213        self.active = false;
214    }
215
216    /// Check if the spinner is active
217    pub fn is_active(&self) -> bool {
218        self.active
219    }
220}
221
222/// Label position relative to the spinner
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
224pub enum LabelPosition {
225    /// Label appears before (left of) the spinner
226    Before,
227    /// Label appears after (right of) the spinner
228    #[default]
229    After,
230}
231
232/// Style configuration for spinners
233#[derive(Debug, Clone)]
234pub struct SpinnerStyle {
235    /// Spinner frames to use
236    pub frames: SpinnerFrames,
237    /// Style for the spinner character
238    pub spinner_style: Style,
239    /// Style for the label text
240    pub label_style: Style,
241    /// Position of the label
242    pub label_position: LabelPosition,
243    /// Separator between spinner and label
244    pub separator: &'static str,
245}
246
247impl Default for SpinnerStyle {
248    fn default() -> Self {
249        Self {
250            frames: SpinnerFrames::Dots,
251            spinner_style: Style::default()
252                .fg(Color::Cyan)
253                .add_modifier(Modifier::BOLD),
254            label_style: Style::default().fg(Color::White),
255            label_position: LabelPosition::After,
256            separator: " ",
257        }
258    }
259}
260
261impl From<&crate::theme::Theme> for SpinnerStyle {
262    fn from(theme: &crate::theme::Theme) -> Self {
263        let p = &theme.palette;
264        Self {
265            frames: SpinnerFrames::Dots,
266            spinner_style: Style::default()
267                .fg(p.secondary)
268                .add_modifier(Modifier::BOLD),
269            label_style: Style::default().fg(p.text),
270            label_position: LabelPosition::After,
271            separator: " ",
272        }
273    }
274}
275
276impl SpinnerStyle {
277    /// Create a new spinner style with specific frames
278    pub fn new(frames: SpinnerFrames) -> Self {
279        Self {
280            frames,
281            ..Default::default()
282        }
283    }
284
285    /// Set the spinner frames
286    pub fn frames(mut self, frames: SpinnerFrames) -> Self {
287        self.frames = frames;
288        self
289    }
290
291    /// Set the spinner color
292    pub fn color(mut self, color: Color) -> Self {
293        self.spinner_style = self.spinner_style.fg(color);
294        self
295    }
296
297    /// Set the label style
298    pub fn label_style(mut self, style: Style) -> Self {
299        self.label_style = style;
300        self
301    }
302
303    /// Set the label position
304    pub fn label_position(mut self, position: LabelPosition) -> Self {
305        self.label_position = position;
306        self
307    }
308
309    /// Set the separator between spinner and label
310    pub fn separator(mut self, separator: &'static str) -> Self {
311        self.separator = separator;
312        self
313    }
314
315    /// Success style (green spinner)
316    pub fn success() -> Self {
317        Self {
318            spinner_style: Style::default()
319                .fg(Color::Green)
320                .add_modifier(Modifier::BOLD),
321            ..Default::default()
322        }
323    }
324
325    /// Warning style (yellow spinner)
326    pub fn warning() -> Self {
327        Self {
328            spinner_style: Style::default()
329                .fg(Color::Yellow)
330                .add_modifier(Modifier::BOLD),
331            ..Default::default()
332        }
333    }
334
335    /// Error style (red spinner)
336    pub fn error() -> Self {
337        Self {
338            spinner_style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
339            ..Default::default()
340        }
341    }
342
343    /// Info style (blue spinner)
344    pub fn info() -> Self {
345        Self {
346            spinner_style: Style::default()
347                .fg(Color::Blue)
348                .add_modifier(Modifier::BOLD),
349            ..Default::default()
350        }
351    }
352
353    /// Minimal style (dimmed)
354    pub fn minimal() -> Self {
355        Self {
356            spinner_style: Style::default().fg(Color::DarkGray),
357            label_style: Style::default().fg(Color::DarkGray),
358            ..Default::default()
359        }
360    }
361}
362
363/// A spinner widget for loading/processing indicators
364///
365/// Displays an animated spinner character with an optional label.
366#[derive(Debug, Clone)]
367pub struct Spinner<'a> {
368    /// Reference to the spinner state
369    state: &'a SpinnerState,
370    /// Optional label text
371    label: Option<&'a str>,
372    /// Style configuration
373    style: SpinnerStyle,
374}
375
376impl<'a> Spinner<'a> {
377    /// Create a new spinner with the given state
378    pub fn new(state: &'a SpinnerState) -> Self {
379        Self {
380            state,
381            label: None,
382            style: SpinnerStyle::default(),
383        }
384    }
385
386    /// Set the label text
387    pub fn label(mut self, label: &'a str) -> Self {
388        self.label = Some(label);
389        self
390    }
391
392    /// Set the spinner frames
393    pub fn frames(mut self, frames: SpinnerFrames) -> Self {
394        self.style.frames = frames;
395        self
396    }
397
398    /// Set the style
399    pub fn style(mut self, style: SpinnerStyle) -> Self {
400        self.style = style;
401        self
402    }
403
404    /// Apply a theme to derive the style
405    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
406        self.style(SpinnerStyle::from(theme))
407    }
408
409    /// Set the spinner color
410    pub fn color(mut self, color: Color) -> Self {
411        self.style.spinner_style = self.style.spinner_style.fg(color);
412        self
413    }
414
415    /// Set the label position
416    pub fn label_position(mut self, position: LabelPosition) -> Self {
417        self.style.label_position = position;
418        self
419    }
420
421    /// Get the current frame character
422    fn current_frame(&self) -> &'static str {
423        let frames = self.style.frames.frames();
424        let idx = self.state.frame % frames.len();
425        frames[idx]
426    }
427
428    /// Calculate the display width of the spinner (including label)
429    pub fn display_width(&self) -> usize {
430        let frame_width = self.current_frame().width();
431        match self.label {
432            Some(label) => frame_width + self.style.separator.width() + label.width(),
433            None => frame_width,
434        }
435    }
436}
437
438impl Widget for Spinner<'_> {
439    fn render(self, area: Rect, buf: &mut Buffer) {
440        if area.width == 0 || area.height == 0 {
441            return;
442        }
443
444        let frame = self.current_frame();
445        let mut x = area.x;
446        let y = area.y;
447
448        match (self.label, self.style.label_position) {
449            (Some(label), LabelPosition::Before) => {
450                // Label first, then separator, then spinner
451                let label_width = label.width() as u16;
452                if x + label_width <= area.x + area.width {
453                    buf.set_string(x, y, label, self.style.label_style);
454                    x += label_width;
455                }
456
457                let sep_width = self.style.separator.width() as u16;
458                if x + sep_width <= area.x + area.width {
459                    buf.set_string(x, y, self.style.separator, Style::default());
460                    x += sep_width;
461                }
462
463                let frame_width = frame.width() as u16;
464                if x + frame_width <= area.x + area.width {
465                    buf.set_string(x, y, frame, self.style.spinner_style);
466                }
467            }
468            (Some(label), LabelPosition::After) => {
469                // Spinner first, then separator, then label
470                let frame_width = frame.width() as u16;
471                if x + frame_width <= area.x + area.width {
472                    buf.set_string(x, y, frame, self.style.spinner_style);
473                    x += frame_width;
474                }
475
476                let sep_width = self.style.separator.width() as u16;
477                if x + sep_width <= area.x + area.width {
478                    buf.set_string(x, y, self.style.separator, Style::default());
479                    x += sep_width;
480                }
481
482                let label_width = label.width() as u16;
483                if x + label_width <= area.x + area.width {
484                    buf.set_string(x, y, label, self.style.label_style);
485                }
486            }
487            (None, _) => {
488                // Just the spinner
489                buf.set_string(x, y, frame, self.style.spinner_style);
490            }
491        }
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn test_spinner_state_new() {
501        let state = SpinnerState::new();
502        assert_eq!(state.frame, 0);
503        assert!(state.active);
504    }
505
506    #[test]
507    fn test_spinner_state_for_frames() {
508        let state = SpinnerState::for_frames(SpinnerFrames::Braille);
509        assert_eq!(state.interval, Duration::from_millis(80));
510    }
511
512    #[test]
513    fn test_spinner_state_next_frame() {
514        let mut state = SpinnerState::new();
515        assert_eq!(state.frame, 0);
516
517        state.next_frame(5);
518        assert_eq!(state.frame, 1);
519
520        state.next_frame(5);
521        assert_eq!(state.frame, 2);
522
523        // Wrap around
524        state.frame = 4;
525        state.next_frame(5);
526        assert_eq!(state.frame, 0);
527    }
528
529    #[test]
530    fn test_spinner_state_reset() {
531        let mut state = SpinnerState::new();
532        state.frame = 5;
533        state.reset();
534        assert_eq!(state.frame, 0);
535    }
536
537    #[test]
538    fn test_spinner_state_start_stop() {
539        let mut state = SpinnerState::new();
540        assert!(state.is_active());
541
542        state.stop();
543        assert!(!state.is_active());
544
545        state.start();
546        assert!(state.is_active());
547    }
548
549    #[test]
550    fn test_spinner_frames() {
551        assert_eq!(SpinnerFrames::Dots.frames().len(), 10);
552        assert_eq!(SpinnerFrames::Braille.frames().len(), 8);
553        assert_eq!(SpinnerFrames::Line.frames().len(), 4);
554        assert_eq!(SpinnerFrames::Circle.frames().len(), 4);
555        assert_eq!(SpinnerFrames::Arrow.frames().len(), 8);
556        assert_eq!(SpinnerFrames::Clock.frames().len(), 12);
557        assert_eq!(SpinnerFrames::Moon.frames().len(), 8);
558    }
559
560    #[test]
561    fn test_spinner_frames_interval() {
562        assert_eq!(SpinnerFrames::Dots.interval_ms(), 80);
563        assert_eq!(SpinnerFrames::Line.interval_ms(), 100);
564        assert_eq!(SpinnerFrames::Moon.interval_ms(), 150);
565    }
566
567    #[test]
568    fn test_spinner_style_presets() {
569        let success = SpinnerStyle::success();
570        assert_eq!(success.spinner_style.fg, Some(Color::Green));
571
572        let warning = SpinnerStyle::warning();
573        assert_eq!(warning.spinner_style.fg, Some(Color::Yellow));
574
575        let error = SpinnerStyle::error();
576        assert_eq!(error.spinner_style.fg, Some(Color::Red));
577
578        let info = SpinnerStyle::info();
579        assert_eq!(info.spinner_style.fg, Some(Color::Blue));
580    }
581
582    #[test]
583    fn test_spinner_display_width() {
584        let state = SpinnerState::new();
585
586        let spinner = Spinner::new(&state);
587        assert!(spinner.display_width() > 0);
588
589        let spinner_with_label = Spinner::new(&state).label("Loading");
590        assert!(spinner_with_label.display_width() > spinner.display_width());
591    }
592
593    #[test]
594    fn test_spinner_current_frame() {
595        let mut state = SpinnerState::new();
596        let spinner = Spinner::new(&state).frames(SpinnerFrames::Line);
597
598        // Frame 0 should be "|"
599        assert_eq!(spinner.current_frame(), "|");
600
601        state.frame = 1;
602        let spinner = Spinner::new(&state).frames(SpinnerFrames::Line);
603        assert_eq!(spinner.current_frame(), "/");
604
605        state.frame = 2;
606        let spinner = Spinner::new(&state).frames(SpinnerFrames::Line);
607        assert_eq!(spinner.current_frame(), "-");
608
609        state.frame = 3;
610        let spinner = Spinner::new(&state).frames(SpinnerFrames::Line);
611        assert_eq!(spinner.current_frame(), "\\");
612    }
613
614    #[test]
615    fn test_spinner_render() {
616        let state = SpinnerState::new();
617        let spinner = Spinner::new(&state).label("Loading...");
618
619        let mut buf = Buffer::empty(Rect::new(0, 0, 20, 1));
620        spinner.render(Rect::new(0, 0, 20, 1), &mut buf);
621        // Just verify it doesn't panic
622    }
623
624    #[test]
625    fn test_spinner_render_label_before() {
626        let state = SpinnerState::new();
627        let spinner = Spinner::new(&state)
628            .label("Status:")
629            .label_position(LabelPosition::Before);
630
631        let mut buf = Buffer::empty(Rect::new(0, 0, 20, 1));
632        spinner.render(Rect::new(0, 0, 20, 1), &mut buf);
633        // Just verify it doesn't panic
634    }
635
636    #[test]
637    fn test_spinner_render_empty_area() {
638        let state = SpinnerState::new();
639        let spinner = Spinner::new(&state);
640
641        let mut buf = Buffer::empty(Rect::new(0, 0, 0, 0));
642        spinner.render(Rect::new(0, 0, 0, 0), &mut buf);
643        // Should not panic on empty area
644    }
645}