1use crate::ui::{Icons, OutputContext, ProgressContext};
25use std::sync::Arc;
26use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
27use std::time::{Duration, Instant};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum SpinnerStyle {
32 #[default]
34 Dots,
35 Arrows,
37 Bounce,
39 Ascii,
41}
42
43impl SpinnerStyle {
44 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum SpinnerResult {
73 Success,
75 Error,
77 Skipped,
79}
80
81#[derive(Debug)]
83struct SharedSpinnerState {
84 frame_index: AtomicUsize,
86 #[allow(dead_code)]
88 last_frame_ns: AtomicU64,
89 #[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#[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 progress_state: Option<ProgressBarState>,
150}
151
152#[derive(Debug)]
154struct ProgressBarState {
155 current: u64,
156 total: u64,
157}
158
159impl AnimatedSpinner {
160 pub fn new(ctx: OutputContext, message: impl Into<String>) -> Self {
162 Self::with_style(ctx, message, SpinnerStyle::default())
163 }
164
165 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), last_frame: now,
185 finished: false,
186 progress_state: None,
187 }
188 }
189
190 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 pub fn set_message(&mut self, message: impl Into<String>) {
219 self.message = message.into();
220 }
221
222 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 pub fn set_total(&mut self, total: u64) {
245 self.progress_state = Some(ProgressBarState { current: 0, total });
246 }
247
248 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 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 #[must_use]
266 pub fn elapsed(&self) -> Duration {
267 self.start.elapsed()
268 }
269
270 pub fn finish_success(&mut self, message: impl Into<String>) {
272 self.finish_with(SpinnerResult::Success, message);
273 }
274
275 pub fn finish_error(&mut self, message: impl Into<String>) {
277 self.finish_with(SpinnerResult::Error, message);
278 }
279
280 pub fn finish_skipped(&mut self, message: impl Into<String>) {
282 self.finish_with(SpinnerResult::Skipped, message);
283 }
284
285 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 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 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 if let Some(progress) = &self.progress {
371 progress.clear();
372 }
373 }
374 }
375}
376
377fn 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
395fn 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"); 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 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 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}