bubbletea_widgets/
spinner.rs

1//! Spinner component for Bubble Tea applications.
2//!
3//! This module provides a spinner component for Bubble Tea applications.
4//! It closely matches the API of the Go bubbles spinner component for 1-1 compatibility.
5//!
6//! # Basic Usage
7//!
8//! ```rust
9//! use bubbletea_widgets::spinner::{new, with_spinner, with_style, DOT};
10//! use lipgloss_extras::prelude::*;
11//!
12//! // Create a spinner with default settings
13//! let spinner = new(&[]);
14//!
15//! // Create a spinner with custom settings using the option pattern
16//! let spinner = new(&[
17//!     with_spinner(DOT.clone()),
18//!     with_style(Style::new().foreground(lipgloss::Color::from("red"))),
19//! ]);
20//! ```
21//!
22//! # Available Spinners
23//!
24//! The following predefined spinners are available as constants (matching Go exactly):
25//! - `LINE`: Basic line spinner (|, /, -, \)
26//! - `DOT`: Braille dot pattern spinner
27//! - `MINI_DOT`: Smaller braille dot pattern
28//! - `JUMP`: Jumping dot animation
29//! - `PULSE`: Block fade animation (█, ▓, ▒, ░)
30//! - `POINTS`: Three dot bounce animation
31//! - `GLOBE`: Earth emoji rotation
32//! - `MOON`: Moon phase animation
33//! - `MONKEY`: See-no-evil monkey sequence
34//! - `METER`: Progress bar style animation
35//! - `HAMBURGER`: Trigram symbol animation
36//! - `ELLIPSIS`: Text ellipsis animation ("", ".", "..", "...")
37//!
38//! # bubbletea-rs Integration
39//!
40//! ```rust
41//! use bubbletea_rs::{Model as BubbleTeaModel, Msg, Cmd};
42//! use bubbletea_widgets::spinner::{new, with_spinner, DOT, TickMsg};
43//!
44//! struct MyApp {
45//!     spinner: bubbletea_widgets::spinner::Model,
46//! }
47//!
48//! impl BubbleTeaModel for MyApp {
49//!     fn init() -> (Self, Option<Cmd>) {
50//!         let spinner = new(&[with_spinner(DOT.clone())]);
51//!         // Spinners start automatically when created
52//!         (Self { spinner }, None)
53//!     }
54//!
55//!     fn update(&mut self, msg: Msg) -> Option<Cmd> {
56//!         // Forward spinner messages to spinner
57//!         self.spinner.update(msg)
58//!     }
59//!
60//!     fn view(&self) -> String {
61//!         format!("{} Loading...", self.spinner.view())
62//!     }
63//! }
64//! ```
65
66use bubbletea_rs::{tick as bubbletea_tick, Cmd, Model as BubbleTeaModel, Msg};
67use lipgloss_extras::prelude::*;
68use once_cell::sync::Lazy;
69use std::sync::atomic::{AtomicI64, Ordering};
70use std::time::Duration;
71
72// Internal ID management for spinner instances
73static LAST_ID: AtomicI64 = AtomicI64::new(0);
74
75/// Generates the next unique ID for spinner instances.
76///
77/// This function is thread-safe and ensures that each spinner instance
78/// receives a unique identifier for message routing.
79///
80/// # Returns
81///
82/// Returns a unique positive integer ID.
83fn next_id() -> i64 {
84    LAST_ID.fetch_add(1, Ordering::SeqCst) + 1
85}
86
87/// Spinner configuration defining animation frames and timing.
88///
89/// A Spinner contains the visual frames and frame rate for a terminal spinner animation.
90/// This matches the Go bubbles Spinner struct for full API compatibility.
91///
92/// # Fields
93///
94/// * `frames` - Vector of strings representing each animation frame
95/// * `fps` - Duration between frame updates (smaller = faster animation)
96///
97/// # Examples
98///
99/// ```rust
100/// use bubbletea_widgets::spinner::Spinner;
101/// use std::time::Duration;
102///
103/// // Create a custom spinner
104/// let custom = Spinner::new(
105///     vec!["◐".to_string(), "◓".to_string(), "◑".to_string(), "◒".to_string()],
106///     Duration::from_millis(200)
107/// );
108/// ```
109#[derive(Debug, Clone)]
110pub struct Spinner {
111    /// Animation frames to cycle through.
112    pub frames: Vec<String>,
113    /// Delay between frames; smaller is faster.
114    pub fps: Duration,
115}
116
117impl Spinner {
118    /// Creates a new Spinner with the given frames and timing.
119    ///
120    /// # Arguments
121    ///
122    /// * `frames` - Vector of strings representing each animation frame
123    /// * `fps` - Duration between frame updates
124    ///
125    /// # Examples
126    ///
127    /// ```rust
128    /// use bubbletea_widgets::spinner::Spinner;
129    /// use std::time::Duration;
130    ///
131    /// let spinner = Spinner::new(
132    ///     vec!["|".to_string(), "/".to_string(), "-".to_string(), "\\".to_string()],
133    ///     Duration::from_millis(100)
134    /// );
135    /// assert_eq!(spinner.frames.len(), 4);
136    /// ```
137    pub fn new(frames: Vec<String>, fps: Duration) -> Self {
138        Self { frames, fps }
139    }
140}
141
142// Predefined spinner styles matching the Go implementation exactly
143
144/// Line spinner - matches Go's Line constant
145pub static LINE: Lazy<Spinner> = Lazy::new(|| Spinner {
146    frames: vec![
147        "|".to_string(),
148        "/".to_string(),
149        "-".to_string(),
150        "\\".to_string(),
151    ],
152    fps: Duration::from_millis(100), // time.Second / 10
153});
154
155/// Dot spinner - matches Go's Dot constant  
156pub static DOT: Lazy<Spinner> = Lazy::new(|| Spinner {
157    frames: vec![
158        "⣾ ".to_string(),
159        "⣽ ".to_string(),
160        "⣻ ".to_string(),
161        "⢿ ".to_string(),
162        "⡿ ".to_string(),
163        "⣟ ".to_string(),
164        "⣯ ".to_string(),
165        "⣷ ".to_string(),
166    ],
167    fps: Duration::from_millis(100), // time.Second / 10
168});
169
170/// MiniDot spinner - matches Go's MiniDot constant
171pub static MINI_DOT: Lazy<Spinner> = Lazy::new(|| Spinner {
172    frames: vec![
173        "⠋".to_string(),
174        "⠙".to_string(),
175        "⠹".to_string(),
176        "⠸".to_string(),
177        "⠼".to_string(),
178        "⠴".to_string(),
179        "⠦".to_string(),
180        "⠧".to_string(),
181        "⠇".to_string(),
182        "⠏".to_string(),
183    ],
184    fps: Duration::from_millis(83), // time.Second / 12
185});
186
187/// Jump spinner - matches Go's Jump constant
188pub static JUMP: Lazy<Spinner> = Lazy::new(|| Spinner {
189    frames: vec![
190        "⢄".to_string(),
191        "⢂".to_string(),
192        "⢁".to_string(),
193        "⡁".to_string(),
194        "⡈".to_string(),
195        "⡐".to_string(),
196        "⡠".to_string(),
197    ],
198    fps: Duration::from_millis(100), // time.Second / 10
199});
200
201/// Pulse spinner - matches Go's Pulse constant
202pub static PULSE: Lazy<Spinner> = Lazy::new(|| Spinner {
203    frames: vec![
204        "█".to_string(),
205        "▓".to_string(),
206        "▒".to_string(),
207        "░".to_string(),
208    ],
209    fps: Duration::from_millis(125), // time.Second / 8
210});
211
212/// Points spinner - matches Go's Points constant
213pub static POINTS: Lazy<Spinner> = Lazy::new(|| Spinner {
214    frames: vec![
215        "∙∙∙".to_string(),
216        "●∙∙".to_string(),
217        "∙●∙".to_string(),
218        "∙∙●".to_string(),
219    ],
220    fps: Duration::from_millis(143), // time.Second / 7 (approximately)
221});
222
223/// Globe spinner - matches Go's Globe constant
224pub static GLOBE: Lazy<Spinner> = Lazy::new(|| Spinner {
225    frames: vec!["🌍".to_string(), "🌎".to_string(), "🌏".to_string()],
226    fps: Duration::from_millis(250), // time.Second / 4
227});
228
229/// Moon spinner - matches Go's Moon constant
230pub static MOON: Lazy<Spinner> = Lazy::new(|| Spinner {
231    frames: vec![
232        "🌑".to_string(),
233        "🌒".to_string(),
234        "🌓".to_string(),
235        "🌔".to_string(),
236        "🌕".to_string(),
237        "🌖".to_string(),
238        "🌗".to_string(),
239        "🌘".to_string(),
240    ],
241    fps: Duration::from_millis(125), // time.Second / 8
242});
243
244/// Monkey spinner - matches Go's Monkey constant
245pub static MONKEY: Lazy<Spinner> = Lazy::new(|| Spinner {
246    frames: vec!["🙈".to_string(), "🙉".to_string(), "🙊".to_string()],
247    fps: Duration::from_millis(333), // time.Second / 3
248});
249
250/// Meter spinner - matches Go's Meter constant
251pub static METER: Lazy<Spinner> = Lazy::new(|| Spinner {
252    frames: vec![
253        "▱▱▱".to_string(),
254        "▰▱▱".to_string(),
255        "▰▰▱".to_string(),
256        "▰▰▰".to_string(),
257        "▰▰▱".to_string(),
258        "▰▱▱".to_string(),
259        "▱▱▱".to_string(),
260    ],
261    fps: Duration::from_millis(143), // time.Second / 7 (approximately)
262});
263
264/// Hamburger spinner - matches Go's Hamburger constant  
265pub static HAMBURGER: Lazy<Spinner> = Lazy::new(|| Spinner {
266    frames: vec![
267        "☱".to_string(),
268        "☲".to_string(),
269        "☴".to_string(),
270        "☲".to_string(),
271    ],
272    fps: Duration::from_millis(333), // time.Second / 3
273});
274
275/// Ellipsis spinner - matches Go's Ellipsis constant
276pub static ELLIPSIS: Lazy<Spinner> = Lazy::new(|| Spinner {
277    frames: vec![
278        "".to_string(),
279        ".".to_string(),
280        "..".to_string(),
281        "...".to_string(),
282    ],
283    fps: Duration::from_millis(333), // time.Second / 3
284});
285
286// Deprecated function aliases for backward compatibility
287/// Deprecated: use the `LINE` constant instead.
288#[deprecated(since = "0.0.7", note = "use LINE constant instead")]
289pub fn line() -> Spinner {
290    LINE.clone()
291}
292
293/// Deprecated: use the `DOT` constant instead.
294#[deprecated(since = "0.0.7", note = "use DOT constant instead")]
295pub fn dot() -> Spinner {
296    DOT.clone()
297}
298
299/// Deprecated: use the `MINI_DOT` constant instead.
300#[deprecated(since = "0.0.7", note = "use MINI_DOT constant instead")]
301pub fn mini_dot() -> Spinner {
302    MINI_DOT.clone()
303}
304
305/// Deprecated: use the `JUMP` constant instead.
306#[deprecated(since = "0.0.7", note = "use JUMP constant instead")]
307pub fn jump() -> Spinner {
308    JUMP.clone()
309}
310
311/// Deprecated: use the `PULSE` constant instead.
312#[deprecated(since = "0.0.7", note = "use PULSE constant instead")]
313pub fn pulse() -> Spinner {
314    PULSE.clone()
315}
316
317/// Deprecated: use the `POINTS` constant instead.
318#[deprecated(since = "0.0.7", note = "use POINTS constant instead")]
319pub fn points() -> Spinner {
320    POINTS.clone()
321}
322
323/// Deprecated: use the `GLOBE` constant instead.
324#[deprecated(since = "0.0.7", note = "use GLOBE constant instead")]
325pub fn globe() -> Spinner {
326    GLOBE.clone()
327}
328
329/// Deprecated: use the `MOON` constant instead.
330#[deprecated(since = "0.0.7", note = "use MOON constant instead")]
331pub fn moon() -> Spinner {
332    MOON.clone()
333}
334
335/// Deprecated: use the `MONKEY` constant instead.
336#[deprecated(since = "0.0.7", note = "use MONKEY constant instead")]
337pub fn monkey() -> Spinner {
338    MONKEY.clone()
339}
340
341/// Deprecated: use the `METER` constant instead.
342#[deprecated(since = "0.0.7", note = "use METER constant instead")]
343pub fn meter() -> Spinner {
344    METER.clone()
345}
346
347/// Deprecated: use the `HAMBURGER` constant instead.
348#[deprecated(since = "0.0.7", note = "use HAMBURGER constant instead")]
349pub fn hamburger() -> Spinner {
350    HAMBURGER.clone()
351}
352
353/// Deprecated: use the `ELLIPSIS` constant instead.
354#[deprecated(since = "0.0.7", note = "use ELLIPSIS constant instead")]
355pub fn ellipsis() -> Spinner {
356    ELLIPSIS.clone()
357}
358
359/// Message indicating that the timer has ticked and the spinner should advance one frame.
360///
361/// TickMsg is used by the bubbletea-rs event system to trigger spinner animation updates.
362/// Each message contains timing information and routing data to ensure proper message delivery.
363/// This exactly matches the Go bubbles TickMsg struct for API compatibility.
364///
365/// # Fields
366///
367/// * `time` - Timestamp when the tick occurred
368/// * `id` - Unique identifier of the target spinner (0 for global messages)
369/// * `tag` - Internal sequence number to prevent message flooding
370///
371/// # Examples
372///
373/// ```rust
374/// use bubbletea_widgets::spinner::{new, DOT};
375/// use bubbletea_widgets::spinner::{with_spinner};
376///
377/// let spinner = new(&[with_spinner(DOT.clone())]);
378/// let tick_msg = spinner.tick_msg();
379/// assert_eq!(tick_msg.id, spinner.id());
380/// ```
381#[derive(Debug, Clone)]
382pub struct TickMsg {
383    /// Time is the time at which the tick occurred.
384    pub time: std::time::SystemTime,
385    /// ID is the identifier of the spinner that this message belongs to.
386    pub id: i64,
387    /// tag is used internally to prevent spinner from receiving too many messages.
388    tag: i64,
389}
390
391/// Model represents the state and configuration of a spinner component.
392///
393/// The Model struct contains all the state needed to render and animate a spinner,
394/// including the animation frames, styling, current position, and unique identifier
395/// for message routing. This matches the Go bubbles spinner.Model for full compatibility.
396///
397/// # Fields
398///
399/// * `spinner` - The Spinner configuration (frames and timing)
400/// * `style` - Lipgloss Style for visual formatting
401/// * `frame` - Current animation frame index (private)
402/// * `id` - Unique instance identifier for message routing (private)
403/// * `tag` - Message sequence number to prevent flooding (private)
404///
405/// # Examples
406///
407/// ```rust
408/// use bubbletea_widgets::spinner::{new, with_spinner, DOT};
409/// use lipgloss_extras::prelude::*;
410///
411/// let mut spinner = new(&[
412///     with_spinner(DOT.clone())
413/// ]);
414///
415/// // Use in a bubbletea-rs application
416/// let view = spinner.view(); // Returns current frame as a styled string
417/// ```
418#[derive(Debug)]
419pub struct Model {
420    /// Spinner settings to use.
421    pub spinner: Spinner,
422    /// Style sets the styling for the spinner.
423    pub style: Style,
424    frame: usize,
425    id: i64,
426    tag: i64,
427}
428
429/// Configuration option for creating a new spinner with custom settings.
430///
431/// SpinnerOption implements the options pattern used by the `new()` function
432/// to configure spinner instances. This matches Go's functional options pattern
433/// used in the original bubbles library.
434///
435/// # Variants
436///
437/// * `WithSpinner(Spinner)` - Sets the animation frames and timing
438/// * `WithStyle(Style)` - Sets the lipgloss styling
439///
440/// # Examples
441///
442/// ```rust
443/// use bubbletea_widgets::spinner::{new, with_spinner, with_style, DOT};
444/// use lipgloss_extras::prelude::*;
445///
446/// let spinner = new(&[
447///     with_spinner(DOT.clone()),
448///     with_style(Style::new().foreground(Color::from("red")))
449/// ]);
450/// ```
451pub enum SpinnerOption {
452    /// Sets the animation frames and timing to use.
453    WithSpinner(Spinner),
454    /// Sets the lipgloss style for rendering the spinner.
455    WithStyle(Box<Style>),
456}
457
458impl SpinnerOption {
459    fn apply(&self, m: &mut Model) {
460        match self {
461            SpinnerOption::WithSpinner(spinner) => m.spinner = spinner.clone(),
462            SpinnerOption::WithStyle(style) => m.style = style.as_ref().clone(),
463        }
464    }
465}
466
467/// Creates a SpinnerOption to set the animation frames and timing.
468///
469/// This function creates an option that can be passed to `new()` to configure
470/// the spinner's animation. Matches Go's WithSpinner function for API compatibility.
471///
472/// # Arguments
473///
474/// * `spinner` - The Spinner configuration to use
475///
476/// # Examples
477///
478/// ```rust
479/// use bubbletea_widgets::spinner::{new, with_spinner, DOT};
480///
481/// let spinner_model = new(&[with_spinner(DOT.clone())]);
482/// assert_eq!(spinner_model.spinner.frames.len(), 8); // DOT has 8 frames
483/// ```
484pub fn with_spinner(spinner: Spinner) -> SpinnerOption {
485    SpinnerOption::WithSpinner(spinner)
486}
487
488/// Creates a SpinnerOption to set the visual styling.
489///
490/// This function creates an option that can be passed to `new()` to configure
491/// the spinner's appearance using lipgloss styling. Matches Go's WithStyle function.
492///
493/// # Arguments
494///
495/// * `style` - The lipgloss Style to apply to the spinner
496///
497/// # Examples
498///
499/// ```rust
500/// use bubbletea_widgets::spinner::{new, with_style};
501/// use lipgloss_extras::prelude::*;
502///
503/// let red_style = Style::new().foreground(Color::from("red"));
504/// let spinner = new(&[with_style(red_style)]);
505/// // Spinner will render in red color
506/// ```
507pub fn with_style(style: Style) -> SpinnerOption {
508    SpinnerOption::WithStyle(Box::new(style))
509}
510
511impl Model {
512    /// Creates a new spinner model with default settings.
513    ///
514    /// Creates a spinner using the LINE animation with default styling.
515    /// Each spinner instance gets a unique ID for message routing.
516    ///
517    /// # Examples
518    ///
519    /// ```rust
520    /// use bubbletea_widgets::spinner::Model;
521    ///
522    /// let spinner = Model::new();
523    /// assert!(spinner.id() > 0);
524    /// ```
525    pub fn new() -> Self {
526        Self {
527            spinner: LINE.clone(),
528            style: Style::new(),
529            frame: 0,
530            id: next_id(),
531            tag: 0,
532        }
533    }
534
535    /// Creates a new spinner model with custom configuration options.
536    ///
537    /// This function implements the options pattern to create a customized spinner.
538    /// It matches Go's New function exactly for API compatibility.
539    ///
540    /// # Arguments
541    ///
542    /// * `opts` - Slice of SpinnerOption values to configure the spinner
543    ///
544    /// # Examples
545    ///
546    /// ```rust
547    /// use bubbletea_widgets::spinner::{Model, with_spinner, DOT};
548    ///
549    /// let spinner = Model::new_with_options(&[with_spinner(DOT.clone())]);
550    /// assert_eq!(spinner.spinner.frames.len(), 8);
551    /// ```
552    pub fn new_with_options(opts: &[SpinnerOption]) -> Self {
553        let mut m = Self {
554            spinner: LINE.clone(),
555            style: Style::new(),
556            frame: 0,
557            id: next_id(),
558            tag: 0,
559        };
560
561        for opt in opts {
562            opt.apply(&mut m);
563        }
564
565        m
566    }
567
568    /// Sets the spinner animation configuration using builder pattern.
569    ///
570    /// # Arguments
571    ///
572    /// * `spinner` - The Spinner configuration to use
573    ///
574    /// # Examples
575    ///
576    /// ```rust
577    /// use bubbletea_widgets::spinner::{Model, DOT};
578    ///
579    /// let spinner = Model::new().with_spinner(DOT.clone());
580    /// assert_eq!(spinner.spinner.frames.len(), 8);
581    /// ```
582    pub fn with_spinner(mut self, spinner: Spinner) -> Self {
583        self.spinner = spinner;
584        self
585    }
586
587    /// Sets the visual styling using builder pattern.
588    ///
589    /// # Arguments
590    ///
591    /// * `style` - The lipgloss Style to apply
592    ///
593    /// # Examples
594    ///
595    /// ```rust
596    /// use bubbletea_widgets::spinner::Model;
597    /// use lipgloss_extras::prelude::*;
598    ///
599    /// let spinner = Model::new()
600    ///     .with_style(Style::new().foreground(Color::from("blue")));
601    /// ```
602    pub fn with_style(mut self, style: Style) -> Self {
603        self.style = style;
604        self
605    }
606
607    /// Returns the spinner's unique identifier.
608    ///
609    /// Each spinner instance has a unique ID used for message routing
610    /// to ensure tick messages are delivered to the correct spinner.
611    /// Matches Go's ID() method for API compatibility.
612    ///
613    /// # Examples
614    ///
615    /// ```rust
616    /// use bubbletea_widgets::spinner::Model;
617    ///
618    /// let spinner1 = Model::new();
619    /// let spinner2 = Model::new();
620    /// assert_ne!(spinner1.id(), spinner2.id());
621    /// ```
622    pub fn id(&self) -> i64 {
623        self.id
624    }
625
626    /// Creates a tick message to advance the spinner animation.
627    ///
628    /// This method creates a TickMsg that can be sent through the bubbletea-rs
629    /// message system to trigger the next animation frame. The message includes
630    /// the current time, spinner ID, and tag for proper routing.
631    /// Matches Go's Tick() method for API compatibility.
632    ///
633    /// # Examples
634    ///
635    /// ```rust
636    /// use bubbletea_widgets::spinner::Model;
637    ///
638    /// let spinner = Model::new();
639    /// let tick_msg = spinner.tick_msg();
640    /// assert_eq!(tick_msg.id, spinner.id());
641    /// ```
642    pub fn tick_msg(&self) -> TickMsg {
643        TickMsg {
644            time: std::time::SystemTime::now(),
645            id: self.id,
646            tag: self.tag,
647        }
648    }
649
650    /// Creates a bubbletea-rs command to schedule the next tick.
651    ///
652    /// This internal method creates a Cmd that will trigger after the spinner's
653    /// frame duration, sending a TickMsg to continue the animation loop.
654    ///
655    /// # Returns
656    ///
657    /// Returns a Cmd that schedules the next animation frame update.
658    fn tick(&self) -> Cmd {
659        let id = self.id;
660        let tag = self.tag;
661        let fps = self.spinner.fps;
662
663        bubbletea_tick(fps, move |_| {
664            Box::new(TickMsg {
665                time: std::time::SystemTime::now(),
666                id,
667                tag,
668            }) as Msg
669        })
670    }
671}
672
673impl Default for Model {
674    fn default() -> Self {
675        Self::new()
676    }
677}
678
679impl Model {
680    /// Processes messages and updates the spinner state.
681    ///
682    /// This is the standard bubbletea-rs update function that processes incoming messages.
683    /// It handles TickMsg messages to advance the animation and ignores other message types.
684    /// The function includes ID and tag validation to ensure proper message routing and
685    /// prevent animation rate issues. Matches Go's Update method exactly.
686    ///
687    /// # Arguments
688    ///
689    /// * `msg` - The message to process
690    ///
691    /// # Returns
692    ///
693    /// Returns Some(Cmd) to schedule the next tick, or None if the message was ignored.
694    ///
695    /// # Examples
696    ///
697    /// ```rust
698    /// use bubbletea_widgets::spinner::Model;
699    ///
700    /// let mut spinner = Model::new();
701    /// let tick_msg = spinner.tick_msg();
702    /// let cmd = spinner.update(Box::new(tick_msg));
703    /// assert!(cmd.is_some()); // Should return next tick command
704    /// ```
705    pub fn update(&mut self, msg: Msg) -> std::option::Option<Cmd> {
706        if let Some(tick_msg) = msg.downcast_ref::<TickMsg>() {
707            // If an ID is set, and the ID doesn't belong to this spinner, reject the message.
708            if tick_msg.id > 0 && tick_msg.id != self.id {
709                return None;
710            }
711
712            // If a tag is set, and it's not the one we expect, reject the message.
713            // This prevents the spinner from receiving too many messages and thus spinning too fast.
714            if tick_msg.tag > 0 && tick_msg.tag != self.tag {
715                return None;
716            }
717
718            self.frame += 1;
719            if self.frame >= self.spinner.frames.len() {
720                self.frame = 0;
721            }
722
723            self.tag += 1;
724            return std::option::Option::Some(self.tick());
725        }
726
727        std::option::Option::None
728    }
729
730    /// Renders the current spinner frame as a styled string.
731    ///
732    /// This method returns the current animation frame with styling applied.
733    /// It's the standard bubbletea-rs view function that produces the visual output.
734    /// Matches Go's View method exactly.
735    ///
736    /// # Returns
737    ///
738    /// Returns the styled string representation of the current frame.
739    /// Returns "(error)" if the frame index is invalid.
740    ///
741    /// # Examples
742    ///
743    /// ```rust
744    /// use bubbletea_widgets::spinner::{new, with_spinner, LINE};
745    ///
746    /// let spinner = new(&[with_spinner(LINE.clone())]);
747    /// let output = spinner.view();
748    /// assert_eq!(output, "|"); // First frame of LINE spinner
749    /// ```
750    pub fn view(&self) -> String {
751        if self.frame >= self.spinner.frames.len() {
752            return "(error)".to_string();
753        }
754
755        self.style.render(&self.spinner.frames[self.frame])
756    }
757}
758
759impl BubbleTeaModel for Model {
760    fn init() -> (Self, std::option::Option<Cmd>) {
761        let model = Self::new();
762        let cmd = model.tick();
763        (model, std::option::Option::Some(cmd))
764    }
765
766    fn update(&mut self, msg: Msg) -> std::option::Option<Cmd> {
767        self.update(msg)
768    }
769
770    fn view(&self) -> String {
771        self.view()
772    }
773}
774
775/// Creates a new spinner with the given configuration options.
776///
777/// This is the main constructor function that implements the options pattern
778/// for creating customized spinners. It matches Go's New function exactly
779/// for full API compatibility.
780///
781/// # Arguments
782///
783/// * `opts` - Slice of SpinnerOption values to configure the spinner
784///
785/// # Examples
786///
787/// ```rust
788/// use bubbletea_widgets::spinner::{new, with_spinner, with_style, DOT};
789/// use lipgloss_extras::prelude::*;
790///
791/// // Create with default settings
792/// let basic_spinner = new(&[]);
793///
794/// // Create with custom animation and styling
795/// let fancy_spinner = new(&[
796///     with_spinner(DOT.clone()),
797///     with_style(Style::new().foreground(Color::from("cyan")))
798/// ]);
799/// ```
800pub fn new(opts: &[SpinnerOption]) -> Model {
801    Model::new_with_options(opts)
802}
803
804/// NewModel returns a model with default values - matches Go's deprecated NewModel.
805#[deprecated(since = "0.0.7", note = "use new instead")]
806pub fn new_model(opts: &[SpinnerOption]) -> Model {
807    new(opts)
808}
809
810/// Tick is the command used to advance the spinner one frame - matches Go's deprecated Tick function.
811#[deprecated(since = "0.0.7", note = "use Model::tick_msg instead")]
812pub fn tick() -> TickMsg {
813    TickMsg {
814        time: std::time::SystemTime::now(),
815        id: 0,
816        tag: 0,
817    }
818}
819
820#[cfg(test)]
821#[allow(deprecated)]
822mod tests {
823    use super::*;
824    use crate::spinner::{
825        dot, line, new, new_model, tick, with_spinner, with_style, DOT, ELLIPSIS, GLOBE, HAMBURGER,
826        JUMP, LINE, METER, MINI_DOT, MONKEY, MOON, POINTS, PULSE,
827    };
828
829    #[test]
830    fn test_spinner_constants() {
831        // Test that all spinner constants exist and have correct frame counts
832        assert_eq!(LINE.frames.len(), 4);
833        assert_eq!(DOT.frames.len(), 8);
834        assert_eq!(MINI_DOT.frames.len(), 10);
835        assert_eq!(JUMP.frames.len(), 7);
836        assert_eq!(PULSE.frames.len(), 4);
837        assert_eq!(POINTS.frames.len(), 4);
838        assert_eq!(GLOBE.frames.len(), 3);
839        assert_eq!(MOON.frames.len(), 8);
840        assert_eq!(MONKEY.frames.len(), 3);
841        assert_eq!(METER.frames.len(), 7);
842        assert_eq!(HAMBURGER.frames.len(), 4);
843        assert_eq!(ELLIPSIS.frames.len(), 4);
844    }
845
846    #[test]
847    fn test_spinner_frames_match_go() {
848        // Test that spinner frames match Go implementation exactly
849        assert_eq!(LINE.frames, vec!["|", "/", "-", "\\"]);
850        assert_eq!(
851            DOT.frames,
852            vec!["⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "]
853        );
854        assert_eq!(
855            MINI_DOT.frames,
856            vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
857        );
858        assert_eq!(JUMP.frames, vec!["⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"]);
859        assert_eq!(PULSE.frames, vec!["█", "▓", "▒", "░"]);
860        assert_eq!(POINTS.frames, vec!["∙∙∙", "●∙∙", "∙●∙", "∙∙●"]);
861        assert_eq!(GLOBE.frames, vec!["🌍", "🌎", "🌏"]);
862        assert_eq!(
863            MOON.frames,
864            vec!["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"]
865        );
866        assert_eq!(MONKEY.frames, vec!["🙈", "🙉", "🙊"]);
867        assert_eq!(
868            METER.frames,
869            vec!["▱▱▱", "▰▱▱", "▰▰▱", "▰▰▰", "▰▰▱", "▰▱▱", "▱▱▱"]
870        );
871        assert_eq!(HAMBURGER.frames, vec!["☱", "☲", "☴", "☲"]);
872        assert_eq!(ELLIPSIS.frames, vec!["", ".", "..", "..."]);
873    }
874
875    #[test]
876    fn test_new_with_no_options() {
877        // Test Go's: New()
878        let spinner = new(&[]);
879        assert!(spinner.id() > 0); // Should have a unique ID
880        assert_eq!(spinner.spinner.frames, LINE.frames); // Should default to Line
881    }
882
883    #[test]
884    fn test_new_with_spinner_option() {
885        // Test Go's: New(WithSpinner(Dot))
886        let spinner = new(&[with_spinner(DOT.clone())]);
887        assert_eq!(spinner.spinner.frames, DOT.frames);
888    }
889
890    #[test]
891    fn test_new_with_style_option() {
892        // Test Go's: New(WithStyle(style))
893        let style = Style::new().foreground(lipgloss::Color::from("red"));
894        let _spinner = new(&[with_style(style.clone())]);
895        // Note: Style comparison is complex, so we just verify it was set
896        // In a real test, you'd verify the style was applied correctly
897    }
898
899    #[test]
900    fn test_new_with_multiple_options() {
901        // Test Go's: New(WithSpinner(Jump), WithStyle(style))
902        let style = Style::new().foreground(lipgloss::Color::from("blue"));
903        let spinner = new(&[with_spinner(JUMP.clone()), with_style(style.clone())]);
904        assert_eq!(spinner.spinner.frames, JUMP.frames);
905    }
906
907    #[test]
908    fn test_model_id() {
909        // Test Go's: model.ID()
910        let spinner1 = new(&[]);
911        let spinner2 = new(&[]);
912
913        // Each spinner should have unique IDs
914        assert_ne!(spinner1.id(), spinner2.id());
915        assert!(spinner1.id() > 0);
916        assert!(spinner2.id() > 0);
917    }
918
919    #[test]
920    fn test_model_tick_msg() {
921        // Test Go's: model.Tick()
922        let spinner = new(&[]);
923        let tick_msg = spinner.tick_msg();
924
925        assert_eq!(tick_msg.id, spinner.id());
926        // Time should be recent (within last second)
927        let now = std::time::SystemTime::now();
928        let elapsed = now.duration_since(tick_msg.time).unwrap();
929        assert!(elapsed.as_secs() < 1);
930    }
931
932    #[test]
933    fn test_global_tick_deprecated() {
934        // Test Go's deprecated: Tick()
935        let tick_msg = tick();
936        assert_eq!(tick_msg.id, 0); // Global tick has ID 0
937    }
938
939    #[test]
940    fn test_update_with_wrong_id() {
941        // Test that spinner rejects messages with wrong ID
942        let mut spinner = new(&[]);
943        let wrong_tick = TickMsg {
944            time: std::time::SystemTime::now(),
945            id: spinner.id() + 999, // Wrong ID
946            tag: 0,
947        };
948
949        let result = spinner.update(Box::new(wrong_tick));
950        assert!(result.is_none()); // Should reject
951    }
952
953    #[test]
954    fn test_update_with_correct_id() {
955        // Test that spinner accepts messages with correct ID
956        let mut spinner = new(&[]);
957        let correct_tick = TickMsg {
958            time: std::time::SystemTime::now(),
959            id: spinner.id(),
960            tag: 0,
961        };
962
963        let result = spinner.update(Box::new(correct_tick));
964        assert!(result.is_some()); // Should accept and return new tick
965    }
966
967    #[test]
968    fn test_view_renders_correctly() {
969        // Test Go's: model.View()
970        let mut spinner = new(&[with_spinner(LINE.clone())]);
971
972        // Initial view should show first frame
973        let view = spinner.view();
974        assert_eq!(view, "|"); // First frame of Line spinner
975
976        // After update, should show next frame
977        let tick_msg = spinner.tick_msg();
978        spinner.update(Box::new(tick_msg));
979        let view = spinner.view();
980        assert_eq!(view, "/"); // Second frame of Line spinner
981    }
982
983    #[test]
984    fn test_frame_wrapping() {
985        // Test that frames wrap around correctly
986        let mut spinner = new(&[with_spinner(LINE.clone())]); // 4 frames
987
988        // Advance through all frames
989        for expected_frame in &["|", "/", "-", "\\", "|"] {
990            // Should wrap back to first
991            let view = spinner.view();
992            assert_eq!(view, *expected_frame);
993
994            if expected_frame != &"|" || view == "|" {
995                // Don't tick after last assertion
996                let tick_msg = spinner.tick_msg();
997                spinner.update(Box::new(tick_msg));
998            }
999        }
1000    }
1001
1002    #[test]
1003    fn test_deprecated_functions() {
1004        // Test that deprecated function aliases work
1005        #[allow(deprecated)]
1006        {
1007            let spinner_line = line();
1008            assert_eq!(spinner_line.frames, LINE.frames);
1009
1010            let spinner_dot = dot();
1011            assert_eq!(spinner_dot.frames, DOT.frames);
1012
1013            let model = new_model(&[]);
1014            assert!(model.id() > 0);
1015        }
1016    }
1017
1018    #[test]
1019    fn test_builder_methods_still_work() {
1020        // Test that existing builder methods still work for backward compatibility
1021        let spinner = Model::new()
1022            .with_spinner(PULSE.clone())
1023            .with_style(Style::new());
1024
1025        assert_eq!(spinner.spinner.frames, PULSE.frames);
1026    }
1027}