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
49impl Spinner {
50 pub fn new(name: &str) -> Self {
52 let (interval, frames) = crate::spinner_data::spinner_data(name)
53 .or_else(|| crate::spinner_data::spinner_data("dots"))
54 .expect("dots spinner exists");
55 Spinner {
56 frames,
57 interval,
58 text: None,
59 style: None,
60 speed: Cell::new(1.0),
61 start_time: Cell::new(None),
62 frame_no_offset: Cell::new(0.0),
63 update_speed: Cell::new(0.0),
64 }
65 }
66
67 pub fn text(mut self, text: impl Into<String>) -> Self {
70 self.text = Some(Box::new(markup(&text.into())));
71 self
72 }
73
74 pub fn speed(self, speed: f64) -> Self {
76 self.speed.set(speed);
77 self
78 }
79
80 pub fn style(mut self, style: impl Into<StyleType>) -> Self {
83 self.style = Some(style.into());
84 self
85 }
86
87 pub fn update(&mut self, text: Option<&str>, style: Option<StyleType>, speed: Option<f64>) {
91 if let Some(text) = text.filter(|t| !t.is_empty()) {
92 self.text = Some(Box::new(markup(text)));
93 }
94 if let Some(style) = style {
95 self.style = Some(style);
96 }
97 if let Some(speed) = speed.filter(|s| *s != 0.0) {
98 self.update_speed.set(speed);
99 }
100 }
101
102 pub fn render(&self, time: f64) -> Text {
106 let start = self.start_time.get().unwrap_or(time);
107 self.start_time.set(Some(start));
108 let frame_no = (time - start) * self.speed.get() / (self.interval / 1000.0)
109 + self.frame_no_offset.get();
110 let index = (frame_no.trunc() as i64).rem_euclid(self.frames.len() as i64) as usize;
112 let frame_str = self.frames[index];
113 let pending = self.update_speed.get();
114 if pending != 0.0 {
115 self.frame_no_offset.set(frame_no);
116 self.start_time.set(Some(time));
117 self.speed.set(pending);
118 self.update_speed.set(0.0);
119 }
120 match &self.text {
121 Some(text) if !text.plain().is_empty() => {
124 let mut assembled = Text::new("");
125 assembled.append(frame_str, self.style.clone());
126 assembled.append(" ", None);
127 assembled.append_text(text)
128 }
129 _ => {
130 let mut frame = Text::new(frame_str);
131 if let Some(style) = &self.style {
132 frame.set_base_style(style.clone());
133 }
134 frame
135 }
136 }
137 }
138}
139
140impl Renderable for Spinner {
141 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
145 self.render(console.get_time())
146 .rich_render(console, options)
147 }
148
149 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
151 self.render(0.0).measure(console, options)
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use crate::color::ColorSystem;
159
160 fn frame_at(name: &str, time: f64) -> String {
162 let console = Console::builder()
163 .force_terminal(true)
164 .color_system(Some(ColorSystem::Truecolor))
165 .width(20)
166 .build();
167 let spinner = Spinner::new(name);
168 spinner.render(0.0);
169 console.render_to_string(&spinner.render(time))
170 }
171
172 #[test]
173 fn dots_frames_match_upstream() {
174 assert_eq!(frame_at("dots", 0.0), "⠋");
176 assert_eq!(frame_at("dots", 0.1), "⠙");
177 assert_eq!(frame_at("dots", 0.25), "⠸");
178 }
179
180 #[test]
181 fn line_frames_match_upstream() {
182 assert_eq!(frame_at("line", 0.0), "-");
183 assert_eq!(frame_at("line", 0.1), "-");
184 assert_eq!(frame_at("line", 0.25), "\\");
185 }
186
187 #[test]
188 fn full_table_covers_more_spinners() {
189 assert_eq!(frame_at("moon", 0.0), "\u{1f311} ");
191 assert_eq!(frame_at("moon", 0.08), "\u{1f312} ");
192 assert_eq!(frame_at("bounce", 0.0), "\u{2801}");
193 }
194
195 #[test]
196 fn arrow_and_dots2_match_upstream() {
197 assert_eq!(frame_at("arrow", 0.0), "←");
199 assert_eq!(frame_at("arrow", 0.1), "↖");
200 assert_eq!(frame_at("dots2", 0.0), "⣾");
201 }
202
203 #[test]
204 fn text_follows_frame() {
205 let console = Console::builder()
206 .force_terminal(true)
207 .color_system(Some(ColorSystem::Truecolor))
208 .width(20)
209 .build();
210 let out = console.render_to_string(&Spinner::new("dots").text("Working").render(0.0));
211 assert_eq!(out, "⠋ Working");
212 }
213
214 #[test]
215 fn styled_frame_only() {
216 let console = Console::builder()
218 .force_terminal(true)
219 .color_system(Some(ColorSystem::Truecolor))
220 .width(30)
221 .no_color(false)
222 .build();
223 let spinner = Spinner::new("dots")
224 .text("Working")
225 .style(crate::style::Style::parse("green").unwrap());
226 assert_eq!(
227 console.render_to_string(&spinner.render(0.0)),
228 "\x1b[32m⠋\x1b[0m Working"
229 );
230 }
231}