sqlmodel_console/renderables/
spinner.rs1use std::time::Instant;
19
20use serde::{Deserialize, Serialize};
21
22use super::OperationProgress;
23use crate::theme::Theme;
24
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
27pub enum SpinnerStyle {
28 #[default]
30 Dots,
31 Braille,
33 Line,
35 Arrow,
37 Simple,
39}
40
41impl SpinnerStyle {
42 #[must_use]
44 pub fn frames(&self) -> &'static [&'static str] {
45 match self {
46 Self::Dots => &[".", "..", "...", ".."],
47 Self::Braille => &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
48 Self::Line => &["-", "\\", "|", "/"],
49 Self::Arrow => &["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"],
50 Self::Simple => &["*", " "],
51 }
52 }
53
54 #[must_use]
56 pub const fn interval_ms(&self) -> u64 {
57 match self {
58 Self::Dots => 250,
59 Self::Braille => 80,
60 Self::Line => 100,
61 Self::Arrow => 120,
62 Self::Simple => 500,
63 }
64 }
65
66 #[must_use]
68 #[allow(clippy::cast_possible_truncation)] pub fn frame_at(&self, elapsed_ms: u64) -> &'static str {
70 let frames = self.frames();
71 let interval = self.interval_ms();
72 let frame_index = ((elapsed_ms / interval) as usize) % frames.len();
73 frames[frame_index]
74 }
75}
76
77#[derive(Debug, Clone)]
103pub struct IndeterminateSpinner {
104 message: String,
106 started_at: Instant,
108 style: SpinnerStyle,
110 theme: Option<Theme>,
112}
113
114impl IndeterminateSpinner {
115 #[must_use]
120 pub fn new(message: impl Into<String>) -> Self {
121 Self {
122 message: message.into(),
123 started_at: Instant::now(),
124 style: SpinnerStyle::default(),
125 theme: None,
126 }
127 }
128
129 #[must_use]
131 pub fn style(mut self, style: SpinnerStyle) -> Self {
132 self.style = style;
133 self
134 }
135
136 #[must_use]
138 pub fn theme(mut self, theme: Theme) -> Self {
139 self.theme = Some(theme);
140 self
141 }
142
143 pub fn set_message(&mut self, message: impl Into<String>) {
145 self.message = message.into();
146 }
147
148 #[must_use]
150 pub fn message(&self) -> &str {
151 &self.message
152 }
153
154 #[must_use]
156 pub fn current_style(&self) -> SpinnerStyle {
157 self.style
158 }
159
160 pub fn reset_timer(&mut self) {
162 self.started_at = Instant::now();
163 }
164
165 #[must_use]
167 pub fn elapsed_secs(&self) -> f64 {
168 self.started_at.elapsed().as_secs_f64()
169 }
170
171 #[must_use]
173 #[allow(clippy::cast_possible_truncation)] pub fn elapsed_ms(&self) -> u64 {
175 self.started_at.elapsed().as_millis() as u64
176 }
177
178 #[must_use]
180 pub fn current_frame(&self) -> &'static str {
181 self.style.frame_at(self.elapsed_ms())
182 }
183
184 #[must_use]
192 pub fn into_progress(self, total: u64) -> OperationProgress {
193 let mut progress = OperationProgress::new(self.message, total);
194 if let Some(theme) = self.theme {
195 progress = progress.theme(theme);
196 }
197 progress
198 }
199
200 #[must_use]
204 pub fn render_plain(&self) -> String {
205 format!(
206 "[...] {} ({})",
207 self.message,
208 format_elapsed(self.elapsed_secs())
209 )
210 }
211
212 #[must_use]
216 pub fn render_styled(&self) -> String {
217 let theme = self.theme.clone().unwrap_or_default();
218 let frame = self.current_frame();
219
220 let color = theme.info.color_code();
221 let reset = "\x1b[0m";
222
223 format!(
224 "{color}[{frame}]{reset} {} ({})",
225 self.message,
226 format_elapsed(self.elapsed_secs())
227 )
228 }
229
230 #[must_use]
232 pub fn to_json(&self) -> String {
233 #[derive(Serialize)]
234 struct SpinnerJson<'a> {
235 message: &'a str,
236 elapsed_secs: f64,
237 style: &'a str,
238 frame: &'a str,
239 }
240
241 let style_str = match self.style {
242 SpinnerStyle::Dots => "dots",
243 SpinnerStyle::Braille => "braille",
244 SpinnerStyle::Line => "line",
245 SpinnerStyle::Arrow => "arrow",
246 SpinnerStyle::Simple => "simple",
247 };
248
249 let json = SpinnerJson {
250 message: &self.message,
251 elapsed_secs: self.elapsed_secs(),
252 style: style_str,
253 frame: self.current_frame(),
254 };
255
256 serde_json::to_string(&json).unwrap_or_else(|_| "{}".to_string())
257 }
258}
259
260fn format_elapsed(secs: f64) -> String {
262 if secs < 60.0 {
263 format!("{secs:.1}s")
264 } else if secs < 3600.0 {
265 let mins = (secs / 60.0).floor();
266 let remaining = secs % 60.0;
267 format!("{mins:.0}m{remaining:.0}s")
268 } else {
269 let hours = (secs / 3600.0).floor();
270 let remaining_mins = ((secs % 3600.0) / 60.0).floor();
271 format!("{hours:.0}h{remaining_mins:.0}m")
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn test_spinner_creation() {
281 let spinner = IndeterminateSpinner::new("Connecting");
282 assert_eq!(spinner.message(), "Connecting");
283 assert_eq!(spinner.current_style(), SpinnerStyle::Dots);
284 }
285
286 #[test]
287 fn test_spinner_all_styles() {
288 for style in [
289 SpinnerStyle::Dots,
290 SpinnerStyle::Braille,
291 SpinnerStyle::Line,
292 SpinnerStyle::Arrow,
293 SpinnerStyle::Simple,
294 ] {
295 let spinner = IndeterminateSpinner::new("Test").style(style);
296 assert_eq!(spinner.current_style(), style);
297 assert!(!spinner.current_frame().is_empty() || style == SpinnerStyle::Simple);
299 }
300 }
301
302 #[test]
303 fn test_spinner_style_frames() {
304 assert_eq!(SpinnerStyle::Dots.frames().len(), 4);
305 assert_eq!(SpinnerStyle::Braille.frames().len(), 10);
306 assert_eq!(SpinnerStyle::Line.frames().len(), 4);
307 assert_eq!(SpinnerStyle::Arrow.frames().len(), 8);
308 assert_eq!(SpinnerStyle::Simple.frames().len(), 2);
309 }
310
311 #[test]
312 fn test_spinner_frame_generation() {
313 let style = SpinnerStyle::Dots;
314 assert_eq!(style.frame_at(0), ".");
316 assert_eq!(style.frame_at(250), "..");
318 assert_eq!(style.frame_at(500), "...");
320 assert_eq!(style.frame_at(750), "..");
322 assert_eq!(style.frame_at(1000), ".");
324 }
325
326 #[test]
327 fn test_spinner_style_intervals() {
328 assert_eq!(SpinnerStyle::Dots.interval_ms(), 250);
329 assert_eq!(SpinnerStyle::Braille.interval_ms(), 80);
330 assert_eq!(SpinnerStyle::Line.interval_ms(), 100);
331 assert_eq!(SpinnerStyle::Arrow.interval_ms(), 120);
332 assert_eq!(SpinnerStyle::Simple.interval_ms(), 500);
333 }
334
335 #[test]
336 fn test_spinner_elapsed_time() {
337 let spinner = IndeterminateSpinner::new("Test");
338 assert!(spinner.elapsed_secs() < 1.0);
340 assert!(spinner.elapsed_ms() < 1000);
341 }
342
343 #[test]
344 fn test_spinner_render_plain() {
345 let spinner = IndeterminateSpinner::new("Connecting to database");
346 let plain = spinner.render_plain();
347
348 assert!(plain.starts_with("[...]"));
349 assert!(plain.contains("Connecting to database"));
350 assert!(plain.contains('s')); }
352
353 #[test]
354 fn test_spinner_render_styled() {
355 let spinner = IndeterminateSpinner::new("Loading").style(SpinnerStyle::Dots);
356 let styled = spinner.render_styled();
357
358 assert!(styled.contains('['));
359 assert!(styled.contains(']'));
360 assert!(styled.contains("Loading"));
361 assert!(styled.contains('\x1b')); }
363
364 #[test]
365 fn test_spinner_message_update() {
366 let mut spinner = IndeterminateSpinner::new("Initial");
367 assert_eq!(spinner.message(), "Initial");
368
369 spinner.set_message("Updated");
370 assert_eq!(spinner.message(), "Updated");
371 }
372
373 #[test]
374 fn test_spinner_convert_to_progress() {
375 let spinner = IndeterminateSpinner::new("Processing")
376 .style(SpinnerStyle::Braille)
377 .theme(Theme::default());
378
379 let progress = spinner.into_progress(1000);
380
381 assert_eq!(progress.operation_name(), "Processing");
382 assert_eq!(progress.total_count(), 1000);
383 assert_eq!(progress.completed_count(), 0);
384 }
385
386 #[test]
387 fn test_spinner_json_output() {
388 let spinner = IndeterminateSpinner::new("Test").style(SpinnerStyle::Line);
389 let json = spinner.to_json();
390
391 assert!(json.contains("\"message\":\"Test\""));
392 assert!(json.contains("\"style\":\"line\""));
393 assert!(json.contains("\"elapsed_secs\""));
394 assert!(json.contains("\"frame\""));
395 }
396
397 #[test]
398 fn test_spinner_with_theme() {
399 let theme = Theme::default();
400 let spinner = IndeterminateSpinner::new("Test").theme(theme.clone());
401
402 let styled = spinner.render_styled();
404 assert!(styled.contains('\x1b')); }
406
407 #[test]
408 fn test_spinner_reset_timer() {
409 let mut spinner = IndeterminateSpinner::new("Test");
410 std::thread::sleep(std::time::Duration::from_millis(10));
411
412 let elapsed_before = spinner.elapsed_ms();
413 spinner.reset_timer();
414 let elapsed_after = spinner.elapsed_ms();
415
416 assert!(elapsed_after < elapsed_before);
418 }
419
420 #[test]
421 fn test_format_elapsed_seconds() {
422 assert_eq!(format_elapsed(0.1), "0.1s");
423 assert_eq!(format_elapsed(5.5), "5.5s");
424 assert_eq!(format_elapsed(59.9), "59.9s");
425 }
426
427 #[test]
428 fn test_format_elapsed_minutes() {
429 let result = format_elapsed(90.0);
430 assert!(result.contains('m'));
431 assert!(result.contains('s'));
432 }
433
434 #[test]
435 fn test_format_elapsed_hours() {
436 let result = format_elapsed(3700.0);
437 assert!(result.contains('h'));
438 assert!(result.contains('m'));
439 }
440
441 #[test]
442 fn test_spinner_default_style() {
443 let spinner = IndeterminateSpinner::new("Test");
444 assert_eq!(spinner.current_style(), SpinnerStyle::Dots);
445 }
446
447 #[test]
448 fn test_spinner_braille_animation() {
449 let style = SpinnerStyle::Braille;
450 let frames = style.frames();
452 for frame in frames {
453 assert!(frame.chars().all(|c| c.is_alphabetic() || c > '\u{2800}'));
454 }
455 }
456
457 #[test]
458 fn test_spinner_line_animation() {
459 let style = SpinnerStyle::Line;
460 let expected = ["-", "\\", "|", "/"];
461 for (i, frame) in style.frames().iter().enumerate() {
462 assert_eq!(*frame, expected[i]);
463 }
464 }
465
466 #[test]
467 fn test_spinner_arrow_animation() {
468 let style = SpinnerStyle::Arrow;
469 assert_eq!(style.frames().len(), 8);
470 for frame in style.frames() {
472 assert_eq!(frame.chars().count(), 1);
473 }
474 }
475}