1use std::cell::Cell;
17
18use crate::console::{Console, ConsoleOptions};
19use crate::protocol::Renderable;
20use crate::segment::Segment;
21use crate::style::StyleType;
22use crate::text::Text;
23
24pub struct Spinner {
26 frames: &'static [&'static str],
27 interval: f64,
29 text: Option<Box<Text>>,
31 style: Option<StyleType>,
32 speed: Cell<f64>,
33 start_time: Cell<Option<f64>>,
35 frame_no_offset: Cell<f64>,
37 update_speed: Cell<f64>,
39}
40
41fn markup(text: &str) -> Text {
44 Text::from_markup(text).unwrap_or_else(|_| Text::new(text))
45}
46
47impl Spinner {
48 pub fn new(name: &str) -> Self {
50 let (interval, frames) = crate::spinner_data::spinner_data(name)
51 .or_else(|| crate::spinner_data::spinner_data("dots"))
52 .expect("dots spinner exists");
53 Spinner {
54 frames,
55 interval,
56 text: None,
57 style: None,
58 speed: Cell::new(1.0),
59 start_time: Cell::new(None),
60 frame_no_offset: Cell::new(0.0),
61 update_speed: Cell::new(0.0),
62 }
63 }
64
65 pub fn text(mut self, text: impl Into<String>) -> Self {
68 self.text = Some(Box::new(markup(&text.into())));
69 self
70 }
71
72 pub fn speed(self, speed: f64) -> Self {
74 self.speed.set(speed);
75 self
76 }
77
78 pub fn style(mut self, style: impl Into<StyleType>) -> Self {
81 self.style = Some(style.into());
82 self
83 }
84
85 pub fn update(&mut self, text: Option<&str>, style: Option<StyleType>, speed: Option<f64>) {
89 if let Some(text) = text.filter(|t| !t.is_empty()) {
90 self.text = Some(Box::new(markup(text)));
91 }
92 if let Some(style) = style {
93 self.style = Some(style);
94 }
95 if let Some(speed) = speed.filter(|s| *s != 0.0) {
96 self.update_speed.set(speed);
97 }
98 }
99
100 pub fn render(&self, time: f64) -> Text {
104 let start = self.start_time.get().unwrap_or(time);
105 self.start_time.set(Some(start));
106 let frame_no = (time - start) * self.speed.get() / (self.interval / 1000.0)
107 + self.frame_no_offset.get();
108 let index = (frame_no.trunc() as i64).rem_euclid(self.frames.len() as i64) as usize;
110 let frame_str = self.frames[index];
111 let pending = self.update_speed.get();
112 if pending != 0.0 {
113 self.frame_no_offset.set(frame_no);
114 self.start_time.set(Some(time));
115 self.speed.set(pending);
116 self.update_speed.set(0.0);
117 }
118 match &self.text {
119 Some(text) if !text.plain().is_empty() => {
122 let mut assembled = Text::new("");
123 assembled.append(frame_str, self.style.clone());
124 assembled.append(" ", None);
125 assembled.append_text(text)
126 }
127 _ => {
128 let mut frame = Text::new(frame_str);
129 if let Some(style) = &self.style {
130 frame.set_base_style(style.clone());
131 }
132 frame
133 }
134 }
135 }
136}
137
138impl Renderable for Spinner {
139 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
140 self.render(self.start_time.get().unwrap_or(0.0))
143 .rich_render(console, options)
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use crate::color::ColorSystem;
151
152 fn frame_at(name: &str, time: f64) -> String {
154 let console = Console::builder()
155 .force_terminal(true)
156 .color_system(Some(ColorSystem::Truecolor))
157 .width(20)
158 .build();
159 let spinner = Spinner::new(name);
160 spinner.render(0.0);
161 console.render_to_string(&spinner.render(time))
162 }
163
164 #[test]
165 fn dots_frames_match_upstream() {
166 assert_eq!(frame_at("dots", 0.0), "⠋");
168 assert_eq!(frame_at("dots", 0.1), "⠙");
169 assert_eq!(frame_at("dots", 0.25), "⠸");
170 }
171
172 #[test]
173 fn line_frames_match_upstream() {
174 assert_eq!(frame_at("line", 0.0), "-");
175 assert_eq!(frame_at("line", 0.1), "-");
176 assert_eq!(frame_at("line", 0.25), "\\");
177 }
178
179 #[test]
180 fn full_table_covers_more_spinners() {
181 assert_eq!(frame_at("moon", 0.0), "\u{1f311} ");
183 assert_eq!(frame_at("moon", 0.08), "\u{1f312} ");
184 assert_eq!(frame_at("bounce", 0.0), "\u{2801}");
185 }
186
187 #[test]
188 fn arrow_and_dots2_match_upstream() {
189 assert_eq!(frame_at("arrow", 0.0), "←");
191 assert_eq!(frame_at("arrow", 0.1), "↖");
192 assert_eq!(frame_at("dots2", 0.0), "⣾");
193 }
194
195 #[test]
196 fn text_follows_frame() {
197 let console = Console::builder()
198 .force_terminal(true)
199 .color_system(Some(ColorSystem::Truecolor))
200 .width(20)
201 .build();
202 let out = console.render_to_string(&Spinner::new("dots").text("Working").render(0.0));
203 assert_eq!(out, "⠋ Working");
204 }
205
206 #[test]
207 fn styled_frame_only() {
208 let console = Console::builder()
210 .force_terminal(true)
211 .color_system(Some(ColorSystem::Truecolor))
212 .width(30)
213 .no_color(false)
214 .build();
215 let spinner = Spinner::new("dots")
216 .text("Working")
217 .style(crate::style::Style::parse("green").unwrap());
218 assert_eq!(
219 console.render_to_string(&spinner.render(0.0)),
220 "\x1b[32m⠋\x1b[0m Working"
221 );
222 }
223}