use std::io::{self, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEvent};
use crossterm::terminal;
pub struct Spinner {
running: Arc<AtomicBool>,
cancelled: Arc<AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl Spinner {
pub fn new(message: impl Into<String>) -> Self {
Self::builder().message(message).build()
}
pub fn builder() -> SpinnerBuilder {
SpinnerBuilder::default()
}
pub fn stop(mut self) -> bool {
self.running.store(false, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
self.cancelled.load(Ordering::SeqCst)
}
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::SeqCst)
}
}
impl Drop for Spinner {
fn drop(&mut self) {
self.running.store(false, Ordering::SeqCst);
}
}
pub struct SpinnerBuilder {
message: String,
frames: Vec<&'static str>,
interval: Duration,
cancellable: bool,
cancel_key: KeyCode,
}
impl Default for SpinnerBuilder {
fn default() -> Self {
Self {
message: "Loading...".to_string(),
frames: vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
interval: Duration::from_millis(80),
cancellable: true,
cancel_key: KeyCode::Esc,
}
}
}
impl SpinnerBuilder {
pub fn message(mut self, message: impl Into<String>) -> Self {
self.message = message.into();
self
}
pub fn frames(mut self, frames: Vec<&'static str>) -> Self {
self.frames = frames;
self
}
pub fn interval(mut self, interval: Duration) -> Self {
self.interval = interval;
self
}
pub fn cancellable(mut self, cancellable: bool) -> Self {
self.cancellable = cancellable;
self
}
pub fn cancel_key(mut self, key: KeyCode) -> Self {
self.cancel_key = key;
self
}
pub fn build(self) -> Spinner {
let running = Arc::new(AtomicBool::new(true));
let cancelled = Arc::new(AtomicBool::new(false));
let running_clone = running.clone();
let cancelled_clone = cancelled.clone();
let handle = std::thread::spawn(move || {
let mut i = 0;
if self.cancellable {
let _ = terminal::enable_raw_mode();
}
while running_clone.load(Ordering::Relaxed) {
if self.cancellable {
if event::poll(self.interval).unwrap_or(false) {
if let Ok(Event::Key(KeyEvent { code, .. })) = event::read() {
if code == self.cancel_key {
cancelled_clone.store(true, Ordering::SeqCst);
running_clone.store(false, Ordering::SeqCst);
break;
}
}
}
} else {
std::thread::sleep(self.interval);
}
let cancel_hint = if self.cancellable {
" \x1b[2m(ESC to cancel)\x1b[0m".to_string()
} else {
String::new()
};
print!(
"\x1b[2K\r\x1b[33m{} {}{}\x1b[0m",
self.frames[i], self.message, cancel_hint
);
let _ = io::stdout().flush();
i = (i + 1) % self.frames.len();
}
if self.cancellable {
let _ = terminal::disable_raw_mode();
}
print!("\x1b[2K\r");
let _ = io::stdout().flush();
});
Spinner {
running,
cancelled,
handle: Some(handle),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spinner_creation() {
let spinner = Spinner::new("Testing...");
assert!(!spinner.is_cancelled());
spinner.stop();
}
#[test]
fn test_spinner_builder() {
let spinner = Spinner::builder()
.message("Custom message")
.cancellable(false)
.build();
assert!(!spinner.is_cancelled());
spinner.stop();
}
}