use std::io::{self, IsTerminal, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::watch;
use crate::output;
use crate::ui::animations::SPINNER_DOTS;
pub fn supports_ansi() -> bool {
if !io::stdout().is_terminal() {
return false;
}
match std::env::var("TERM") {
Ok(term) => !term.is_empty() && term != "dumb",
Err(_) => false, }
}
pub fn supports_color() -> bool {
if std::env::var("NO_COLOR").is_ok() {
return false;
}
supports_ansi()
}
pub struct TerminalSpinner {
stop_signal: Arc<AtomicBool>,
message_tx: watch::Sender<String>,
handle: Option<tokio::task::JoinHandle<()>>,
start_time: Instant,
}
impl TerminalSpinner {
pub fn start(message: &str) -> Self {
if output::is_tui_active()
|| output::is_compact()
|| !io::stdout().is_terminal()
|| !supports_ansi()
{
return Self {
stop_signal: Arc::new(AtomicBool::new(true)),
message_tx: watch::channel(String::new()).0,
handle: None,
start_time: Instant::now(),
};
}
let stop_signal = Arc::new(AtomicBool::new(false));
let (message_tx, message_rx) = watch::channel(message.to_string());
let stop = stop_signal.clone();
let start = Instant::now();
let handle = tokio::spawn(async move {
let frames = SPINNER_DOTS;
let mut tick: usize = 0;
loop {
if stop.load(Ordering::Relaxed) {
break;
}
let frame = frames[tick % frames.len()];
let msg = message_rx.borrow().clone();
let elapsed = start.elapsed().as_secs_f64();
let line = format!(" {} {} ({:.1}s)", frame, msg, elapsed);
print!("\r\x1b[2K{}", line);
io::stdout().flush().ok();
tick += 1;
tokio::time::sleep(tokio::time::Duration::from_millis(80)).await;
}
});
Self {
stop_signal,
message_tx,
handle: Some(handle),
start_time: Instant::now(),
}
}
pub fn set_message(&self, msg: &str) {
let _ = self.message_tx.send(msg.to_string());
}
pub fn stop_success(self, message: &str) {
if supports_color() {
self.stop_with_icon("\x1b[32m\u{2714}\x1b[0m", message); } else {
self.stop_with_icon("\u{2714}", message); }
}
pub fn stop_error(self, message: &str) {
if supports_color() {
self.stop_with_icon("\x1b[31m\u{2715}\x1b[0m", message); } else {
self.stop_with_icon("\u{2715}", message); }
}
fn stop_with_icon(mut self, icon: &str, message: &str) {
self.stop_signal.store(true, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
handle.abort();
std::thread::sleep(std::time::Duration::from_millis(10));
}
if !output::is_tui_active() && !output::is_compact() && io::stdout().is_terminal() {
let elapsed = self.start_time.elapsed().as_secs_f64();
print!("\r\x1b[2K");
println!(" {} {} ({:.1}s)", icon, message, elapsed);
io::stdout().flush().ok();
}
}
pub fn elapsed(&self) -> std::time::Duration {
self.start_time.elapsed()
}
}
impl Drop for TerminalSpinner {
fn drop(&mut self) {
self.stop_signal.store(true, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
handle.abort();
}
if !output::is_compact() && io::stdout().is_terminal() {
print!("\r\x1b[2K");
io::stdout().flush().ok();
}
}
}
#[cfg(test)]
#[path = "../../tests/unit/ui/spinner/spinner_test.rs"]
mod tests;