1use crate::console::{Console, ConsoleOptions};
12use crate::protocol::Renderable;
13use crate::segment::Segment;
14use crate::style::Style;
15use crate::text::Text;
16
17pub struct Spinner {
19 frames: &'static [&'static str],
20 interval: f64,
22 text: String,
23 speed: f64,
24 style: Option<Style>,
25}
26
27impl Spinner {
28 pub fn new(name: &str) -> Self {
30 let (interval, frames) = crate::spinner_data::spinner_data(name)
31 .or_else(|| crate::spinner_data::spinner_data("dots"))
32 .expect("dots spinner exists");
33 Spinner {
34 frames,
35 interval,
36 text: String::new(),
37 speed: 1.0,
38 style: None,
39 }
40 }
41
42 pub fn text(mut self, text: impl Into<String>) -> Self {
44 self.text = text.into();
45 self
46 }
47
48 pub fn speed(mut self, speed: f64) -> Self {
50 self.speed = speed;
51 self
52 }
53
54 pub fn style(mut self, style: Style) -> Self {
56 self.style = Some(style);
57 self
58 }
59
60 fn frame_index(&self, time: f64) -> usize {
62 let interval_secs = self.interval / 1000.0;
63 ((time * self.speed / interval_secs) as usize) % self.frames.len()
64 }
65
66 pub fn render(&self, time: f64) -> Text {
70 let frame = self.frames[self.frame_index(time)];
71 let mut text = if self.text.is_empty() {
72 Text::new(frame)
73 } else {
74 Text::new(format!("{frame} {}", self.text))
75 };
76 if let Some(style) = &self.style {
77 text.stylize(style.clone(), 0, frame.len());
78 }
79 text
80 }
81}
82
83impl Renderable for Spinner {
84 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
85 self.render(0.0).rich_render(console, options)
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93 use crate::color::ColorSystem;
94
95 fn frame_at(name: &str, time: f64) -> String {
96 let console = Console::builder()
97 .force_terminal(true)
98 .color_system(Some(ColorSystem::Truecolor))
99 .width(20)
100 .build();
101 console.render_to_string(&Spinner::new(name).render(time))
102 }
103
104 #[test]
105 fn dots_frames_match_upstream() {
106 assert_eq!(frame_at("dots", 0.0), "⠋");
108 assert_eq!(frame_at("dots", 0.1), "⠙");
109 assert_eq!(frame_at("dots", 0.25), "⠸");
110 }
111
112 #[test]
113 fn line_frames_match_upstream() {
114 assert_eq!(frame_at("line", 0.0), "-");
115 assert_eq!(frame_at("line", 0.1), "-");
116 assert_eq!(frame_at("line", 0.25), "\\");
117 }
118
119 #[test]
120 fn full_table_covers_more_spinners() {
121 assert_eq!(frame_at("moon", 0.0), "\u{1f311} ");
123 assert_eq!(frame_at("moon", 0.08), "\u{1f312} ");
124 assert_eq!(frame_at("bounce", 0.0), "\u{2801}");
125 }
126
127 #[test]
128 fn arrow_and_dots2_match_upstream() {
129 assert_eq!(frame_at("arrow", 0.0), "←");
131 assert_eq!(frame_at("arrow", 0.1), "↖");
132 assert_eq!(frame_at("dots2", 0.0), "⣾");
133 }
134
135 #[test]
136 fn text_follows_frame() {
137 let console = Console::builder()
138 .force_terminal(true)
139 .color_system(Some(ColorSystem::Truecolor))
140 .width(20)
141 .build();
142 let out = console.render_to_string(&Spinner::new("dots").text("Working").render(0.0));
143 assert_eq!(out, "⠋ Working");
144 }
145
146 #[test]
147 fn styled_frame_only() {
148 let console = Console::builder()
150 .force_terminal(true)
151 .color_system(Some(ColorSystem::Truecolor))
152 .width(30)
153 .no_color(false)
154 .build();
155 let spinner = Spinner::new("dots")
156 .text("Working")
157 .style(crate::style::Style::parse("green").unwrap());
158 assert_eq!(
159 console.render_to_string(&spinner.render(0.0)),
160 "\x1b[32m⠋\x1b[0m Working"
161 );
162 }
163}