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 "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", "🕛",
86 ],
87 SpinnerFrames::Moon => &["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"],
88 SpinnerFrames::Ascii => &[".", "o", "O", "@", "*"],
89 SpinnerFrames::Toggle => &["⊶", "⊷"],
90 }
91 }
92
93 pub fn interval_ms(&self) -> u64 {
95 match self {
96 SpinnerFrames::Dots => 80,
97 SpinnerFrames::Braille => 80,
98 SpinnerFrames::Line => 100,
99 SpinnerFrames::Circle => 100,
100 SpinnerFrames::Box => 100,
101 SpinnerFrames::Arrow => 100,
102 SpinnerFrames::Bounce => 120,
103 SpinnerFrames::Grow => 80,
104 SpinnerFrames::Clock => 100,
105 SpinnerFrames::Moon => 150,
106 SpinnerFrames::Ascii => 150,
107 SpinnerFrames::Toggle => 200,
108 }
109 }
110}
111
112#[derive(Debug, Clone)]
114pub struct SpinnerState {
115 pub frame: usize,
117 last_tick: Option<Instant>,
119 interval: Duration,
121 pub active: bool,
123}
124
125impl Default for SpinnerState {
126 fn default() -> Self {
127 Self::new()
128 }
129}
130
131impl SpinnerState {
132 pub fn new() -> Self {
134 Self {
135 frame: 0,
136 last_tick: None,
137 interval: Duration::from_millis(80),
138 active: true,
139 }
140 }
141
142 pub fn with_interval(interval_ms: u64) -> Self {
144 Self {
145 frame: 0,
146 last_tick: None,
147 interval: Duration::from_millis(interval_ms),
148 active: true,
149 }
150 }
151
152 pub fn for_frames(frames: SpinnerFrames) -> Self {
154 Self::with_interval(frames.interval_ms())
155 }
156
157 pub fn set_interval(&mut self, interval_ms: u64) {
159 self.interval = Duration::from_millis(interval_ms);
160 }
161
162 pub fn tick(&mut self) -> bool {
166 self.tick_with_frames(10) }
168
169 pub fn tick_with_frames(&mut self, frame_count: usize) -> bool {
173 if !self.active || frame_count == 0 {
174 return false;
175 }
176
177 let now = Instant::now();
178
179 match self.last_tick {
180 Some(last) if now.duration_since(last) >= self.interval => {
181 self.frame = (self.frame + 1) % frame_count;
182 self.last_tick = Some(now);
183 true
184 }
185 None => {
186 self.last_tick = Some(now);
187 false
188 }
189 _ => false,
190 }
191 }
192
193 pub fn next_frame(&mut self, frame_count: usize) {
195 if frame_count > 0 {
196 self.frame = (self.frame + 1) % frame_count;
197 }
198 }
199
200 pub fn reset(&mut self) {
202 self.frame = 0;
203 self.last_tick = None;
204 }
205
206 pub fn start(&mut self) {
208 self.active = true;
209 }
210
211 pub fn stop(&mut self) {
213 self.active = false;
214 }
215
216 pub fn is_active(&self) -> bool {
218 self.active
219 }
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
224pub enum LabelPosition {
225 Before,
227 #[default]
229 After,
230}
231
232#[derive(Debug, Clone)]
234pub struct SpinnerStyle {
235 pub frames: SpinnerFrames,
237 pub spinner_style: Style,
239 pub label_style: Style,
241 pub label_position: LabelPosition,
243 pub separator: &'static str,
245}
246
247impl Default for SpinnerStyle {
248 fn default() -> Self {
249 Self {
250 frames: SpinnerFrames::Dots,
251 spinner_style: Style::default()
252 .fg(Color::Cyan)
253 .add_modifier(Modifier::BOLD),
254 label_style: Style::default().fg(Color::White),
255 label_position: LabelPosition::After,
256 separator: " ",
257 }
258 }
259}
260
261impl From<&crate::theme::Theme> for SpinnerStyle {
262 fn from(theme: &crate::theme::Theme) -> Self {
263 let p = &theme.palette;
264 Self {
265 frames: SpinnerFrames::Dots,
266 spinner_style: Style::default()
267 .fg(p.secondary)
268 .add_modifier(Modifier::BOLD),
269 label_style: Style::default().fg(p.text),
270 label_position: LabelPosition::After,
271 separator: " ",
272 }
273 }
274}
275
276impl SpinnerStyle {
277 pub fn new(frames: SpinnerFrames) -> Self {
279 Self {
280 frames,
281 ..Default::default()
282 }
283 }
284
285 pub fn frames(mut self, frames: SpinnerFrames) -> Self {
287 self.frames = frames;
288 self
289 }
290
291 pub fn color(mut self, color: Color) -> Self {
293 self.spinner_style = self.spinner_style.fg(color);
294 self
295 }
296
297 pub fn label_style(mut self, style: Style) -> Self {
299 self.label_style = style;
300 self
301 }
302
303 pub fn label_position(mut self, position: LabelPosition) -> Self {
305 self.label_position = position;
306 self
307 }
308
309 pub fn separator(mut self, separator: &'static str) -> Self {
311 self.separator = separator;
312 self
313 }
314
315 pub fn success() -> Self {
317 Self {
318 spinner_style: Style::default()
319 .fg(Color::Green)
320 .add_modifier(Modifier::BOLD),
321 ..Default::default()
322 }
323 }
324
325 pub fn warning() -> Self {
327 Self {
328 spinner_style: Style::default()
329 .fg(Color::Yellow)
330 .add_modifier(Modifier::BOLD),
331 ..Default::default()
332 }
333 }
334
335 pub fn error() -> Self {
337 Self {
338 spinner_style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
339 ..Default::default()
340 }
341 }
342
343 pub fn info() -> Self {
345 Self {
346 spinner_style: Style::default()
347 .fg(Color::Blue)
348 .add_modifier(Modifier::BOLD),
349 ..Default::default()
350 }
351 }
352
353 pub fn minimal() -> Self {
355 Self {
356 spinner_style: Style::default().fg(Color::DarkGray),
357 label_style: Style::default().fg(Color::DarkGray),
358 ..Default::default()
359 }
360 }
361}
362
363#[derive(Debug, Clone)]
367pub struct Spinner<'a> {
368 state: &'a SpinnerState,
370 label: Option<&'a str>,
372 style: SpinnerStyle,
374}
375
376impl<'a> Spinner<'a> {
377 pub fn new(state: &'a SpinnerState) -> Self {
379 Self {
380 state,
381 label: None,
382 style: SpinnerStyle::default(),
383 }
384 }
385
386 pub fn label(mut self, label: &'a str) -> Self {
388 self.label = Some(label);
389 self
390 }
391
392 pub fn frames(mut self, frames: SpinnerFrames) -> Self {
394 self.style.frames = frames;
395 self
396 }
397
398 pub fn style(mut self, style: SpinnerStyle) -> Self {
400 self.style = style;
401 self
402 }
403
404 pub fn theme(self, theme: &crate::theme::Theme) -> Self {
406 self.style(SpinnerStyle::from(theme))
407 }
408
409 pub fn color(mut self, color: Color) -> Self {
411 self.style.spinner_style = self.style.spinner_style.fg(color);
412 self
413 }
414
415 pub fn label_position(mut self, position: LabelPosition) -> Self {
417 self.style.label_position = position;
418 self
419 }
420
421 fn current_frame(&self) -> &'static str {
423 let frames = self.style.frames.frames();
424 let idx = self.state.frame % frames.len();
425 frames[idx]
426 }
427
428 pub fn display_width(&self) -> usize {
430 let frame_width = self.current_frame().width();
431 match self.label {
432 Some(label) => frame_width + self.style.separator.width() + label.width(),
433 None => frame_width,
434 }
435 }
436}
437
438impl Widget for Spinner<'_> {
439 fn render(self, area: Rect, buf: &mut Buffer) {
440 if area.width == 0 || area.height == 0 {
441 return;
442 }
443
444 let frame = self.current_frame();
445 let mut x = area.x;
446 let y = area.y;
447
448 match (self.label, self.style.label_position) {
449 (Some(label), LabelPosition::Before) => {
450 let label_width = label.width() as u16;
452 if x + label_width <= area.x + area.width {
453 buf.set_string(x, y, label, self.style.label_style);
454 x += label_width;
455 }
456
457 let sep_width = self.style.separator.width() as u16;
458 if x + sep_width <= area.x + area.width {
459 buf.set_string(x, y, self.style.separator, Style::default());
460 x += sep_width;
461 }
462
463 let frame_width = frame.width() as u16;
464 if x + frame_width <= area.x + area.width {
465 buf.set_string(x, y, frame, self.style.spinner_style);
466 }
467 }
468 (Some(label), LabelPosition::After) => {
469 let frame_width = frame.width() as u16;
471 if x + frame_width <= area.x + area.width {
472 buf.set_string(x, y, frame, self.style.spinner_style);
473 x += frame_width;
474 }
475
476 let sep_width = self.style.separator.width() as u16;
477 if x + sep_width <= area.x + area.width {
478 buf.set_string(x, y, self.style.separator, Style::default());
479 x += sep_width;
480 }
481
482 let label_width = label.width() as u16;
483 if x + label_width <= area.x + area.width {
484 buf.set_string(x, y, label, self.style.label_style);
485 }
486 }
487 (None, _) => {
488 buf.set_string(x, y, frame, self.style.spinner_style);
490 }
491 }
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498
499 #[test]
500 fn test_spinner_state_new() {
501 let state = SpinnerState::new();
502 assert_eq!(state.frame, 0);
503 assert!(state.active);
504 }
505
506 #[test]
507 fn test_spinner_state_for_frames() {
508 let state = SpinnerState::for_frames(SpinnerFrames::Braille);
509 assert_eq!(state.interval, Duration::from_millis(80));
510 }
511
512 #[test]
513 fn test_spinner_state_next_frame() {
514 let mut state = SpinnerState::new();
515 assert_eq!(state.frame, 0);
516
517 state.next_frame(5);
518 assert_eq!(state.frame, 1);
519
520 state.next_frame(5);
521 assert_eq!(state.frame, 2);
522
523 state.frame = 4;
525 state.next_frame(5);
526 assert_eq!(state.frame, 0);
527 }
528
529 #[test]
530 fn test_spinner_state_reset() {
531 let mut state = SpinnerState::new();
532 state.frame = 5;
533 state.reset();
534 assert_eq!(state.frame, 0);
535 }
536
537 #[test]
538 fn test_spinner_state_start_stop() {
539 let mut state = SpinnerState::new();
540 assert!(state.is_active());
541
542 state.stop();
543 assert!(!state.is_active());
544
545 state.start();
546 assert!(state.is_active());
547 }
548
549 #[test]
550 fn test_spinner_frames() {
551 assert_eq!(SpinnerFrames::Dots.frames().len(), 10);
552 assert_eq!(SpinnerFrames::Braille.frames().len(), 8);
553 assert_eq!(SpinnerFrames::Line.frames().len(), 4);
554 assert_eq!(SpinnerFrames::Circle.frames().len(), 4);
555 assert_eq!(SpinnerFrames::Arrow.frames().len(), 8);
556 assert_eq!(SpinnerFrames::Clock.frames().len(), 12);
557 assert_eq!(SpinnerFrames::Moon.frames().len(), 8);
558 }
559
560 #[test]
561 fn test_spinner_frames_interval() {
562 assert_eq!(SpinnerFrames::Dots.interval_ms(), 80);
563 assert_eq!(SpinnerFrames::Line.interval_ms(), 100);
564 assert_eq!(SpinnerFrames::Moon.interval_ms(), 150);
565 }
566
567 #[test]
568 fn test_spinner_style_presets() {
569 let success = SpinnerStyle::success();
570 assert_eq!(success.spinner_style.fg, Some(Color::Green));
571
572 let warning = SpinnerStyle::warning();
573 assert_eq!(warning.spinner_style.fg, Some(Color::Yellow));
574
575 let error = SpinnerStyle::error();
576 assert_eq!(error.spinner_style.fg, Some(Color::Red));
577
578 let info = SpinnerStyle::info();
579 assert_eq!(info.spinner_style.fg, Some(Color::Blue));
580 }
581
582 #[test]
583 fn test_spinner_display_width() {
584 let state = SpinnerState::new();
585
586 let spinner = Spinner::new(&state);
587 assert!(spinner.display_width() > 0);
588
589 let spinner_with_label = Spinner::new(&state).label("Loading");
590 assert!(spinner_with_label.display_width() > spinner.display_width());
591 }
592
593 #[test]
594 fn test_spinner_current_frame() {
595 let mut state = SpinnerState::new();
596 let spinner = Spinner::new(&state).frames(SpinnerFrames::Line);
597
598 assert_eq!(spinner.current_frame(), "|");
600
601 state.frame = 1;
602 let spinner = Spinner::new(&state).frames(SpinnerFrames::Line);
603 assert_eq!(spinner.current_frame(), "/");
604
605 state.frame = 2;
606 let spinner = Spinner::new(&state).frames(SpinnerFrames::Line);
607 assert_eq!(spinner.current_frame(), "-");
608
609 state.frame = 3;
610 let spinner = Spinner::new(&state).frames(SpinnerFrames::Line);
611 assert_eq!(spinner.current_frame(), "\\");
612 }
613
614 #[test]
615 fn test_spinner_render() {
616 let state = SpinnerState::new();
617 let spinner = Spinner::new(&state).label("Loading...");
618
619 let mut buf = Buffer::empty(Rect::new(0, 0, 20, 1));
620 spinner.render(Rect::new(0, 0, 20, 1), &mut buf);
621 }
623
624 #[test]
625 fn test_spinner_render_label_before() {
626 let state = SpinnerState::new();
627 let spinner = Spinner::new(&state)
628 .label("Status:")
629 .label_position(LabelPosition::Before);
630
631 let mut buf = Buffer::empty(Rect::new(0, 0, 20, 1));
632 spinner.render(Rect::new(0, 0, 20, 1), &mut buf);
633 }
635
636 #[test]
637 fn test_spinner_render_empty_area() {
638 let state = SpinnerState::new();
639 let spinner = Spinner::new(&state);
640
641 let mut buf = Buffer::empty(Rect::new(0, 0, 0, 0));
642 spinner.render(Rect::new(0, 0, 0, 0), &mut buf);
643 }
645}