Skip to main content

rch_common/ui/progress/
spinner.rs

1//! AnimatedSpinner - Indeterminate progress display for unknown-duration operations.
2//!
3//! Provides animated spinners with:
4//! - Multiple spinner styles: dots, braille, arrows, bouncing bar
5//! - Message alongside spinner with elapsed time
6//! - Spinner-to-progress-bar transition when total becomes known
7//! - Success/failure final state replacement
8//! - Nested spinners for sub-operations
9//!
10//! # Example
11//!
12//! ```ignore
13//! use rch_common::ui::{OutputContext, AnimatedSpinner, SpinnerStyle};
14//!
15//! let ctx = OutputContext::detect();
16//! let mut spinner = AnimatedSpinner::new(ctx, "Connecting to worker...");
17//!
18//! // Later, when operation completes:
19//! spinner.finish_success("Connected to worker1");
20//! // Or on failure:
21//! spinner.finish_error("Connection refused");
22//! ```
23
24use crate::ui::{Icons, OutputContext, ProgressContext};
25use std::sync::Arc;
26use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
27use std::time::{Duration, Instant};
28
29/// Spinner animation style.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum SpinnerStyle {
32    /// Unicode braille dots animation (⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏).
33    #[default]
34    Dots,
35    /// Arrow rotation (← ↖ ↑ ↗ → ↘ ↓ ↙).
36    Arrows,
37    /// Bouncing bar animation ([=    ] [==   ] ...).
38    Bounce,
39    /// Simple ASCII rotation (| / - \).
40    Ascii,
41}
42
43impl SpinnerStyle {
44    /// Get the animation frames for this style.
45    fn frames(self, supports_unicode: bool) -> &'static [&'static str] {
46        if !supports_unicode {
47            return Self::ASCII_FRAMES;
48        }
49
50        match self {
51            Self::Dots => Self::DOTS_FRAMES,
52            Self::Arrows => Self::ARROWS_FRAMES,
53            Self::Bounce => Self::BOUNCE_FRAMES,
54            Self::Ascii => Self::ASCII_FRAMES,
55        }
56    }
57
58    const DOTS_FRAMES: &'static [&'static str] =
59        &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
60
61    const ARROWS_FRAMES: &'static [&'static str] = &["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"];
62
63    const BOUNCE_FRAMES: &'static [&'static str] = &[
64        "[=    ]", "[ =   ]", "[  =  ]", "[   = ]", "[    =]", "[   = ]", "[  =  ]", "[ =   ]",
65    ];
66
67    const ASCII_FRAMES: &'static [&'static str] = &["|", "/", "-", "\\"];
68}
69
70/// Final state of a spinner operation.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum SpinnerResult {
73    /// Operation completed successfully.
74    Success,
75    /// Operation failed.
76    Error,
77    /// Operation was cancelled/skipped.
78    Skipped,
79}
80
81/// Shared state for nested spinners (thread-safe).
82#[derive(Debug)]
83struct SharedSpinnerState {
84    /// Current frame index across all spinners.
85    frame_index: AtomicUsize,
86    /// Last frame update time (nanoseconds since epoch).
87    #[allow(dead_code)]
88    last_frame_ns: AtomicU64,
89    /// Number of active spinners.
90    #[allow(dead_code)]
91    active_count: AtomicUsize,
92}
93
94impl SharedSpinnerState {
95    fn new() -> Self {
96        Self {
97            frame_index: AtomicUsize::new(0),
98            last_frame_ns: AtomicU64::new(0),
99            active_count: AtomicUsize::new(0),
100        }
101    }
102
103    fn next_frame(&self, frame_count: usize) -> usize {
104        let idx = self.frame_index.fetch_add(1, Ordering::Relaxed);
105        idx % frame_count
106    }
107}
108
109/// Animated spinner for indeterminate progress.
110///
111/// Displays an animated spinner with a message and elapsed time.
112/// Thread-safe and uses RAII for clean terminal state on drop.
113///
114/// # Features
115///
116/// - Automatic rate limiting (10 FPS / 100ms per frame)
117/// - Multiple animation styles
118/// - Elapsed time display
119/// - Success/failure/skipped final states
120/// - Transition to progress bar when total becomes known
121///
122/// # Example
123///
124/// ```ignore
125/// let mut spinner = AnimatedSpinner::new(ctx, "Connecting to worker...");
126///
127/// // Animate while working...
128/// for _ in 0..100 {
129///     spinner.tick(); // Call periodically to animate
130///     std::thread::sleep(std::time::Duration::from_millis(50));
131/// }
132///
133/// // When done:
134/// spinner.finish_success("Connected!");
135/// ```
136#[derive(Debug)]
137pub struct AnimatedSpinner {
138    ctx: OutputContext,
139    style: SpinnerStyle,
140    message: String,
141    enabled: bool,
142    progress: Option<ProgressContext>,
143    start: Instant,
144    shared_state: Arc<SharedSpinnerState>,
145    frame_interval: Duration,
146    last_frame: Instant,
147    finished: bool,
148    /// Optional progress bar state for transition.
149    progress_state: Option<ProgressBarState>,
150}
151
152/// State for spinner-to-progress-bar transition.
153#[derive(Debug)]
154struct ProgressBarState {
155    current: u64,
156    total: u64,
157}
158
159impl AnimatedSpinner {
160    /// Create a new animated spinner.
161    pub fn new(ctx: OutputContext, message: impl Into<String>) -> Self {
162        Self::with_style(ctx, message, SpinnerStyle::default())
163    }
164
165    /// Create a spinner with a specific animation style.
166    pub fn with_style(ctx: OutputContext, message: impl Into<String>, style: SpinnerStyle) -> Self {
167        let enabled = !ctx.is_machine();
168        let progress = if enabled && matches!(ctx, OutputContext::Interactive) {
169            Some(ProgressContext::new(ctx))
170        } else {
171            None
172        };
173
174        let now = Instant::now();
175        Self {
176            ctx,
177            style,
178            message: message.into(),
179            enabled,
180            progress,
181            start: now,
182            shared_state: Arc::new(SharedSpinnerState::new()),
183            frame_interval: Duration::from_millis(100), // 10 FPS
184            last_frame: now,
185            finished: false,
186            progress_state: None,
187        }
188    }
189
190    /// Create a nested spinner that shares animation state with parent.
191    ///
192    /// Nested spinners synchronize their animation frames for visual coherence.
193    pub fn nested(&self, message: impl Into<String>) -> Self {
194        let enabled = self.enabled;
195        let progress = if enabled && matches!(self.ctx, OutputContext::Interactive) {
196            Some(ProgressContext::new(self.ctx))
197        } else {
198            None
199        };
200
201        let now = Instant::now();
202        Self {
203            ctx: self.ctx,
204            style: self.style,
205            message: message.into(),
206            enabled,
207            progress,
208            start: now,
209            shared_state: Arc::clone(&self.shared_state),
210            frame_interval: self.frame_interval,
211            last_frame: now,
212            finished: false,
213            progress_state: None,
214        }
215    }
216
217    /// Set the spinner message.
218    pub fn set_message(&mut self, message: impl Into<String>) {
219        self.message = message.into();
220    }
221
222    /// Update the spinner animation.
223    ///
224    /// Call this periodically while the operation is in progress.
225    /// Rate-limited to 10 FPS automatically.
226    pub fn tick(&mut self) {
227        if !self.enabled || self.finished {
228            return;
229        }
230
231        let now = Instant::now();
232        if now.duration_since(self.last_frame) < self.frame_interval {
233            return;
234        }
235        self.last_frame = now;
236
237        self.render();
238    }
239
240    /// Transition the spinner to a progress bar.
241    ///
242    /// When the total count becomes known, the spinner transforms into
243    /// a determinate progress bar.
244    pub fn set_total(&mut self, total: u64) {
245        self.progress_state = Some(ProgressBarState { current: 0, total });
246    }
247
248    /// Update progress when in progress bar mode.
249    pub fn set_progress(&mut self, current: u64) {
250        if let Some(state) = &mut self.progress_state {
251            state.current = current;
252        }
253        self.tick();
254    }
255
256    /// Increment progress by one.
257    pub fn inc(&mut self) {
258        if let Some(state) = &mut self.progress_state {
259            state.current = state.current.saturating_add(1);
260        }
261        self.tick();
262    }
263
264    /// Get elapsed time since spinner started.
265    #[must_use]
266    pub fn elapsed(&self) -> Duration {
267        self.start.elapsed()
268    }
269
270    /// Finish with success state.
271    pub fn finish_success(&mut self, message: impl Into<String>) {
272        self.finish_with(SpinnerResult::Success, message);
273    }
274
275    /// Finish with error state.
276    pub fn finish_error(&mut self, message: impl Into<String>) {
277        self.finish_with(SpinnerResult::Error, message);
278    }
279
280    /// Finish with skipped state.
281    pub fn finish_skipped(&mut self, message: impl Into<String>) {
282        self.finish_with(SpinnerResult::Skipped, message);
283    }
284
285    /// Finish with a specific result state.
286    pub fn finish_with(&mut self, result: SpinnerResult, message: impl Into<String>) {
287        if self.finished {
288            return;
289        }
290        self.finished = true;
291
292        if let Some(progress) = &self.progress {
293            progress.clear();
294        }
295
296        if !self.enabled {
297            return;
298        }
299
300        let icon = match result {
301            SpinnerResult::Success => Icons::check(self.ctx),
302            SpinnerResult::Error => Icons::cross(self.ctx),
303            SpinnerResult::Skipped => Icons::arrow_right(self.ctx),
304        };
305
306        let elapsed = format_duration(self.elapsed());
307        let msg = message.into();
308
309        eprintln!("{icon} {msg}  {elapsed}");
310    }
311
312    /// Clear the spinner without printing a final message.
313    pub fn clear(&mut self) {
314        self.finished = true;
315        if let Some(progress) = &self.progress {
316            progress.clear();
317        }
318    }
319
320    fn render(&mut self) {
321        if !self.enabled {
322            return;
323        }
324
325        // Check if we should render as progress bar
326        if let Some(state) = &self.progress_state {
327            self.render_progress_bar(state.current, state.total);
328            return;
329        }
330
331        let supports_unicode = self.ctx.supports_unicode();
332        let frames = self.style.frames(supports_unicode);
333        let frame_idx = self.shared_state.next_frame(frames.len());
334        let frame = frames[frame_idx];
335
336        let elapsed = format_duration(self.elapsed());
337        let line = format!("{frame} {}  {elapsed}", self.message);
338
339        if let Some(progress) = &mut self.progress {
340            progress.render(&line);
341        }
342    }
343
344    fn render_progress_bar(&mut self, current: u64, total: u64) {
345        if !self.enabled {
346            return;
347        }
348
349        let percent = if total > 0 {
350            (current as f64 / total as f64).clamp(0.0, 1.0)
351        } else {
352            0.0
353        };
354
355        let bar = render_bar(self.ctx, percent, 20);
356        let pct = (percent * 100.0).round() as u32;
357        let elapsed = format_duration(self.elapsed());
358        let line = format!("{bar} {pct}% | {} | {elapsed}", self.message);
359
360        if let Some(progress) = &mut self.progress {
361            progress.render(&line);
362        }
363    }
364}
365
366impl Drop for AnimatedSpinner {
367    fn drop(&mut self) {
368        if !self.finished {
369            // Clean up without printing if dropped before finish
370            if let Some(progress) = &self.progress {
371                progress.clear();
372            }
373        }
374    }
375}
376
377/// Render a simple progress bar.
378fn render_bar(ctx: OutputContext, percent: f64, width: usize) -> String {
379    let filled = (percent * width as f64).round() as usize;
380    let empty = width.saturating_sub(filled);
381
382    let (filled_char, empty_char) = if ctx.supports_unicode() {
383        ("█", "░")
384    } else {
385        ("#", "-")
386    };
387
388    let mut bar = String::from("[");
389    bar.push_str(&filled_char.repeat(filled));
390    bar.push_str(&empty_char.repeat(empty));
391    bar.push(']');
392    bar
393}
394
395/// Format a duration for display.
396fn format_duration(duration: Duration) -> String {
397    let total_secs = duration.as_secs_f64();
398    if total_secs < 60.0 {
399        format!("{total_secs:.1}s")
400    } else {
401        let mins = (total_secs / 60.0).floor() as u64;
402        let secs = (total_secs % 60.0).round() as u64;
403        format!("{mins}:{secs:02}")
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn spinner_style_frames_count() {
413        assert_eq!(SpinnerStyle::DOTS_FRAMES.len(), 10);
414        assert_eq!(SpinnerStyle::ARROWS_FRAMES.len(), 8);
415        assert_eq!(SpinnerStyle::BOUNCE_FRAMES.len(), 8);
416        assert_eq!(SpinnerStyle::ASCII_FRAMES.len(), 4);
417    }
418
419    #[test]
420    fn spinner_style_ascii_fallback() {
421        let dots = SpinnerStyle::Dots;
422        let ascii_frames = dots.frames(false);
423        assert_eq!(ascii_frames, SpinnerStyle::ASCII_FRAMES);
424    }
425
426    #[test]
427    fn spinner_style_unicode_frames() {
428        let dots = SpinnerStyle::Dots;
429        let frames = dots.frames(true);
430        assert_eq!(frames, SpinnerStyle::DOTS_FRAMES);
431    }
432
433    #[test]
434    fn spinner_creates_with_message() {
435        let ctx = OutputContext::Plain;
436        let spinner = AnimatedSpinner::new(ctx, "Testing...");
437        assert_eq!(spinner.message, "Testing...");
438        assert!(!spinner.finished);
439    }
440
441    #[test]
442    fn spinner_disabled_in_machine_mode() {
443        let ctx = OutputContext::Machine;
444        let spinner = AnimatedSpinner::new(ctx, "Testing...");
445        assert!(!spinner.enabled);
446    }
447
448    #[test]
449    fn spinner_set_message() {
450        let ctx = OutputContext::Plain;
451        let mut spinner = AnimatedSpinner::new(ctx, "Initial");
452        spinner.set_message("Updated");
453        assert_eq!(spinner.message, "Updated");
454    }
455
456    #[test]
457    fn spinner_elapsed_increases() {
458        let ctx = OutputContext::Plain;
459        let spinner = AnimatedSpinner::new(ctx, "Testing...");
460        std::thread::sleep(Duration::from_millis(10));
461        assert!(spinner.elapsed() >= Duration::from_millis(10));
462    }
463
464    #[test]
465    fn spinner_finish_marks_finished() {
466        let ctx = OutputContext::Plain;
467        let mut spinner = AnimatedSpinner::new(ctx, "Testing...");
468        assert!(!spinner.finished);
469        spinner.finish_success("Done!");
470        assert!(spinner.finished);
471    }
472
473    #[test]
474    fn spinner_finish_idempotent() {
475        let ctx = OutputContext::Plain;
476        let mut spinner = AnimatedSpinner::new(ctx, "Testing...");
477        spinner.finish_success("Done 1");
478        spinner.finish_success("Done 2"); // Should not panic or double-print
479        assert!(spinner.finished);
480    }
481
482    #[test]
483    fn spinner_clear() {
484        let ctx = OutputContext::Plain;
485        let mut spinner = AnimatedSpinner::new(ctx, "Testing...");
486        spinner.clear();
487        assert!(spinner.finished);
488    }
489
490    #[test]
491    fn spinner_nested_shares_state() {
492        let ctx = OutputContext::Plain;
493        let parent = AnimatedSpinner::new(ctx, "Parent");
494        let child = parent.nested("Child");
495
496        // Both should reference the same shared state
497        assert!(Arc::ptr_eq(&parent.shared_state, &child.shared_state));
498    }
499
500    #[test]
501    fn spinner_progress_transition() {
502        let ctx = OutputContext::Plain;
503        let mut spinner = AnimatedSpinner::new(ctx, "Processing...");
504
505        assert!(spinner.progress_state.is_none());
506
507        spinner.set_total(100);
508        assert!(spinner.progress_state.is_some());
509        assert_eq!(spinner.progress_state.as_ref().unwrap().total, 100);
510        assert_eq!(spinner.progress_state.as_ref().unwrap().current, 0);
511
512        spinner.set_progress(50);
513        assert_eq!(spinner.progress_state.as_ref().unwrap().current, 50);
514
515        spinner.inc();
516        assert_eq!(spinner.progress_state.as_ref().unwrap().current, 51);
517    }
518
519    #[test]
520    fn format_duration_seconds() {
521        let dur = Duration::from_secs_f64(5.7);
522        assert_eq!(format_duration(dur), "5.7s");
523    }
524
525    #[test]
526    fn format_duration_minutes() {
527        let dur = Duration::from_secs(125);
528        assert_eq!(format_duration(dur), "2:05");
529    }
530
531    #[test]
532    fn render_bar_empty() {
533        let bar = render_bar(OutputContext::Plain, 0.0, 10);
534        assert_eq!(bar, "[----------]");
535    }
536
537    #[test]
538    fn render_bar_full() {
539        let bar = render_bar(OutputContext::Plain, 1.0, 10);
540        assert_eq!(bar, "[##########]");
541    }
542
543    #[test]
544    fn render_bar_half() {
545        let bar = render_bar(OutputContext::Plain, 0.5, 10);
546        assert_eq!(bar, "[#####-----]");
547    }
548
549    #[test]
550    fn shared_state_next_frame_wraps() {
551        let state = SharedSpinnerState::new();
552
553        // Advance past frame count
554        for i in 0..15 {
555            let frame = state.next_frame(4);
556            assert!(frame < 4, "Frame {frame} at iteration {i} should be < 4");
557        }
558    }
559
560    #[test]
561    fn spinner_result_variants() {
562        assert_ne!(SpinnerResult::Success, SpinnerResult::Error);
563        assert_ne!(SpinnerResult::Error, SpinnerResult::Skipped);
564        assert_ne!(SpinnerResult::Success, SpinnerResult::Skipped);
565    }
566
567    #[test]
568    fn spinner_style_default_is_dots() {
569        assert_eq!(SpinnerStyle::default(), SpinnerStyle::Dots);
570    }
571}