use std::fmt;
use crate::console::{Console, ConsoleOptions, Renderable};
use crate::measure::Measurement;
use crate::segment::Segment;
use crate::spinners::SPINNERS;
use crate::style::Style;
use crate::text::{Text, TextPart};
#[derive(Debug, Clone)]
pub struct SpinnerError(pub String);
impl fmt::Display for SpinnerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for SpinnerError {}
#[derive(Debug)]
pub struct Spinner {
pub name: String,
pub text: Option<Text>,
pub frames: Vec<String>,
pub interval: f64,
pub start_time: Option<f64>,
pub style: Option<Style>,
pub speed: f64,
pub frame_no_offset: f64,
update_speed: f64,
}
impl Spinner {
pub fn new(name: &str) -> Result<Spinner, SpinnerError> {
let spinner_data = SPINNERS
.get(name)
.ok_or_else(|| SpinnerError(format!("no spinner called {:?}", name)))?;
Ok(Spinner {
name: name.to_string(),
text: None,
frames: spinner_data.frames.clone(),
interval: spinner_data.interval,
start_time: None,
style: None,
speed: 1.0,
frame_no_offset: 0.0,
update_speed: 0.0,
})
}
#[must_use]
pub fn with_text(mut self, text: Text) -> Self {
self.text = Some(text);
self
}
#[must_use]
pub fn with_style(mut self, style: Style) -> Self {
self.style = Some(style);
self
}
#[must_use]
pub fn with_speed(mut self, speed: f64) -> Self {
self.speed = speed;
self
}
pub fn render(&mut self, time: f64) -> Text {
if self.start_time.is_none() {
self.start_time = Some(time);
}
let elapsed = time - self.start_time.unwrap();
let frame_no = (elapsed * self.speed) / (self.interval / 1000.0) + self.frame_no_offset;
let frame_idx = (frame_no as usize) % self.frames.len();
let frame_style = self.style.clone().unwrap_or_else(Style::null);
let frame = Text::new(&self.frames[frame_idx], frame_style);
if self.update_speed != 0.0 {
self.frame_no_offset = frame_no;
self.start_time = Some(time);
self.speed = self.update_speed;
self.update_speed = 0.0;
}
match &self.text {
None => frame,
Some(text) => Text::assemble(
&[
TextPart::Rich(frame),
TextPart::Raw(" ".to_string()),
TextPart::Rich(text.clone()),
],
Style::null(),
),
}
}
pub fn update(&mut self, text: Option<Text>, style: Option<Style>, speed: Option<f64>) {
if let Some(t) = text {
if !t.is_empty() {
self.text = Some(t);
}
}
if let Some(s) = style {
self.style = Some(s);
}
if let Some(sp) = speed {
self.update_speed = sp;
}
}
}
impl Renderable for Spinner {
fn rich_console(&self, _console: &Console, _options: &ConsoleOptions) -> Vec<Segment> {
let mut spinner_clone = Spinner {
name: self.name.clone(),
text: self.text.clone(),
frames: self.frames.clone(),
interval: self.interval,
start_time: self.start_time,
style: self.style.clone(),
speed: self.speed,
frame_no_offset: self.frame_no_offset,
update_speed: self.update_speed,
};
let text = spinner_clone.render(0.0);
text.render()
}
}
impl Spinner {
pub fn measure(&self) -> Measurement {
let mut spinner_clone = Spinner {
name: self.name.clone(),
text: self.text.clone(),
frames: self.frames.clone(),
interval: self.interval,
start_time: None,
style: self.style.clone(),
speed: self.speed,
frame_no_offset: self.frame_no_offset,
update_speed: self.update_speed,
};
let text = spinner_clone.render(0.0);
text.measure()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_construction_valid_name() {
let spinner = Spinner::new("dots");
assert!(spinner.is_ok());
let spinner = spinner.unwrap();
assert_eq!(spinner.name, "dots");
assert_eq!(spinner.frames.len(), 10);
assert_eq!(spinner.interval, 80.0);
assert_eq!(spinner.speed, 1.0);
assert!(spinner.start_time.is_none());
assert!(spinner.text.is_none());
assert!(spinner.style.is_none());
}
#[test]
fn test_construction_invalid_name() {
let spinner = Spinner::new("nonexistent_spinner_xyz");
assert!(spinner.is_err());
let err = spinner.unwrap_err();
assert!(err.0.contains("nonexistent_spinner_xyz"));
}
#[test]
fn test_with_text() {
let spinner = Spinner::new("dots")
.unwrap()
.with_text(Text::new("Loading...", Style::null()));
assert!(spinner.text.is_some());
assert_eq!(spinner.text.as_ref().unwrap().plain(), "Loading...");
}
#[test]
fn test_with_style() {
let style = Style::parse("bold red").unwrap();
let spinner = Spinner::new("dots").unwrap().with_style(style.clone());
assert!(spinner.style.is_some());
assert_eq!(spinner.style.unwrap(), style);
}
#[test]
fn test_with_speed() {
let spinner = Spinner::new("dots").unwrap().with_speed(2.0);
assert_eq!(spinner.speed, 2.0);
}
#[test]
fn test_render_at_time_zero_returns_first_frame() {
let mut spinner = Spinner::new("dots").unwrap();
let text = spinner.render(0.0);
let first_frame = &spinner.frames[0];
assert_eq!(text.plain(), first_frame.as_str());
}
#[test]
fn test_render_at_different_times_returns_different_frames() {
let mut spinner = Spinner::new("dots").unwrap();
let text0 = spinner.render(0.0);
let text1 = spinner.render(0.08);
let text2 = spinner.render(0.16);
let plain0 = text0.plain().to_string();
let plain1 = text1.plain().to_string();
let plain2 = text2.plain().to_string();
assert_ne!(plain0, plain1);
assert_ne!(plain1, plain2);
}
#[test]
fn test_render_wraps_around() {
let mut spinner = Spinner::new("dots").unwrap();
let _frame_count = spinner.frames.len();
spinner.render(0.0);
let text = spinner.render(0.8);
let first_frame = &spinner.frames[0];
assert_eq!(text.plain(), first_frame.as_str());
}
#[test]
fn test_render_with_text() {
let mut spinner = Spinner::new("dots")
.unwrap()
.with_text(Text::new("Working", Style::null()));
let text = spinner.render(0.0);
let plain = text.plain().to_string();
assert!(plain.contains("Working"));
assert!(plain.contains(&spinner.frames[0]));
let expected = format!("{} Working", spinner.frames[0]);
assert_eq!(plain, expected);
}
#[test]
fn test_speed_affects_frame_selection() {
let mut spinner_normal = Spinner::new("dots").unwrap();
let mut spinner_fast = Spinner::new("dots").unwrap().with_speed(2.0);
spinner_normal.render(0.0);
spinner_fast.render(0.0);
let text_normal = spinner_normal.render(0.08);
let text_fast = spinner_fast.render(0.08);
assert_ne!(text_normal.plain(), text_fast.plain());
}
#[test]
fn test_update_text() {
let mut spinner = Spinner::new("dots").unwrap();
assert!(spinner.text.is_none());
spinner.update(Some(Text::new("New text", Style::null())), None, None);
assert!(spinner.text.is_some());
assert_eq!(spinner.text.as_ref().unwrap().plain(), "New text");
}
#[test]
fn test_update_style() {
let mut spinner = Spinner::new("dots").unwrap();
assert!(spinner.style.is_none());
let style = Style::parse("bold").unwrap();
spinner.update(None, Some(style.clone()), None);
assert_eq!(spinner.style, Some(style));
}
#[test]
fn test_update_speed() {
let mut spinner = Spinner::new("dots").unwrap();
assert_eq!(spinner.speed, 1.0);
spinner.update(None, None, Some(3.0));
assert_eq!(spinner.update_speed, 3.0);
assert_eq!(spinner.speed, 1.0);
spinner.render(0.0);
spinner.render(0.1);
assert_eq!(spinner.speed, 3.0);
assert_eq!(spinner.update_speed, 0.0);
}
#[test]
fn test_update_does_not_set_empty_text() {
let mut spinner = Spinner::new("dots")
.unwrap()
.with_text(Text::new("Original", Style::null()));
spinner.update(Some(Text::empty()), None, None);
assert_eq!(spinner.text.as_ref().unwrap().plain(), "Original");
}
#[test]
fn test_renderable_trait() {
let spinner = Spinner::new("dots").unwrap();
let console = Console::builder().width(80).build();
let opts = console.options();
let segments = spinner.rich_console(&console, &opts);
assert!(!segments.is_empty());
let combined: String = segments.iter().map(|s| s.text.as_str()).collect();
assert!(!combined.is_empty());
}
#[test]
fn test_renderable_with_text() {
let spinner = Spinner::new("dots")
.unwrap()
.with_text(Text::new("Loading", Style::null()));
let console = Console::builder().width(80).build();
let opts = console.options();
let segments = spinner.rich_console(&console, &opts);
let combined: String = segments.iter().map(|s| s.text.as_str()).collect();
assert!(combined.contains("Loading"));
}
#[test]
fn test_measure_returns_reasonable_values() {
let spinner = Spinner::new("dots").unwrap();
let measurement = spinner.measure();
assert!(measurement.minimum >= 1);
assert!(measurement.maximum >= 1);
}
#[test]
fn test_measure_with_text() {
let spinner = Spinner::new("dots")
.unwrap()
.with_text(Text::new("Loading", Style::null()));
let measurement = spinner.measure();
assert!(measurement.minimum >= 1);
assert_eq!(measurement.maximum, 9);
}
#[test]
fn test_start_time_set_on_first_render() {
let mut spinner = Spinner::new("dots").unwrap();
assert!(spinner.start_time.is_none());
spinner.render(5.0);
assert_eq!(spinner.start_time, Some(5.0));
}
#[test]
fn test_line_spinner() {
let mut spinner = Spinner::new("line").unwrap();
assert_eq!(spinner.interval, 130.0);
let text = spinner.render(0.0);
assert_eq!(text.plain(), "-");
}
#[test]
fn test_spinner_error_display() {
let err = SpinnerError("test error".to_string());
assert_eq!(format!("{}", err), "test error");
}
#[test]
fn test_render_with_style() {
let style = Style::parse("bold").unwrap();
let mut spinner = Spinner::new("dots").unwrap().with_style(style);
let text = spinner.render(0.0);
let segments = text.render();
let has_styled = segments.iter().any(|s| s.style.is_some());
assert!(has_styled);
}
#[test]
fn test_various_spinners() {
let spinner_names = [
"dots",
"line",
"arc",
"bouncingBar",
"star",
"bounce",
"toggle",
"arrow",
"circle",
];
for name in &spinner_names {
let mut spinner = Spinner::new(name)
.unwrap_or_else(|e| panic!("failed to create spinner '{}': {}", name, e));
let text = spinner.render(0.0);
assert!(
!text.plain().is_empty(),
"spinner '{}' rendered empty text",
name
);
}
}
}