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}