use crate::core::render::Rendered;
use crate::core::style::{Color, Style};
use crate::core::text::{Line, Span};
use crate::core::theme::theme;
use crate::error::Result;
use crate::output::live::InPlace;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SpinnerStyle {
#[default]
Braille,
Pipe,
Dots,
Arrow,
}
impl SpinnerStyle {
fn frames(self) -> &'static [char] {
match self {
Self::Braille => {
&['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
}
Self::Pipe => &['|', '/', '-', '\\'],
Self::Dots => &['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'],
Self::Arrow => &['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'],
}
}
}
pub struct Spinner {
style: SpinnerStyle,
color: Color,
label: String,
label_style: Style,
frame_index: usize,
inplace: Option<InPlace>,
}
impl Spinner {
pub fn new(label: impl Into<String>) -> Self {
let theme = theme();
Self {
style: SpinnerStyle::Braille,
color: theme.accent,
label: label.into(),
label_style: Style::new(),
frame_index: 0,
inplace: None,
}
}
#[must_use]
pub fn style(mut self, style: SpinnerStyle) -> Self {
self.style = style;
self
}
#[must_use]
pub fn color(mut self, color: Color) -> Self {
self.color = color;
self
}
pub fn set_label(&mut self, label: impl Into<String>) {
self.label = label.into();
}
pub fn frame(&self) -> Rendered {
let frames = self.style.frames();
let glyph = frames[self.frame_index % frames.len()];
self.compose(glyph.to_string(), Style::new().fg(self.color))
}
pub fn tick(&mut self) -> Result<()> {
let frame = self.frame();
self.inplace
.get_or_insert_with(|| InPlace::new(false))
.draw(&frame)?;
self.frame_index = self.frame_index.wrapping_add(1);
Ok(())
}
pub fn clear(mut self) -> Result<()> {
match self.inplace.take() {
Some(inplace) => inplace.clear(),
None => Ok(()),
}
}
pub fn finish(
mut self,
success: bool,
label: impl Into<String>,
) -> Result<()> {
let theme = theme();
let (glyph, style) = if success {
(success_glyph(theme.unicode), theme.success)
} else {
(failure_glyph(theme.unicode), theme.error)
};
self.label = label.into();
let frame = self.compose(glyph.to_string(), style);
let mut inplace =
self.inplace.take().unwrap_or_else(|| InPlace::new(false));
inplace.draw(&frame)?;
inplace.finish()
}
fn compose(&self, glyph: String, glyph_style: Style) -> Rendered {
let mut spans = vec![Span::styled(glyph, glyph_style)];
if !self.label.is_empty() {
spans.push(Span::styled(
format!(" {}", self.label),
self.label_style,
));
}
Rendered::new(vec![Line::new(spans)])
}
}
fn success_glyph(unicode: bool) -> char {
if unicode { '✔' } else { '+' }
}
fn failure_glyph(unicode: bool) -> char {
if unicode { '✖' } else { 'x' }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_contains_glyph_and_label() {
let spinner = Spinner::new("loading");
let line = spinner.frame().lines[0].plain();
assert!(line.contains('⠋'));
assert!(line.contains("loading"));
}
#[test]
fn pipe_style_uses_ascii_frames() {
let spinner = Spinner::new("").style(SpinnerStyle::Pipe);
assert_eq!(spinner.frame().lines[0].plain(), "|");
}
#[test]
fn clearing_an_untouched_spinner_is_harmless() {
assert!(Spinner::new("loading").clear().is_ok());
}
}