Skip to main content

bubbles/
spinner.rs

1//! Spinner component for loading indicators.
2//!
3//! This module provides animated spinners with multiple preset styles.
4//!
5//! # Example
6//!
7//! ```rust
8//! use bubbles::spinner::{Spinner, SpinnerModel, spinners};
9//!
10//! // Create a spinner with the default style
11//! let spinner = SpinnerModel::new();
12//!
13//! // Or with a specific style
14//! let spinner = SpinnerModel::with_spinner(spinners::dot());
15//!
16//! // Get the tick command to start animation
17//! let tick_msg = spinner.tick();
18//! ```
19
20use std::sync::atomic::{AtomicU64, Ordering};
21use std::time::Duration;
22
23use bubbletea::{Cmd, Message, Model};
24use lipgloss::Style;
25
26/// Global ID counter for spinner instances.
27static NEXT_ID: AtomicU64 = AtomicU64::new(1);
28
29fn next_id() -> u64 {
30    NEXT_ID.fetch_add(1, Ordering::Relaxed)
31}
32
33/// A spinner animation definition.
34#[derive(Debug, Clone)]
35pub struct Spinner {
36    /// The frames of the animation.
37    pub frames: Vec<String>,
38    /// Frames per second for the animation.
39    pub fps: u32,
40}
41
42impl Spinner {
43    /// Creates a new spinner with the given frames and FPS.
44    #[must_use]
45    pub fn new(frames: Vec<&str>, fps: u32) -> Self {
46        Self {
47            frames: frames.into_iter().map(String::from).collect(),
48            fps,
49        }
50    }
51
52    /// Returns the duration between frames.
53    #[must_use]
54    pub fn frame_duration(&self) -> Duration {
55        if self.fps == 0 {
56            Duration::from_secs(1)
57        } else {
58            Duration::from_secs_f64(1.0 / f64::from(self.fps))
59        }
60    }
61}
62
63/// Predefined spinner styles.
64pub mod spinners {
65    use super::Spinner;
66
67    /// Line spinner: `| / - \`
68    #[must_use]
69    pub fn line() -> Spinner {
70        Spinner::new(vec!["|", "/", "-", "\\"], 10)
71    }
72
73    /// Braille dot spinner.
74    #[must_use]
75    pub fn dot() -> Spinner {
76        Spinner::new(vec!["⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "], 10)
77    }
78
79    /// Mini braille dot spinner.
80    #[must_use]
81    pub fn mini_dot() -> Spinner {
82        Spinner::new(vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], 12)
83    }
84
85    /// Jump spinner.
86    #[must_use]
87    pub fn jump() -> Spinner {
88        Spinner::new(vec!["⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"], 10)
89    }
90
91    /// Pulse spinner.
92    #[must_use]
93    pub fn pulse() -> Spinner {
94        Spinner::new(vec!["█", "▓", "▒", "░"], 8)
95    }
96
97    /// Points spinner.
98    #[must_use]
99    pub fn points() -> Spinner {
100        Spinner::new(vec!["∙∙∙", "●∙∙", "∙●∙", "∙∙●"], 7)
101    }
102
103    /// Globe spinner.
104    #[must_use]
105    pub fn globe() -> Spinner {
106        Spinner::new(vec!["🌍", "🌎", "🌏"], 4)
107    }
108
109    /// Moon phases spinner.
110    #[must_use]
111    pub fn moon() -> Spinner {
112        Spinner::new(vec!["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"], 8)
113    }
114
115    /// Monkey spinner.
116    #[must_use]
117    pub fn monkey() -> Spinner {
118        Spinner::new(vec!["🙈", "🙉", "🙊"], 3)
119    }
120
121    /// Meter spinner.
122    #[must_use]
123    pub fn meter() -> Spinner {
124        Spinner::new(vec!["▱▱▱", "▰▱▱", "▰▰▱", "▰▰▰", "▰▰▱", "▰▱▱", "▱▱▱"], 7)
125    }
126
127    /// Hamburger spinner.
128    #[must_use]
129    pub fn hamburger() -> Spinner {
130        Spinner::new(vec!["☱", "☲", "☴", "☲"], 3)
131    }
132
133    /// Ellipsis spinner.
134    #[must_use]
135    pub fn ellipsis() -> Spinner {
136        Spinner::new(vec!["", ".", "..", "..."], 3)
137    }
138}
139
140/// Message indicating that the spinner should advance to the next frame.
141#[derive(Debug, Clone)]
142pub struct TickMsg {
143    /// The spinner ID this tick is for.
144    pub id: u64,
145    /// Tag for message ordering.
146    tag: u64,
147}
148
149/// The spinner model.
150#[derive(Debug, Clone)]
151pub struct SpinnerModel {
152    /// The spinner animation to use.
153    pub spinner: Spinner,
154    /// Style for rendering the spinner.
155    pub style: Style,
156
157    frame: usize,
158    id: u64,
159    tag: u64,
160}
161
162impl Default for SpinnerModel {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168impl SpinnerModel {
169    /// Creates a new spinner with the default line style.
170    #[must_use]
171    pub fn new() -> Self {
172        Self {
173            spinner: spinners::line(),
174            style: Style::new(),
175            frame: 0,
176            id: next_id(),
177            tag: 0,
178        }
179    }
180
181    /// Creates a new spinner with the given spinner style.
182    #[must_use]
183    pub fn with_spinner(spinner: Spinner) -> Self {
184        Self {
185            spinner,
186            style: Style::new(),
187            frame: 0,
188            id: next_id(),
189            tag: 0,
190        }
191    }
192
193    /// Sets the spinner animation style.
194    #[must_use]
195    pub fn spinner(mut self, spinner: Spinner) -> Self {
196        self.spinner = spinner;
197        self
198    }
199
200    /// Sets the lipgloss style.
201    #[must_use]
202    pub fn style(mut self, style: Style) -> Self {
203        self.style = style;
204        self
205    }
206
207    /// Returns the spinner's unique ID.
208    #[must_use]
209    pub fn id(&self) -> u64 {
210        self.id
211    }
212
213    /// Creates a tick message to start or continue the spinner animation.
214    ///
215    /// Use this to get the initial tick message, then the spinner will
216    /// continue ticking via the command returned from `update`.
217    #[must_use]
218    pub fn tick(&self) -> Message {
219        Message::new(TickMsg {
220            id: self.id,
221            tag: self.tag,
222        })
223    }
224
225    /// Creates a command to tick the spinner after the appropriate delay.
226    fn tick_cmd(&self) -> Cmd {
227        let id = self.id;
228        let tag = self.tag;
229        let duration = self.spinner.frame_duration();
230
231        Cmd::new(move || {
232            std::thread::sleep(duration);
233            Message::new(TickMsg { id, tag })
234        })
235    }
236
237    /// Updates the spinner state.
238    ///
239    /// Returns a command to schedule the next tick.
240    pub fn update(&mut self, msg: Message) -> Option<Cmd> {
241        if let Some(tick) = msg.downcast_ref::<TickMsg>() {
242            // Reject messages for other spinners
243            if tick.id > 0 && tick.id != self.id {
244                return None;
245            }
246
247            // Reject outdated tags
248            if tick.tag != self.tag {
249                return None;
250            }
251
252            // Advance frame
253            self.frame += 1;
254            if self.frame >= self.spinner.frames.len() {
255                self.frame = 0;
256            }
257
258            // Increment tag and schedule next tick
259            self.tag = self.tag.wrapping_add(1);
260            return Some(self.tick_cmd());
261        }
262
263        None
264    }
265
266    /// Renders the current spinner frame.
267    #[must_use]
268    pub fn view(&self) -> String {
269        if self.frame >= self.spinner.frames.len() {
270            return "(error)".to_string();
271        }
272
273        self.style.render(&self.spinner.frames[self.frame])
274    }
275}
276
277/// Implement the Model trait for standalone bubbletea usage.
278impl Model for SpinnerModel {
279    fn init(&self) -> Option<Cmd> {
280        // Return a command to start the spinner's tick cycle
281        let id = self.id;
282        let tag = self.tag;
283        let duration = self.spinner.frame_duration();
284
285        Some(Cmd::new(move || {
286            std::thread::sleep(duration);
287            Message::new(TickMsg { id, tag })
288        }))
289    }
290
291    fn update(&mut self, msg: Message) -> Option<Cmd> {
292        SpinnerModel::update(self, msg)
293    }
294
295    fn view(&self) -> String {
296        SpinnerModel::view(self)
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn test_spinner_new() {
306        let spinner = SpinnerModel::new();
307        assert!(!spinner.spinner.frames.is_empty());
308        assert!(spinner.id() > 0);
309    }
310
311    #[test]
312    fn test_spinner_unique_ids() {
313        let s1 = SpinnerModel::new();
314        let s2 = SpinnerModel::new();
315        assert_ne!(s1.id(), s2.id());
316    }
317
318    #[test]
319    fn test_spinner_with_style() {
320        let spinner = SpinnerModel::with_spinner(spinners::dot());
321        assert_eq!(spinner.spinner.frames.len(), 8);
322    }
323
324    #[test]
325    fn test_spinner_view() {
326        let spinner = SpinnerModel::new();
327        let view = spinner.view();
328        assert!(!view.is_empty());
329    }
330
331    #[test]
332    fn test_spinner_frame_advance() {
333        let mut spinner = SpinnerModel::new();
334        let initial_frame = spinner.frame;
335
336        // Simulate a tick
337        let tick = Message::new(TickMsg {
338            id: spinner.id(),
339            tag: spinner.tag,
340        });
341        spinner.update(tick);
342
343        assert_eq!(spinner.frame, initial_frame + 1);
344    }
345
346    #[test]
347    fn test_spinner_frame_wrap() {
348        let mut spinner = SpinnerModel::with_spinner(Spinner::new(vec!["a", "b"], 10));
349        spinner.frame = 1;
350        spinner.tag = 0;
351
352        let tick = Message::new(TickMsg {
353            id: spinner.id(),
354            tag: 0,
355        });
356        spinner.update(tick);
357
358        assert_eq!(spinner.frame, 0); // Should wrap around
359    }
360
361    #[test]
362    fn test_spinner_ignores_other_ids() {
363        let mut spinner = SpinnerModel::new();
364        let initial_frame = spinner.frame;
365
366        // Tick with wrong ID
367        let tick = Message::new(TickMsg { id: 9999, tag: 0 });
368        spinner.update(tick);
369
370        assert_eq!(spinner.frame, initial_frame); // Should not advance
371    }
372
373    #[test]
374    fn test_spinner_ignores_old_tags() {
375        let mut spinner = SpinnerModel::new();
376        spinner.tag = 5;
377        let initial_frame = spinner.frame;
378
379        // Tick with old tag
380        let tick = Message::new(TickMsg {
381            id: spinner.id(),
382            tag: 3,
383        });
384        spinner.update(tick);
385
386        assert_eq!(spinner.frame, initial_frame); // Should not advance
387    }
388
389    #[test]
390    fn test_spinner_rejects_stale_zero_tag() {
391        let mut spinner = SpinnerModel::new();
392        spinner.tag = 1;
393        let initial_frame = spinner.frame;
394
395        let tick = Message::new(TickMsg {
396            id: spinner.id(),
397            tag: 0,
398        });
399        spinner.update(tick);
400
401        assert_eq!(spinner.frame, initial_frame);
402    }
403
404    #[test]
405    fn test_predefined_spinners() {
406        // Just verify they can be created
407        let _ = spinners::line();
408        let _ = spinners::dot();
409        let _ = spinners::mini_dot();
410        let _ = spinners::jump();
411        let _ = spinners::pulse();
412        let _ = spinners::points();
413        let _ = spinners::globe();
414        let _ = spinners::moon();
415        let _ = spinners::monkey();
416        let _ = spinners::meter();
417        let _ = spinners::hamburger();
418        let _ = spinners::ellipsis();
419    }
420
421    #[test]
422    fn test_spinner_frame_duration() {
423        let spinner = Spinner::new(vec!["a"], 10);
424        assert_eq!(spinner.frame_duration(), Duration::from_millis(100));
425
426        let spinner = Spinner::new(vec!["a"], 0);
427        assert_eq!(spinner.frame_duration(), Duration::from_secs(1));
428    }
429
430    #[test]
431    fn test_model_init_returns_tick_cmd() {
432        let spinner = SpinnerModel::new();
433        let cmd = Model::init(&spinner);
434        assert!(cmd.is_some());
435    }
436
437    #[test]
438    fn test_model_update_advances_frame() {
439        let mut spinner = SpinnerModel::new();
440        let initial_frame = spinner.frame;
441        let tick = Message::new(TickMsg {
442            id: spinner.id(),
443            tag: spinner.tag,
444        });
445
446        let cmd = Model::update(&mut spinner, tick);
447
448        assert!(cmd.is_some());
449        assert_eq!(spinner.frame, initial_frame + 1);
450    }
451
452    #[test]
453    fn test_model_view_matches_view() {
454        let spinner = SpinnerModel::new();
455        assert_eq!(Model::view(&spinner), spinner.view());
456    }
457}