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