1use std::cell::Cell;
18
19use crate::console::{Console, ConsoleOptions};
20use crate::measure::Measurement;
21use crate::protocol::Renderable;
22use crate::segment::Segment;
23use crate::style::StyleType;
24use crate::text::Text;
25
26pub struct Spinner {
28 frames: &'static [&'static str],
29 interval: f64,
31 text: Option<Box<Text>>,
33 style: Option<StyleType>,
34 speed: Cell<f64>,
35 start_time: Cell<Option<f64>>,
37 frame_no_offset: Cell<f64>,
39 update_speed: Cell<f64>,
41}
42
43fn markup(text: &str) -> Text {
46 Text::from_markup(text).unwrap_or_else(|_| Text::new(text))
47}
48
49pub fn spinner_names() -> &'static [&'static str] {
52 crate::spinner_data::SPINNER_NAMES
53}
54
55pub fn spinner_frames(name: &str) -> Option<(f64, &'static [&'static str])> {
58 crate::spinner_data::spinner_data(name)
59}
60
61impl Spinner {
62 pub fn new(name: &str) -> Self {
64 let (interval, frames) = crate::spinner_data::spinner_data(name)
65 .or_else(|| crate::spinner_data::spinner_data("dots"))
66 .expect("dots spinner exists");
67 Spinner {
68 frames,
69 interval,
70 text: None,
71 style: None,
72 speed: Cell::new(1.0),
73 start_time: Cell::new(None),
74 frame_no_offset: Cell::new(0.0),
75 update_speed: Cell::new(0.0),
76 }
77 }
78
79 pub fn text(mut self, text: impl Into<String>) -> Self {
82 self.text = Some(Box::new(markup(&text.into())));
83 self
84 }
85
86 pub fn speed(self, speed: f64) -> Self {
88 self.speed.set(speed);
89 self
90 }
91
92 pub fn style(mut self, style: impl Into<StyleType>) -> Self {
95 self.style = Some(style.into());
96 self
97 }
98
99 pub fn update(&mut self, text: Option<&str>, style: Option<StyleType>, speed: Option<f64>) {
103 if let Some(text) = text.filter(|t| !t.is_empty()) {
104 self.text = Some(Box::new(markup(text)));
105 }
106 if let Some(style) = style {
107 self.style = Some(style);
108 }
109 if let Some(speed) = speed.filter(|s| *s != 0.0) {
110 self.update_speed.set(speed);
111 }
112 }
113
114 pub fn render(&self, time: f64) -> Text {
118 let start = self.start_time.get().unwrap_or(time);
119 self.start_time.set(Some(start));
120 let frame_no = (time - start) * self.speed.get() / (self.interval / 1000.0)
121 + self.frame_no_offset.get();
122 let index = (frame_no.trunc() as i64).rem_euclid(self.frames.len() as i64) as usize;
124 let frame_str = self.frames[index];
125 let pending = self.update_speed.get();
126 if pending != 0.0 {
127 self.frame_no_offset.set(frame_no);
128 self.start_time.set(Some(time));
129 self.speed.set(pending);
130 self.update_speed.set(0.0);
131 }
132 match &self.text {
133 Some(text) if !text.plain().is_empty() => {
136 let mut assembled = Text::new("");
137 assembled.append(frame_str, self.style.clone());
138 assembled.append(" ", None);
139 assembled.append_text(text)
140 }
141 _ => {
142 let mut frame = Text::new(frame_str);
143 if let Some(style) = &self.style {
144 frame.set_base_style(style.clone());
145 }
146 frame
147 }
148 }
149 }
150}
151
152impl Renderable for Spinner {
153 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
157 self.render(console.get_time())
158 .rich_render(console, options)
159 }
160
161 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
163 self.render(0.0).measure(console, options)
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn spinner_table_is_public_in_upstream_order() {
173 let names = spinner_names();
174 assert_eq!(names.len(), 73);
175 assert_eq!(names[0], "dots");
176 assert!(names.iter().all(|name| spinner_frames(name).is_some()));
177 assert!(spinner_frames("nope").is_none());
178 }
179 use crate::color::ColorSystem;
180
181 fn frame_at(name: &str, time: f64) -> String {
183 let console = Console::builder()
184 .force_terminal(true)
185 .color_system(Some(ColorSystem::Truecolor))
186 .width(20)
187 .build();
188 let spinner = Spinner::new(name);
189 spinner.render(0.0);
190 console.render_to_string(&spinner.render(time))
191 }
192
193 #[test]
194 fn dots_frames_match_upstream() {
195 assert_eq!(frame_at("dots", 0.0), "⠋");
197 assert_eq!(frame_at("dots", 0.1), "⠙");
198 assert_eq!(frame_at("dots", 0.25), "⠸");
199 }
200
201 #[test]
202 fn line_frames_match_upstream() {
203 assert_eq!(frame_at("line", 0.0), "-");
204 assert_eq!(frame_at("line", 0.1), "-");
205 assert_eq!(frame_at("line", 0.25), "\\");
206 }
207
208 #[test]
209 fn full_table_covers_more_spinners() {
210 assert_eq!(frame_at("moon", 0.0), "\u{1f311} ");
212 assert_eq!(frame_at("moon", 0.08), "\u{1f312} ");
213 assert_eq!(frame_at("bounce", 0.0), "\u{2801}");
214 }
215
216 #[test]
217 fn arrow_and_dots2_match_upstream() {
218 assert_eq!(frame_at("arrow", 0.0), "←");
220 assert_eq!(frame_at("arrow", 0.1), "↖");
221 assert_eq!(frame_at("dots2", 0.0), "⣾");
222 }
223
224 #[test]
225 fn text_follows_frame() {
226 let console = Console::builder()
227 .force_terminal(true)
228 .color_system(Some(ColorSystem::Truecolor))
229 .width(20)
230 .build();
231 let out = console.render_to_string(&Spinner::new("dots").text("Working").render(0.0));
232 assert_eq!(out, "⠋ Working");
233 }
234
235 #[test]
236 fn styled_frame_only() {
237 let console = Console::builder()
239 .force_terminal(true)
240 .color_system(Some(ColorSystem::Truecolor))
241 .width(30)
242 .no_color(false)
243 .build();
244 let spinner = Spinner::new("dots")
245 .text("Working")
246 .style(crate::style::Style::parse("green").unwrap());
247 assert_eq!(
248 console.render_to_string(&spinner.render(0.0)),
249 "\x1b[32m⠋\x1b[0m Working"
250 );
251 }
252}