1use std::time::{Duration, Instant};
33
34use ratatui::{
35 buffer::Buffer,
36 layout::Rect,
37 style::{Color, Modifier, Style},
38 widgets::Widget,
39};
40use unicode_width::UnicodeWidthStr;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum SpinnerFrames {
45 #[default]
47 Dots,
48 Braille,
50 Line,
52 Circle,
54 Box,
56 Arrow,
58 Bounce,
60 Grow,
62 Clock,
64 Moon,
66 Ascii,
68 Toggle,
70}
71
72impl SpinnerFrames {
73 pub fn frames(&self) -> &'static [&'static str] {
75 match self {
76 SpinnerFrames::Dots => &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
77 SpinnerFrames::Braille => &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"],
78 SpinnerFrames::Line => &["|", "/", "-", "\\"],
79 SpinnerFrames::Circle => &["◐", "◓", "◑", "◒"],
80 SpinnerFrames::Box => &["▖", "▘", "▝", "▗"],
81 SpinnerFrames::Arrow => &["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"],
82 SpinnerFrames::Bounce => &["⠁", "⠂", "⠄", "⠂"],
83 SpinnerFrames::Grow => &["▁", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃"],
84 SpinnerFrames::Clock => &["🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", "🕛"],
85 SpinnerFrames::Moon => &["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"],
86 SpinnerFrames::Ascii => &[".", "o", "O", "@", "*"],
87 SpinnerFrames::Toggle => &["⊶", "⊷"],
88 }
89 }
90
91 pub fn interval_ms(&self) -> u64 {
93 match self {
94 SpinnerFrames::Dots => 80,
95 SpinnerFrames::Braille => 80,
96 SpinnerFrames::Line => 100,
97 SpinnerFrames::Circle => 100,
98 SpinnerFrames::Box => 100,
99 SpinnerFrames::Arrow => 100,
100 SpinnerFrames::Bounce => 120,
101 SpinnerFrames::Grow => 80,
102 SpinnerFrames::Clock => 100,
103 SpinnerFrames::Moon => 150,
104 SpinnerFrames::Ascii => 150,
105 SpinnerFrames::Toggle => 200,
106 }
107 }
108}
109
110#[derive(Debug, Clone)]
112pub struct SpinnerState {
113 pub frame: usize,
115 last_tick: Option<Instant>,
117 interval: Duration,
119 pub active: bool,
121}
122
123impl Default for SpinnerState {
124 fn default() -> Self {
125 Self::new()
126 }
127}
128
129impl SpinnerState {
130 pub fn new() -> Self {
132 Self {
133 frame: 0,
134 last_tick: None,
135 interval: Duration::from_millis(80),
136 active: true,
137 }
138 }
139
140 pub fn with_interval(interval_ms: u64) -> Self {
142 Self {
143 frame: 0,
144 last_tick: None,
145 interval: Duration::from_millis(interval_ms),
146 active: true,
147 }
148 }
149
150 pub fn for_frames(frames: SpinnerFrames) -> Self {
152 Self::with_interval(frames.interval_ms())
153 }
154
155 pub fn set_interval(&mut self, interval_ms: u64) {
157 self.interval = Duration::from_millis(interval_ms);
158 }
159
160 pub fn tick(&mut self) -> bool {
164 self.tick_with_frames(10) }
166
167 pub fn tick_with_frames(&mut self, frame_count: usize) -> bool {
171 if !self.active || frame_count == 0 {
172 return false;
173 }
174
175 let now = Instant::now();
176
177 match self.last_tick {
178 Some(last) if now.duration_since(last) >= self.interval => {
179 self.frame = (self.frame + 1) % frame_count;
180 self.last_tick = Some(now);
181 true
182 }
183 None => {
184 self.last_tick = Some(now);
185 false
186 }
187 _ => false,
188 }
189 }
190
191 pub fn next_frame(&mut self, frame_count: usize) {
193 if frame_count > 0 {
194 self.frame = (self.frame + 1) % frame_count;
195 }
196 }
197
198 pub fn reset(&mut self) {
200 self.frame = 0;
201 self.last_tick = None;
202 }
203
204 pub fn start(&mut self) {
206 self.active = true;
207 }
208
209 pub fn stop(&mut self) {
211 self.active = false;
212 }
213
214 pub fn is_active(&self) -> bool {
216 self.active
217 }
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
222pub enum LabelPosition {
223 Before,
225 #[default]
227 After,
228}
229
230#[derive(Debug, Clone)]
232pub struct SpinnerStyle {
233 pub frames: SpinnerFrames,
235 pub spinner_style: Style,
237 pub label_style: Style,
239 pub label_position: LabelPosition,
241 pub separator: &'static str,
243}
244
245impl Default for SpinnerStyle {
246 fn default() -> Self {
247 Self {
248 frames: SpinnerFrames::Dots,
249 spinner_style: Style::default()
250 .fg(Color::Cyan)
251 .add_modifier(Modifier::BOLD),
252 label_style: Style::default().fg(Color::White),
253 label_position: LabelPosition::After,
254 separator: " ",
255 }
256 }
257}
258
259impl SpinnerStyle {
260 pub fn new(frames: SpinnerFrames) -> Self {
262 Self {
263 frames,
264 ..Default::default()
265 }
266 }
267
268 pub fn frames(mut self, frames: SpinnerFrames) -> Self {
270 self.frames = frames;
271 self
272 }
273
274 pub fn color(mut self, color: Color) -> Self {
276 self.spinner_style = self.spinner_style.fg(color);
277 self
278 }
279
280 pub fn label_style(mut self, style: Style) -> Self {
282 self.label_style = style;
283 self
284 }
285
286 pub fn label_position(mut self, position: LabelPosition) -> Self {
288 self.label_position = position;
289 self
290 }
291
292 pub fn separator(mut self, separator: &'static str) -> Self {
294 self.separator = separator;
295 self
296 }
297
298 pub fn success() -> Self {
300 Self {
301 spinner_style: Style::default()
302 .fg(Color::Green)
303 .add_modifier(Modifier::BOLD),
304 ..Default::default()
305 }
306 }
307
308 pub fn warning() -> Self {
310 Self {
311 spinner_style: Style::default()
312 .fg(Color::Yellow)
313 .add_modifier(Modifier::BOLD),
314 ..Default::default()
315 }
316 }
317
318 pub fn error() -> Self {
320 Self {
321 spinner_style: Style::default()
322 .fg(Color::Red)
323 .add_modifier(Modifier::BOLD),
324 ..Default::default()
325 }
326 }
327
328 pub fn info() -> Self {
330 Self {
331 spinner_style: Style::default()
332 .fg(Color::Blue)
333 .add_modifier(Modifier::BOLD),
334 ..Default::default()
335 }
336 }
337
338 pub fn minimal() -> Self {
340 Self {
341 spinner_style: Style::default().fg(Color::DarkGray),
342 label_style: Style::default().fg(Color::DarkGray),
343 ..Default::default()
344 }
345 }
346}
347
348#[derive(Debug, Clone)]
352pub struct Spinner<'a> {
353 state: &'a SpinnerState,
355 label: Option<&'a str>,
357 style: SpinnerStyle,
359}
360
361impl<'a> Spinner<'a> {
362 pub fn new(state: &'a SpinnerState) -> Self {
364 Self {
365 state,
366 label: None,
367 style: SpinnerStyle::default(),
368 }
369 }
370
371 pub fn label(mut self, label: &'a str) -> Self {
373 self.label = Some(label);
374 self
375 }
376
377 pub fn frames(mut self, frames: SpinnerFrames) -> Self {
379 self.style.frames = frames;
380 self
381 }
382
383 pub fn style(mut self, style: SpinnerStyle) -> Self {
385 self.style = style;
386 self
387 }
388
389 pub fn color(mut self, color: Color) -> Self {
391 self.style.spinner_style = self.style.spinner_style.fg(color);
392 self
393 }
394
395 pub fn label_position(mut self, position: LabelPosition) -> Self {
397 self.style.label_position = position;
398 self
399 }
400
401 fn current_frame(&self) -> &'static str {
403 let frames = self.style.frames.frames();
404 let idx = self.state.frame % frames.len();
405 frames[idx]
406 }
407
408 pub fn display_width(&self) -> usize {
410 let frame_width = self.current_frame().width();
411 match self.label {
412 Some(label) => frame_width + self.style.separator.width() + label.width(),
413 None => frame_width,
414 }
415 }
416}
417
418impl Widget for Spinner<'_> {
419 fn render(self, area: Rect, buf: &mut Buffer) {
420 if area.width == 0 || area.height == 0 {
421 return;
422 }
423
424 let frame = self.current_frame();
425 let mut x = area.x;
426 let y = area.y;
427
428 match (self.label, self.style.label_position) {
429 (Some(label), LabelPosition::Before) => {
430 let label_width = label.width() as u16;
432 if x + label_width <= area.x + area.width {
433 buf.set_string(x, y, label, self.style.label_style);
434 x += label_width;
435 }
436
437 let sep_width = self.style.separator.width() as u16;
438 if x + sep_width <= area.x + area.width {
439 buf.set_string(x, y, self.style.separator, Style::default());
440 x += sep_width;
441 }
442
443 let frame_width = frame.width() as u16;
444 if x + frame_width <= area.x + area.width {
445 buf.set_string(x, y, frame, self.style.spinner_style);
446 }
447 }
448 (Some(label), LabelPosition::After) => {
449 let frame_width = frame.width() as u16;
451 if x + frame_width <= area.x + area.width {
452 buf.set_string(x, y, frame, self.style.spinner_style);
453 x += frame_width;
454 }
455
456 let sep_width = self.style.separator.width() as u16;
457 if x + sep_width <= area.x + area.width {
458 buf.set_string(x, y, self.style.separator, Style::default());
459 x += sep_width;
460 }
461
462 let label_width = label.width() as u16;
463 if x + label_width <= area.x + area.width {
464 buf.set_string(x, y, label, self.style.label_style);
465 }
466 }
467 (None, _) => {
468 buf.set_string(x, y, frame, self.style.spinner_style);
470 }
471 }
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 #[test]
480 fn test_spinner_state_new() {
481 let state = SpinnerState::new();
482 assert_eq!(state.frame, 0);
483 assert!(state.active);
484 }
485
486 #[test]
487 fn test_spinner_state_for_frames() {
488 let state = SpinnerState::for_frames(SpinnerFrames::Braille);
489 assert_eq!(state.interval, Duration::from_millis(80));
490 }
491
492 #[test]
493 fn test_spinner_state_next_frame() {
494 let mut state = SpinnerState::new();
495 assert_eq!(state.frame, 0);
496
497 state.next_frame(5);
498 assert_eq!(state.frame, 1);
499
500 state.next_frame(5);
501 assert_eq!(state.frame, 2);
502
503 state.frame = 4;
505 state.next_frame(5);
506 assert_eq!(state.frame, 0);
507 }
508
509 #[test]
510 fn test_spinner_state_reset() {
511 let mut state = SpinnerState::new();
512 state.frame = 5;
513 state.reset();
514 assert_eq!(state.frame, 0);
515 }
516
517 #[test]
518 fn test_spinner_state_start_stop() {
519 let mut state = SpinnerState::new();
520 assert!(state.is_active());
521
522 state.stop();
523 assert!(!state.is_active());
524
525 state.start();
526 assert!(state.is_active());
527 }
528
529 #[test]
530 fn test_spinner_frames() {
531 assert_eq!(SpinnerFrames::Dots.frames().len(), 10);
532 assert_eq!(SpinnerFrames::Braille.frames().len(), 8);
533 assert_eq!(SpinnerFrames::Line.frames().len(), 4);
534 assert_eq!(SpinnerFrames::Circle.frames().len(), 4);
535 assert_eq!(SpinnerFrames::Arrow.frames().len(), 8);
536 assert_eq!(SpinnerFrames::Clock.frames().len(), 12);
537 assert_eq!(SpinnerFrames::Moon.frames().len(), 8);
538 }
539
540 #[test]
541 fn test_spinner_frames_interval() {
542 assert_eq!(SpinnerFrames::Dots.interval_ms(), 80);
543 assert_eq!(SpinnerFrames::Line.interval_ms(), 100);
544 assert_eq!(SpinnerFrames::Moon.interval_ms(), 150);
545 }
546
547 #[test]
548 fn test_spinner_style_presets() {
549 let success = SpinnerStyle::success();
550 assert_eq!(success.spinner_style.fg, Some(Color::Green));
551
552 let warning = SpinnerStyle::warning();
553 assert_eq!(warning.spinner_style.fg, Some(Color::Yellow));
554
555 let error = SpinnerStyle::error();
556 assert_eq!(error.spinner_style.fg, Some(Color::Red));
557
558 let info = SpinnerStyle::info();
559 assert_eq!(info.spinner_style.fg, Some(Color::Blue));
560 }
561
562 #[test]
563 fn test_spinner_display_width() {
564 let state = SpinnerState::new();
565
566 let spinner = Spinner::new(&state);
567 assert!(spinner.display_width() > 0);
568
569 let spinner_with_label = Spinner::new(&state).label("Loading");
570 assert!(spinner_with_label.display_width() > spinner.display_width());
571 }
572
573 #[test]
574 fn test_spinner_current_frame() {
575 let mut state = SpinnerState::new();
576 let spinner = Spinner::new(&state).frames(SpinnerFrames::Line);
577
578 assert_eq!(spinner.current_frame(), "|");
580
581 state.frame = 1;
582 let spinner = Spinner::new(&state).frames(SpinnerFrames::Line);
583 assert_eq!(spinner.current_frame(), "/");
584
585 state.frame = 2;
586 let spinner = Spinner::new(&state).frames(SpinnerFrames::Line);
587 assert_eq!(spinner.current_frame(), "-");
588
589 state.frame = 3;
590 let spinner = Spinner::new(&state).frames(SpinnerFrames::Line);
591 assert_eq!(spinner.current_frame(), "\\");
592 }
593
594 #[test]
595 fn test_spinner_render() {
596 let state = SpinnerState::new();
597 let spinner = Spinner::new(&state).label("Loading...");
598
599 let mut buf = Buffer::empty(Rect::new(0, 0, 20, 1));
600 spinner.render(Rect::new(0, 0, 20, 1), &mut buf);
601 }
603
604 #[test]
605 fn test_spinner_render_label_before() {
606 let state = SpinnerState::new();
607 let spinner = Spinner::new(&state)
608 .label("Status:")
609 .label_position(LabelPosition::Before);
610
611 let mut buf = Buffer::empty(Rect::new(0, 0, 20, 1));
612 spinner.render(Rect::new(0, 0, 20, 1), &mut buf);
613 }
615
616 #[test]
617 fn test_spinner_render_empty_area() {
618 let state = SpinnerState::new();
619 let spinner = Spinner::new(&state);
620
621 let mut buf = Buffer::empty(Rect::new(0, 0, 0, 0));
622 spinner.render(Rect::new(0, 0, 0, 0), &mut buf);
623 }
625}