1use std::cell::Cell;
19use std::sync::{Arc, Mutex};
20
21use crate::console::{Console, ConsoleOptions};
22use crate::measure::Measurement;
23use crate::protocol::Renderable;
24use crate::segment::Segment;
25use crate::style::StyleType;
26use crate::text::Text;
27
28pub struct Spinner {
30 frames: &'static [&'static str],
31 interval: f64,
33 text: Option<Box<Text>>,
35 renderable: Option<Arc<Mutex<Box<dyn Renderable + Send>>>>,
38 style: Option<StyleType>,
39 speed: Cell<f64>,
40 start_time: Cell<Option<f64>>,
42 frame_no_offset: Cell<f64>,
44 update_speed: Cell<f64>,
46}
47
48fn markup(text: &str) -> Text {
51 Text::from_markup(text).unwrap_or_else(|_| Text::new(text))
52}
53
54pub fn spinner_names() -> &'static [&'static str] {
57 crate::spinner_data::SPINNER_NAMES
58}
59
60pub fn spinner_frames(name: &str) -> Option<(f64, &'static [&'static str])> {
63 crate::spinner_data::spinner_data(name)
64}
65
66impl Spinner {
67 pub fn new(name: &str) -> Self {
69 let (interval, frames) = crate::spinner_data::spinner_data(name)
70 .or_else(|| crate::spinner_data::spinner_data("dots"))
71 .expect("dots spinner exists");
72 Spinner {
73 frames,
74 interval,
75 text: None,
76 renderable: None,
77 style: None,
78 speed: Cell::new(1.0),
79 start_time: Cell::new(None),
80 frame_no_offset: Cell::new(0.0),
81 update_speed: Cell::new(0.0),
82 }
83 }
84
85 pub fn try_new(name: &str) -> Option<Self> {
89 crate::spinner_data::spinner_data(name).map(|_| Spinner::new(name))
90 }
91
92 pub fn text(mut self, text: impl Into<String>) -> Self {
95 self.text = Some(Box::new(markup(&text.into())));
96 self.renderable = None;
97 self
98 }
99
100 pub fn with_text(mut self, text: Text) -> Self {
103 self.text = Some(Box::new(text));
104 self.renderable = None;
105 self
106 }
107
108 pub fn renderable(self, renderable: Box<dyn Renderable + Send>) -> Self {
112 self.content(&SpinnerText::Renderable(Arc::new(Mutex::new(renderable))))
113 }
114
115 pub(crate) fn content(mut self, content: &SpinnerText) -> Self {
117 self.set_content(content);
118 self
119 }
120
121 pub(crate) fn update_content(&mut self, content: &SpinnerText) {
125 let truthy = match content {
126 SpinnerText::Markup(markup) => !markup.is_empty(),
127 SpinnerText::Text(text) => !text.plain().is_empty(),
128 SpinnerText::Renderable(_) => true,
129 };
130 if truthy {
131 self.set_content(content);
132 }
133 }
134
135 fn set_content(&mut self, content: &SpinnerText) {
136 match content {
137 SpinnerText::Markup(source) => {
138 self.text = Some(Box::new(markup(source)));
139 self.renderable = None;
140 }
141 SpinnerText::Text(text) => {
142 self.text = Some(Box::new(text.clone()));
143 self.renderable = None;
144 }
145 SpinnerText::Renderable(renderable) => {
146 self.renderable = Some(renderable.clone());
147 self.text = None;
148 }
149 }
150 }
151
152 pub fn speed(self, speed: f64) -> Self {
154 self.speed.set(speed);
155 self
156 }
157
158 pub fn style(mut self, style: impl Into<StyleType>) -> Self {
161 self.style = Some(style.into());
162 self
163 }
164
165 pub fn update(&mut self, text: Option<&str>, style: Option<StyleType>, speed: Option<f64>) {
169 if let Some(text) = text.filter(|t| !t.is_empty()) {
170 self.text = Some(Box::new(markup(text)));
171 }
172 if let Some(style) = style {
173 self.style = Some(style);
174 }
175 if let Some(speed) = speed.filter(|s| *s != 0.0) {
176 self.update_speed.set(speed);
177 }
178 }
179
180 pub fn render(&self, time: f64) -> Text {
184 let frame = self.frame(time);
185 match &self.text {
186 Some(text) if !text.plain().is_empty() => {
189 let mut assembled = Text::new("");
190 assembled.append(frame.plain(), self.style.clone());
191 assembled.append(" ", None);
192 assembled.append_text(text)
193 }
194 _ => frame,
195 }
196 }
197
198 pub fn render_renderable(&self, time: f64) -> Box<dyn Renderable> {
203 let Some(renderable) = &self.renderable else {
204 return Box::new(self.render(time));
205 };
206 let frame = self.frame(time);
207 let mut table = crate::table::Table::grid().padding(1, 1, 1, 1);
208 table.add_row_cells(vec![
209 crate::table::Cell::Text(frame),
210 crate::table::Cell::Renderable(Arc::new(Shared(renderable.clone()))),
211 ]);
212 Box::new(table)
213 }
214
215 fn frame(&self, time: f64) -> Text {
218 let start = self.start_time.get().unwrap_or(time);
219 self.start_time.set(Some(start));
220 let frame_no = (time - start) * self.speed.get() / (self.interval / 1000.0)
221 + self.frame_no_offset.get();
222 let index = (frame_no.trunc() as i64).rem_euclid(self.frames.len() as i64) as usize;
224 let frame_str = self.frames[index];
225 let pending = self.update_speed.get();
226 if pending != 0.0 {
227 self.frame_no_offset.set(frame_no);
228 self.start_time.set(Some(time));
229 self.speed.set(pending);
230 self.update_speed.set(0.0);
231 }
232 let mut frame = Text::new(frame_str);
233 if let Some(style) = &self.style {
234 frame.set_base_style(style.clone());
235 }
236 frame
237 }
238}
239
240#[derive(Clone)]
243pub(crate) enum SpinnerText {
244 Markup(String),
245 Text(Text),
246 Renderable(Arc<Mutex<Box<dyn Renderable + Send>>>),
247}
248
249struct Shared(Arc<Mutex<Box<dyn Renderable + Send>>>);
251
252impl Renderable for Shared {
253 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
254 self.0
255 .lock()
256 .unwrap_or_else(|poisoned| poisoned.into_inner())
257 .rich_render(console, options)
258 }
259
260 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
261 self.0
262 .lock()
263 .unwrap_or_else(|poisoned| poisoned.into_inner())
264 .measure(console, options)
265 }
266}
267
268impl Renderable for Spinner {
269 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
273 if self.renderable.is_some() {
274 return self
275 .render_renderable(console.get_time())
276 .rich_render(console, options);
277 }
278 self.render(console.get_time())
279 .rich_render(console, options)
280 }
281
282 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
284 if self.renderable.is_some() {
285 return self.render_renderable(0.0).measure(console, options);
286 }
287 self.render(0.0).measure(console, options)
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn spinner_table_is_public_in_upstream_order() {
297 let names = spinner_names();
298 assert_eq!(names.len(), 73);
299 assert_eq!(names[0], "dots");
300 assert!(names.iter().all(|name| spinner_frames(name).is_some()));
301 assert!(spinner_frames("nope").is_none());
302 }
303
304 #[test]
305 fn an_unknown_name_is_refused_by_try_new() {
306 assert!(Spinner::try_new("nope").is_none());
308 assert!(Spinner::try_new("line").is_some());
309 }
310 use crate::color::ColorSystem;
311
312 fn frame_at(name: &str, time: f64) -> String {
314 let console = Console::builder()
315 .force_terminal(true)
316 .color_system(Some(ColorSystem::Truecolor))
317 .width(20)
318 .build();
319 let spinner = Spinner::new(name);
320 spinner.render(0.0);
321 console.render_to_string(&spinner.render(time))
322 }
323
324 #[test]
325 fn dots_frames_match_upstream() {
326 assert_eq!(frame_at("dots", 0.0), "⠋");
328 assert_eq!(frame_at("dots", 0.1), "⠙");
329 assert_eq!(frame_at("dots", 0.25), "⠸");
330 }
331
332 #[test]
333 fn line_frames_match_upstream() {
334 assert_eq!(frame_at("line", 0.0), "-");
335 assert_eq!(frame_at("line", 0.1), "-");
336 assert_eq!(frame_at("line", 0.25), "\\");
337 }
338
339 #[test]
340 fn full_table_covers_more_spinners() {
341 assert_eq!(frame_at("moon", 0.0), "\u{1f311} ");
343 assert_eq!(frame_at("moon", 0.08), "\u{1f312} ");
344 assert_eq!(frame_at("bounce", 0.0), "\u{2801}");
345 }
346
347 #[test]
348 fn arrow_and_dots2_match_upstream() {
349 assert_eq!(frame_at("arrow", 0.0), "←");
351 assert_eq!(frame_at("arrow", 0.1), "↖");
352 assert_eq!(frame_at("dots2", 0.0), "⣾");
353 }
354
355 #[test]
356 fn text_follows_frame() {
357 let console = Console::builder()
358 .force_terminal(true)
359 .color_system(Some(ColorSystem::Truecolor))
360 .width(20)
361 .build();
362 let out = console.render_to_string(&Spinner::new("dots").text("Working").render(0.0));
363 assert_eq!(out, "⠋ Working");
364 }
365
366 #[test]
367 fn styled_frame_only() {
368 let console = Console::builder()
370 .force_terminal(true)
371 .color_system(Some(ColorSystem::Truecolor))
372 .width(30)
373 .no_color(false)
374 .build();
375 let spinner = Spinner::new("dots")
376 .text("Working")
377 .style(crate::style::Style::parse("green").unwrap());
378 assert_eq!(
379 console.render_to_string(&spinner.render(0.0)),
380 "\x1b[32m⠋\x1b[0m Working"
381 );
382 }
383}