use std::io::{IsTerminal, Write};
use std::sync::Mutex;
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
const FRAMES: [char; 4] = ['|', '/', '-', '\\'];
const GRACE_PERIOD: Duration = Duration::from_secs(1);
const FRAME_INTERVAL: Duration = Duration::from_millis(120);
struct SpinnerHandle {
stop_tx: Sender<()>,
thread: JoinHandle<()>,
}
static ACTIVE: Mutex<Option<SpinnerHandle>> = Mutex::new(None);
fn lock_active() -> std::sync::MutexGuard<'static, Option<SpinnerHandle>> {
ACTIVE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn past_grace_period(elapsed: Duration, grace: Duration) -> bool {
elapsed >= grace
}
fn render_frame(frame: usize, message: &str) -> String {
format!("\r{} {}", FRAMES[frame % FRAMES.len()], message)
}
fn render_clear(message: &str) -> String {
format!("\r{:width$}\r", "", width = message.len() + 2)
}
fn run_loop(
out: &mut impl Write,
stop_rx: &Receiver<()>,
message: &str,
grace: Duration,
interval: Duration,
) {
let started = Instant::now();
let mut frame = 0usize;
let mut drawn = false;
loop {
match stop_rx.recv_timeout(interval) {
Ok(()) | Err(RecvTimeoutError::Disconnected) => break,
Err(RecvTimeoutError::Timeout) => {}
}
if !past_grace_period(started.elapsed(), grace) {
continue;
}
let _ = out.write_all(render_frame(frame, message).as_bytes());
let _ = out.flush();
drawn = true;
frame += 1;
}
if drawn {
let _ = out.write_all(render_clear(message).as_bytes());
let _ = out.flush();
}
}
pub fn start(message: String) {
if !std::io::stderr().is_terminal() {
return;
}
start_unchecked(message);
}
fn start_unchecked(message: String) {
{
let mut active = lock_active();
if active.is_some() {
return;
}
*active = None;
}
let (stop_tx, stop_rx) = mpsc::channel::<()>();
let spawned = std::thread::Builder::new()
.name("mux-spinner".to_string())
.spawn(move || {
run_loop(
&mut std::io::stderr(),
&stop_rx,
&message,
GRACE_PERIOD,
FRAME_INTERVAL,
);
});
let mut active = lock_active();
if let Ok(thread) = spawned {
*active = Some(SpinnerHandle { stop_tx, thread });
}
}
pub fn stop() {
let handle = lock_active().take();
if let Some(handle) = handle {
let _ = handle.stop_tx.send(());
let _ = handle.thread.join();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frames_cycle_through_ascii_and_wrap() {
assert_eq!(render_frame(0, "compiling x"), "\r| compiling x");
assert_eq!(render_frame(1, "compiling x"), "\r/ compiling x");
assert_eq!(render_frame(2, "compiling x"), "\r- compiling x");
assert_eq!(render_frame(3, "compiling x"), "\r\\ compiling x");
assert_eq!(render_frame(4, "compiling x"), "\r| compiling x");
}
#[test]
fn clear_overwrites_frame_and_message_with_spaces() {
let clear = render_clear("hi");
assert!(clear.starts_with('\r') && clear.ends_with('\r'));
assert_eq!(clear.len(), 2 + "hi".len() + 2);
assert!(clear[1..clear.len() - 1].chars().all(|c| c == ' '));
}
#[test]
fn grace_period_gates_drawing() {
assert!(!past_grace_period(Duration::ZERO, GRACE_PERIOD));
assert!(!past_grace_period(
GRACE_PERIOD - Duration::from_millis(1),
GRACE_PERIOD
));
assert!(past_grace_period(GRACE_PERIOD, GRACE_PERIOD));
assert!(past_grace_period(GRACE_PERIOD * 2, GRACE_PERIOD));
}
#[test]
fn run_loop_draws_after_grace_and_clears_line() {
let (stop_tx, stop_rx) = mpsc::channel::<()>();
let stopper = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(50));
let _ = stop_tx.send(());
});
let mut out: Vec<u8> = Vec::new();
run_loop(
&mut out,
&stop_rx,
"msg",
Duration::ZERO,
Duration::from_millis(1),
);
stopper.join().expect("stopper thread");
let text = String::from_utf8(out).expect("spinner output is ASCII");
assert!(text.contains("\r| msg"), "missing first frame: {text:?}");
assert!(
text.ends_with(&render_clear("msg")),
"line not cleared at the end: {text:?}"
);
}
#[test]
fn run_loop_stays_silent_inside_grace_period() {
let (stop_tx, stop_rx) = mpsc::channel::<()>();
drop(stop_tx);
let mut out: Vec<u8> = Vec::new();
run_loop(
&mut out,
&stop_rx,
"msg",
GRACE_PERIOD,
Duration::from_millis(1),
);
assert!(out.is_empty(), "drew inside grace period: {out:?}");
}
#[test]
fn global_start_and_stop_lifecycle() {
stop();
assert!(lock_active().is_none());
start_unchecked("test spinner".to_string());
assert!(lock_active().is_some());
start_unchecked("ignored while active".to_string());
stop();
assert!(lock_active().is_none());
stop();
start("tty dependent".to_string());
stop();
assert!(lock_active().is_none());
}
}