use std::io::Write;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex, OnceLock, Weak};
use std::time::{Duration, Instant};
use crossterm::queue;
use crossterm::style::{Color as CtColor, Print, ResetColor, SetForegroundColor};
use super::arbiter::{Ephemeral, LineLease, Sink, Terminal};
use super::caps::LineCaps;
use super::{fit_line, format_spinner, term_cols, FADE_CT};
const TICK: Duration = Duration::from_millis(100);
struct SpinnerState {
lease: LineLease,
start: Instant,
label: Mutex<String>,
chars: AtomicUsize,
line_buf: Mutex<String>,
frame: AtomicUsize,
color: bool,
finished: AtomicBool,
}
impl SpinnerState {
fn draw(&self) {
if self.finished.load(Ordering::SeqCst) {
return;
}
let line = format_spinner(
self.frame.load(Ordering::SeqCst),
self.start.elapsed().as_secs_f32(),
&self
.label
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
self.chars.load(Ordering::SeqCst),
);
let fitted = fit_line(&line, term_cols());
let color = self.color;
self.lease.paint(move |w| {
if color {
queue!(
w,
SetForegroundColor(CtColor::DarkGrey),
Print(&fitted.head),
SetForegroundColor(FADE_CT),
Print(&fitted.fade),
Print(fitted.ellipsis),
ResetColor,
)?;
} else {
write!(w, "{}{}{}", fitted.head, fitted.fade, fitted.ellipsis)?;
}
Ok(())
});
}
fn tick(&self) {
self.frame.fetch_add(1, Ordering::SeqCst);
self.draw();
}
fn finish(&self) {
if self.finished.swap(true, Ordering::SeqCst) {
return;
}
let tail = {
let mut buf = self
.line_buf
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
std::mem::take(&mut *buf)
};
let trimmed = tail.trim_end_matches(['\n', '\r']);
if !trimmed.trim().is_empty() {
self.emit_detail_line(trimmed);
}
self.lease.erase();
}
fn emit_detail_line(&self, line: &str) {
let color = self.color;
let owned = line.to_string();
self.lease.emit_line(move |w| {
if color {
queue!(
w,
SetForegroundColor(CtColor::DarkGrey),
Print(" "),
Print(&owned),
ResetColor,
Print("\n"),
)?;
} else {
writeln!(w, " {owned}")?;
}
Ok(())
});
}
}
impl Ephemeral for SpinnerState {
fn erase(&self) {
self.lease.erase();
}
fn restore(&self) {
}
}
type Registry = (Mutex<Vec<Weak<SpinnerState>>>, Condvar);
fn ticker() -> &'static Registry {
static TICKER: OnceLock<Registry> = OnceLock::new();
TICKER.get_or_init(|| {
let reg: Registry = (Mutex::new(Vec::new()), Condvar::new());
reg
})
}
fn ensure_ticker_thread() {
static STARTED: OnceLock<()> = OnceLock::new();
STARTED.get_or_init(|| {
std::thread::Builder::new()
.name("newt-tty-ticker".to_string())
.spawn(|| loop {
let (m, cv) = ticker();
let live: Vec<Arc<SpinnerState>> = {
let mut list = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
while list.is_empty() {
list = cv
.wait(list)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
list.retain(|w| w.strong_count() > 0);
list.iter().filter_map(Weak::upgrade).collect()
};
for s in &live {
s.tick();
}
drop(live);
std::thread::sleep(TICK);
})
.ok();
});
}
fn register(state: &Arc<SpinnerState>) {
ensure_ticker_thread();
let (m, cv) = ticker();
m.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(Arc::downgrade(state));
cv.notify_all();
}
pub struct Spinner {
state: Arc<SpinnerState>,
}
impl Spinner {
pub fn start(label: &str, sink: Sink, color: bool) -> Option<Self> {
Self::start_with_caps(super::caps::detect(), label, sink, color)
}
pub fn start_with_caps(caps: LineCaps, label: &str, sink: Sink, color: bool) -> Option<Self> {
let lease = Terminal::lease_with_caps(caps, sink)?;
let state = Arc::new(SpinnerState {
lease,
start: Instant::now(),
label: Mutex::new(label.to_string()),
chars: AtomicUsize::new(0),
line_buf: Mutex::new(String::new()),
frame: AtomicUsize::new(0),
color,
finished: AtomicBool::new(false),
});
let as_ephemeral: Arc<dyn Ephemeral> = state.clone();
Terminal::register(
Arc::as_ptr(&state) as *const () as usize as u64,
&as_ephemeral,
);
register(&state);
Some(Self { state })
}
pub fn set_label(&self, label: &str) {
let mut cur = self
.state
.label
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*cur = label.to_string();
}
pub fn detail(&self, chunk: &str) {
self.state
.chars
.fetch_add(chunk.chars().count(), Ordering::SeqCst);
let mut ready: Vec<String> = Vec::new();
{
let mut buf = self
.state
.line_buf
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
buf.push_str(chunk);
while let Some(nl) = buf.find('\n') {
let line: String = buf.drain(..=nl).collect();
ready.push(line);
}
}
for line in ready {
let trimmed = line.trim_end_matches(['\n', '\r']);
if trimmed.trim().is_empty() {
continue;
}
self.state.emit_detail_line(trimmed);
}
self.state.draw();
}
pub fn finish(self) {
}
}
impl Drop for Spinner {
fn drop(&mut self) {
self.state.finish();
}
}
pub async fn with_spinner<F: std::future::Future>(
caps: LineCaps,
label: &str,
sink: Sink,
color: bool,
fut: F,
) -> F::Output {
let _spinner = Spinner::start_with_caps(caps, label, sink, color);
fut.await
}
#[cfg(test)]
mod tests {
use super::*;
#[serial_test::serial(tty_arbiter)]
#[test]
fn no_capability_yields_no_spinner() {
assert!(
Spinner::start_with_caps(LineCaps::None, "thinking…", Sink::Stdout, true).is_none()
);
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn teardown_is_idempotent_and_unforgettable() {
let sp = Spinner::start_with_caps(LineCaps::Own, "thinking…", Sink::Stdout, false)
.expect("Own yields a spinner");
let state = sp.state.clone();
assert!(!state.finished.load(Ordering::SeqCst));
drop(sp);
assert!(
state.finished.load(Ordering::SeqCst),
"Drop must tear the spinner down"
);
state.finish();
assert!(state.finished.load(Ordering::SeqCst));
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn dropping_a_spinner_releases_the_line_for_the_next_writer() {
let sp = Spinner::start_with_caps(LineCaps::Own, "thinking…", Sink::Stdout, false)
.expect("spinner");
assert!(
Spinner::start_with_caps(LineCaps::Own, "other…", Sink::Stdout, false).is_none(),
"one line, one writer"
);
drop(sp);
assert!(
Spinner::start_with_caps(LineCaps::Own, "next…", Sink::Stdout, false).is_some(),
"the line is free again"
);
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn the_label_changes_without_restarting_the_clock() {
let sp = Spinner::start_with_caps(LineCaps::Own, "thinking…", Sink::Stdout, false)
.expect("spinner");
let started = sp.state.start;
sp.set_label("compressing context…");
assert_eq!(
*sp.state
.label
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
"compressing context…"
);
assert_eq!(sp.state.start, started, "the clock is not reset");
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn detail_buffers_partial_lines_and_counts_every_char() {
let sp = Spinner::start_with_caps(LineCaps::Own, "thinking…", Sink::Stdout, false)
.expect("spinner");
sp.detail("abc");
assert_eq!(sp.state.chars.load(Ordering::SeqCst), 3);
assert_eq!(
*sp.state
.line_buf
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
"abc",
"a line with no newline yet is held, not emitted"
);
sp.detail("de\nfg");
assert_eq!(sp.state.chars.load(Ordering::SeqCst), 8);
assert_eq!(
*sp.state
.line_buf
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
"fg",
"the completed line flushed; the remainder is still buffered"
);
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn a_cancelled_covered_future_still_erases() {
let fut = with_spinner(
LineCaps::Own,
"thinking…",
Sink::Stdout,
false,
std::future::pending::<()>(),
);
let mut fut = Box::pin(fut);
let waker = std::task::Waker::noop();
let mut cx = std::task::Context::from_waker(waker);
assert!(std::future::Future::poll(fut.as_mut(), &mut cx).is_pending());
assert!(
Spinner::start_with_caps(LineCaps::Own, "other…", Sink::Stdout, false).is_none(),
"the covered future holds the line while it runs"
);
drop(fut);
assert!(
Spinner::start_with_caps(LineCaps::Own, "after…", Sink::Stdout, false).is_some(),
"cancelling the covered future must release and erase the line"
);
}
}