use crossterm::{
cursor::{Hide, MoveToColumn, Show},
execute,
style::Print,
terminal::{Clear, ClearType},
};
use std::io::{self, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SpinnerStyle {
#[default]
Dots,
Line,
Growing,
Arc,
Bounce,
}
impl SpinnerStyle {
#[must_use]
pub const fn frames(&self) -> &'static [&'static str] {
match self {
Self::Dots => &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
Self::Line => &["|", "/", "-", "\\"],
Self::Growing => &[". ", ".. ", "..."],
Self::Arc => &["◐", "◓", "◑", "◒"],
Self::Bounce => &["⠁", "⠂", "⠄", "⠂"],
}
}
#[must_use]
pub const fn interval_ms(&self) -> u64 {
match self {
Self::Dots => 80,
Self::Line => 100,
Self::Growing => 300,
Self::Arc => 100,
Self::Bounce => 120,
}
}
}
#[derive(Debug)]
pub struct Spinner {
style: SpinnerStyle,
message: Option<String>,
}
impl Default for Spinner {
fn default() -> Self {
Self::new()
}
}
impl Spinner {
#[must_use]
pub const fn new() -> Self {
Self {
style: SpinnerStyle::Dots,
message: None,
}
}
#[must_use]
pub const fn style(mut self, style: SpinnerStyle) -> Self {
self.style = style;
self
}
#[must_use]
pub fn message(mut self, msg: impl Into<String>) -> Self {
self.message = Some(msg.into());
self
}
pub fn start(self) -> SpinnerHandle {
let running = Arc::new(AtomicBool::new(true));
let running_clone = Arc::clone(&running);
let frames = self.style.frames();
let interval = Duration::from_millis(self.style.interval_ms());
let message = self.message;
let handle = thread::spawn(move || {
let mut stdout = io::stdout();
let mut frame_idx = 0;
let _ = execute!(stdout, Hide);
while running_clone.load(Ordering::Relaxed) {
let frame = frames[frame_idx % frames.len()];
let _ = execute!(
stdout,
MoveToColumn(0),
Clear(ClearType::CurrentLine),
Print(frame)
);
if let Some(ref msg) = message {
let _ = execute!(stdout, Print(" "), Print(msg));
}
let _ = stdout.flush();
frame_idx = frame_idx.wrapping_add(1);
thread::sleep(interval);
}
let _ = execute!(stdout, MoveToColumn(0), Clear(ClearType::CurrentLine), Show);
let _ = stdout.flush();
});
SpinnerHandle {
running,
handle: Some(handle),
}
}
}
#[derive(Debug)]
pub struct SpinnerHandle {
running: Arc<AtomicBool>,
handle: Option<JoinHandle<()>>,
}
impl SpinnerHandle {
pub fn stop(mut self) {
self.stop_internal();
}
pub fn stop_with_message(mut self, msg: &str) {
self.stop_internal();
println!("{msg}");
}
fn stop_internal(&mut self) {
self.running.store(false, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
impl Drop for SpinnerHandle {
fn drop(&mut self) {
self.stop_internal();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spinner_style_frames() {
assert!(!SpinnerStyle::Dots.frames().is_empty());
assert!(!SpinnerStyle::Line.frames().is_empty());
assert!(!SpinnerStyle::Growing.frames().is_empty());
assert!(!SpinnerStyle::Arc.frames().is_empty());
assert!(!SpinnerStyle::Bounce.frames().is_empty());
}
#[test]
fn test_spinner_style_interval() {
assert!(SpinnerStyle::Dots.interval_ms() > 0);
assert!(SpinnerStyle::Line.interval_ms() > 0);
}
#[test]
fn test_spinner_builder() {
let spinner = Spinner::new()
.style(SpinnerStyle::Line)
.message("Loading...");
assert_eq!(spinner.style, SpinnerStyle::Line);
assert_eq!(spinner.message, Some("Loading...".to_string()));
}
#[test]
fn test_spinner_default() {
let spinner = Spinner::default();
assert_eq!(spinner.style, SpinnerStyle::Dots);
assert!(spinner.message.is_none());
}
#[test]
fn test_spinner_start_stop() {
let handle = Spinner::new().start();
std::thread::sleep(Duration::from_millis(100));
handle.stop();
}
#[test]
fn test_spinner_drop_stops() {
{
let _handle = Spinner::new().start();
std::thread::sleep(Duration::from_millis(50));
}
}
}