Skip to main content

sqlmodel_console/renderables/
spinner.rs

1//! Indeterminate spinner for unknown-length operations.
2//!
3//! `IndeterminateSpinner` shows activity feedback when the total count or duration
4//! is not known. Useful for connection establishment, complex queries, etc.
5//!
6//! # Example
7//!
8//! ```rust
9//! use sqlmodel_console::renderables::{IndeterminateSpinner, SpinnerStyle};
10//!
11//! let spinner = IndeterminateSpinner::new("Connecting to database")
12//!     .style(SpinnerStyle::Dots);
13//!
14//! // Plain text: "[...] Connecting to database (2.3s)"
15//! println!("{}", spinner.render_plain());
16//! ```
17
18use std::time::Instant;
19
20use serde::{Deserialize, Serialize};
21
22use super::OperationProgress;
23use crate::theme::Theme;
24
25/// Spinner animation style.
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
27pub enum SpinnerStyle {
28    /// Three dots cycling: ".", "..", "..."
29    #[default]
30    Dots,
31    /// Unicode braille pattern animation
32    Braille,
33    /// Rotating line: -, \, |, /
34    Line,
35    /// Rotating arrow
36    Arrow,
37    /// Simple asterisk blinking
38    Simple,
39}
40
41impl SpinnerStyle {
42    /// Get the animation frames for this style.
43    #[must_use]
44    pub fn frames(&self) -> &'static [&'static str] {
45        match self {
46            Self::Dots => &[".", "..", "...", ".."],
47            Self::Braille => &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
48            Self::Line => &["-", "\\", "|", "/"],
49            Self::Arrow => &["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"],
50            Self::Simple => &["*", " "],
51        }
52    }
53
54    /// Get the interval between frames in milliseconds.
55    #[must_use]
56    pub const fn interval_ms(&self) -> u64 {
57        match self {
58            Self::Dots => 250,
59            Self::Braille => 80,
60            Self::Line => 100,
61            Self::Arrow => 120,
62            Self::Simple => 500,
63        }
64    }
65
66    /// Get the frame for a given elapsed time.
67    #[must_use]
68    #[allow(clippy::cast_possible_truncation)] // frame_index is bounded by frames.len()
69    pub fn frame_at(&self, elapsed_ms: u64) -> &'static str {
70        let frames = self.frames();
71        let interval = self.interval_ms();
72        let frame_index = ((elapsed_ms / interval) as usize) % frames.len();
73        frames[frame_index]
74    }
75}
76
77/// A spinner for operations with unknown total count or duration.
78///
79/// Shows activity with elapsed time, useful for:
80/// - Establishing database connections
81/// - Running complex queries
82/// - Waiting for locks
83/// - Initial data discovery
84///
85/// # Rendering Modes
86///
87/// - **Rich mode**: Animated spinner with message and elapsed time
88/// - **Plain mode**: Static `[...] message (elapsed)` format for agents
89/// - **JSON mode**: Structured data for programmatic consumption
90///
91/// # Example
92///
93/// ```rust
94/// use sqlmodel_console::renderables::{IndeterminateSpinner, SpinnerStyle};
95///
96/// let spinner = IndeterminateSpinner::new("Loading data")
97///     .style(SpinnerStyle::Braille);
98///
99/// // Can convert to progress bar when total becomes known
100/// let progress = spinner.into_progress(1000);
101/// ```
102#[derive(Debug, Clone)]
103pub struct IndeterminateSpinner {
104    /// Status message to display
105    message: String,
106    /// When the spinner started
107    started_at: Instant,
108    /// Animation style
109    style: SpinnerStyle,
110    /// Optional theme for styling
111    theme: Option<Theme>,
112}
113
114impl IndeterminateSpinner {
115    /// Create a new spinner with a message.
116    ///
117    /// # Arguments
118    /// - `message`: Status message describing the operation
119    #[must_use]
120    pub fn new(message: impl Into<String>) -> Self {
121        Self {
122            message: message.into(),
123            started_at: Instant::now(),
124            style: SpinnerStyle::default(),
125            theme: None,
126        }
127    }
128
129    /// Set the animation style.
130    #[must_use]
131    pub fn style(mut self, style: SpinnerStyle) -> Self {
132        self.style = style;
133        self
134    }
135
136    /// Set the theme for styled output.
137    #[must_use]
138    pub fn theme(mut self, theme: Theme) -> Self {
139        self.theme = Some(theme);
140        self
141    }
142
143    /// Update the status message.
144    pub fn set_message(&mut self, message: impl Into<String>) {
145        self.message = message.into();
146    }
147
148    /// Get the current message.
149    #[must_use]
150    pub fn message(&self) -> &str {
151        &self.message
152    }
153
154    /// Get the current style.
155    #[must_use]
156    pub fn current_style(&self) -> SpinnerStyle {
157        self.style
158    }
159
160    /// Reset the start time.
161    pub fn reset_timer(&mut self) {
162        self.started_at = Instant::now();
163    }
164
165    /// Get elapsed time in seconds.
166    #[must_use]
167    pub fn elapsed_secs(&self) -> f64 {
168        self.started_at.elapsed().as_secs_f64()
169    }
170
171    /// Get elapsed time in milliseconds.
172    #[must_use]
173    #[allow(clippy::cast_possible_truncation)] // milliseconds won't overflow u64 for practical durations
174    pub fn elapsed_ms(&self) -> u64 {
175        self.started_at.elapsed().as_millis() as u64
176    }
177
178    /// Get the current animation frame.
179    #[must_use]
180    pub fn current_frame(&self) -> &'static str {
181        self.style.frame_at(self.elapsed_ms())
182    }
183
184    /// Convert to a progress bar when total becomes known.
185    ///
186    /// The progress bar inherits the spinner's message as the operation name
187    /// and starts with 0 completed items.
188    ///
189    /// # Arguments
190    /// - `total`: The total number of items to process
191    #[must_use]
192    pub fn into_progress(self, total: u64) -> OperationProgress {
193        let mut progress = OperationProgress::new(self.message, total);
194        if let Some(theme) = self.theme {
195            progress = progress.theme(theme);
196        }
197        progress
198    }
199
200    /// Render as plain text for agents.
201    ///
202    /// Format: `[...] message (elapsed)`
203    #[must_use]
204    pub fn render_plain(&self) -> String {
205        format!(
206            "[...] {} ({})",
207            self.message,
208            format_elapsed(self.elapsed_secs())
209        )
210    }
211
212    /// Render with ANSI styling and animation.
213    ///
214    /// Shows the current animation frame with colors.
215    #[must_use]
216    pub fn render_styled(&self) -> String {
217        let theme = self.theme.clone().unwrap_or_default();
218        let frame = self.current_frame();
219
220        let color = theme.info.color_code();
221        let reset = "\x1b[0m";
222
223        format!(
224            "{color}[{frame}]{reset} {} ({})",
225            self.message,
226            format_elapsed(self.elapsed_secs())
227        )
228    }
229
230    /// Render as JSON for structured output.
231    #[must_use]
232    pub fn to_json(&self) -> String {
233        #[derive(Serialize)]
234        struct SpinnerJson<'a> {
235            message: &'a str,
236            elapsed_secs: f64,
237            style: &'a str,
238            frame: &'a str,
239        }
240
241        let style_str = match self.style {
242            SpinnerStyle::Dots => "dots",
243            SpinnerStyle::Braille => "braille",
244            SpinnerStyle::Line => "line",
245            SpinnerStyle::Arrow => "arrow",
246            SpinnerStyle::Simple => "simple",
247        };
248
249        let json = SpinnerJson {
250            message: &self.message,
251            elapsed_secs: self.elapsed_secs(),
252            style: style_str,
253            frame: self.current_frame(),
254        };
255
256        serde_json::to_string(&json).unwrap_or_else(|_| "{}".to_string())
257    }
258}
259
260/// Format elapsed time as human-readable string.
261fn format_elapsed(secs: f64) -> String {
262    if secs < 60.0 {
263        format!("{secs:.1}s")
264    } else if secs < 3600.0 {
265        let mins = (secs / 60.0).floor();
266        let remaining = secs % 60.0;
267        format!("{mins:.0}m{remaining:.0}s")
268    } else {
269        let hours = (secs / 3600.0).floor();
270        let remaining_mins = ((secs % 3600.0) / 60.0).floor();
271        format!("{hours:.0}h{remaining_mins:.0}m")
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_spinner_creation() {
281        let spinner = IndeterminateSpinner::new("Connecting");
282        assert_eq!(spinner.message(), "Connecting");
283        assert_eq!(spinner.current_style(), SpinnerStyle::Dots);
284    }
285
286    #[test]
287    fn test_spinner_all_styles() {
288        for style in [
289            SpinnerStyle::Dots,
290            SpinnerStyle::Braille,
291            SpinnerStyle::Line,
292            SpinnerStyle::Arrow,
293            SpinnerStyle::Simple,
294        ] {
295            let spinner = IndeterminateSpinner::new("Test").style(style);
296            assert_eq!(spinner.current_style(), style);
297            // Verify frames exist
298            assert!(!spinner.current_frame().is_empty() || style == SpinnerStyle::Simple);
299        }
300    }
301
302    #[test]
303    fn test_spinner_style_frames() {
304        assert_eq!(SpinnerStyle::Dots.frames().len(), 4);
305        assert_eq!(SpinnerStyle::Braille.frames().len(), 10);
306        assert_eq!(SpinnerStyle::Line.frames().len(), 4);
307        assert_eq!(SpinnerStyle::Arrow.frames().len(), 8);
308        assert_eq!(SpinnerStyle::Simple.frames().len(), 2);
309    }
310
311    #[test]
312    fn test_spinner_frame_generation() {
313        let style = SpinnerStyle::Dots;
314        // Frame 0 at 0ms
315        assert_eq!(style.frame_at(0), ".");
316        // Frame 1 at 250ms
317        assert_eq!(style.frame_at(250), "..");
318        // Frame 2 at 500ms
319        assert_eq!(style.frame_at(500), "...");
320        // Frame 3 at 750ms
321        assert_eq!(style.frame_at(750), "..");
322        // Wraps back to frame 0 at 1000ms
323        assert_eq!(style.frame_at(1000), ".");
324    }
325
326    #[test]
327    fn test_spinner_style_intervals() {
328        assert_eq!(SpinnerStyle::Dots.interval_ms(), 250);
329        assert_eq!(SpinnerStyle::Braille.interval_ms(), 80);
330        assert_eq!(SpinnerStyle::Line.interval_ms(), 100);
331        assert_eq!(SpinnerStyle::Arrow.interval_ms(), 120);
332        assert_eq!(SpinnerStyle::Simple.interval_ms(), 500);
333    }
334
335    #[test]
336    fn test_spinner_elapsed_time() {
337        let spinner = IndeterminateSpinner::new("Test");
338        // Elapsed time should be very small initially
339        assert!(spinner.elapsed_secs() < 1.0);
340        assert!(spinner.elapsed_ms() < 1000);
341    }
342
343    #[test]
344    fn test_spinner_render_plain() {
345        let spinner = IndeterminateSpinner::new("Connecting to database");
346        let plain = spinner.render_plain();
347
348        assert!(plain.starts_with("[...]"));
349        assert!(plain.contains("Connecting to database"));
350        assert!(plain.contains('s')); // Contains elapsed time with 's'
351    }
352
353    #[test]
354    fn test_spinner_render_styled() {
355        let spinner = IndeterminateSpinner::new("Loading").style(SpinnerStyle::Dots);
356        let styled = spinner.render_styled();
357
358        assert!(styled.contains('['));
359        assert!(styled.contains(']'));
360        assert!(styled.contains("Loading"));
361        assert!(styled.contains('\x1b')); // Contains ANSI codes
362    }
363
364    #[test]
365    fn test_spinner_message_update() {
366        let mut spinner = IndeterminateSpinner::new("Initial");
367        assert_eq!(spinner.message(), "Initial");
368
369        spinner.set_message("Updated");
370        assert_eq!(spinner.message(), "Updated");
371    }
372
373    #[test]
374    fn test_spinner_convert_to_progress() {
375        let spinner = IndeterminateSpinner::new("Processing")
376            .style(SpinnerStyle::Braille)
377            .theme(Theme::default());
378
379        let progress = spinner.into_progress(1000);
380
381        assert_eq!(progress.operation_name(), "Processing");
382        assert_eq!(progress.total_count(), 1000);
383        assert_eq!(progress.completed_count(), 0);
384    }
385
386    #[test]
387    fn test_spinner_json_output() {
388        let spinner = IndeterminateSpinner::new("Test").style(SpinnerStyle::Line);
389        let json = spinner.to_json();
390
391        assert!(json.contains("\"message\":\"Test\""));
392        assert!(json.contains("\"style\":\"line\""));
393        assert!(json.contains("\"elapsed_secs\""));
394        assert!(json.contains("\"frame\""));
395    }
396
397    #[test]
398    fn test_spinner_with_theme() {
399        let theme = Theme::default();
400        let spinner = IndeterminateSpinner::new("Test").theme(theme.clone());
401
402        // Verify styled output uses theme colors
403        let styled = spinner.render_styled();
404        assert!(styled.contains('\x1b')); // Contains ANSI color codes
405    }
406
407    #[test]
408    fn test_spinner_reset_timer() {
409        let mut spinner = IndeterminateSpinner::new("Test");
410        std::thread::sleep(std::time::Duration::from_millis(10));
411
412        let elapsed_before = spinner.elapsed_ms();
413        spinner.reset_timer();
414        let elapsed_after = spinner.elapsed_ms();
415
416        // After reset, elapsed time should be smaller
417        assert!(elapsed_after < elapsed_before);
418    }
419
420    #[test]
421    fn test_format_elapsed_seconds() {
422        assert_eq!(format_elapsed(0.1), "0.1s");
423        assert_eq!(format_elapsed(5.5), "5.5s");
424        assert_eq!(format_elapsed(59.9), "59.9s");
425    }
426
427    #[test]
428    fn test_format_elapsed_minutes() {
429        let result = format_elapsed(90.0);
430        assert!(result.contains('m'));
431        assert!(result.contains('s'));
432    }
433
434    #[test]
435    fn test_format_elapsed_hours() {
436        let result = format_elapsed(3700.0);
437        assert!(result.contains('h'));
438        assert!(result.contains('m'));
439    }
440
441    #[test]
442    fn test_spinner_default_style() {
443        let spinner = IndeterminateSpinner::new("Test");
444        assert_eq!(spinner.current_style(), SpinnerStyle::Dots);
445    }
446
447    #[test]
448    fn test_spinner_braille_animation() {
449        let style = SpinnerStyle::Braille;
450        // Verify braille frames are Unicode braille characters
451        let frames = style.frames();
452        for frame in frames {
453            assert!(frame.chars().all(|c| c.is_alphabetic() || c > '\u{2800}'));
454        }
455    }
456
457    #[test]
458    fn test_spinner_line_animation() {
459        let style = SpinnerStyle::Line;
460        let expected = ["-", "\\", "|", "/"];
461        for (i, frame) in style.frames().iter().enumerate() {
462            assert_eq!(*frame, expected[i]);
463        }
464    }
465
466    #[test]
467    fn test_spinner_arrow_animation() {
468        let style = SpinnerStyle::Arrow;
469        assert_eq!(style.frames().len(), 8);
470        // Verify all arrow frames are single characters
471        for frame in style.frames() {
472            assert_eq!(frame.chars().count(), 1);
473        }
474    }
475}