use std::borrow::Cow;
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex, OnceLock, Weak};
use std::time::{Duration, Instant};
use crate::progress::{Durable, Frame, Note, ProgressSink, TaskId};
use super::arbiter::Sink;
use super::caps::LineCaps;
use super::progress_sink::TerminalProgressSink;
use super::widgets::Level;
pub const SPINNER_TASK: TaskId = "spinner";
const TICK: Duration = Duration::from_millis(100);
static INTERRUPT_PRESSES: AtomicU32 = AtomicU32::new(0);
pub const INTERRUPT_LABEL: &str = "interrupting…";
fn interrupt_label(presses: u32) -> Cow<'static, str> {
if presses <= 1 {
Cow::Borrowed(INTERRUPT_LABEL)
} else {
Cow::Owned(format!(
"{INTERRUPT_LABEL} (×{presses} heard — already stopping)"
))
}
}
pub fn note_interrupt_press() -> u32 {
INTERRUPT_PRESSES.fetch_add(1, Ordering::SeqCst) + 1
}
pub fn set_interrupt_pending(on: bool) {
INTERRUPT_PRESSES.store(u32::from(on), Ordering::SeqCst);
}
pub fn interrupt_pending() -> bool {
interrupt_presses() > 0
}
pub fn interrupt_presses() -> u32 {
INTERRUPT_PRESSES.load(Ordering::SeqCst)
}
fn effective_label(label: &str, presses: u32) -> Cow<'_, str> {
if presses == 0 {
Cow::Borrowed(label)
} else {
interrupt_label(presses)
}
}
fn detail_note(line: &str) -> Note {
Note {
level: Level::Thinking,
glyph: "",
text: format!(" {line}"),
}
}
struct SpinnerState {
start: Instant,
label: Mutex<String>,
chars: AtomicUsize,
line_buf: Mutex<String>,
view: Mutex<TerminalProgressSink>,
}
impl SpinnerState {
fn publish_frame(&self) {
let frame = self.current_frame();
self.view
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.frame(SPINNER_TASK, Self::at_ms(frame.elapsed), &frame);
}
fn current_frame(&self) -> Frame {
let label = self
.label
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
Frame::new(effective_label(&label, interrupt_presses()))
.after(self.start.elapsed())
.with_units(self.chars.load(Ordering::SeqCst) as u64)
}
fn publish_detail_line(&self, line: &str) {
let at = Self::at_ms(self.start.elapsed());
self.view
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.record(SPINNER_TASK, at, &Durable::Note(detail_note(line)));
}
fn elapsed(&self) -> Duration {
self.start.elapsed()
}
fn at_ms(elapsed: Duration) -> u64 {
u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
}
fn tick(&self) {
self.publish_frame();
}
fn finish(&self) {
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']);
let trailing = (!trimmed.trim().is_empty()).then(|| detail_note(trimmed));
self.view
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.finish(trailing);
}
}
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 view = TerminalProgressSink::animating(caps, sink, color)?;
let state = Arc::new(SpinnerState {
start: Instant::now(),
label: Mutex::new(label.to_string()),
chars: AtomicUsize::new(0),
line_buf: Mutex::new(String::new()),
view: Mutex::new(view),
});
register(&state);
Some(Self { state })
}
pub fn elapsed(&self) -> std::time::Duration {
self.state.elapsed()
}
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.publish_detail_line(trimmed);
}
self.state.publish_frame();
}
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::*;
use crate::progress::Commit;
#[test]
fn the_spinner_owns_no_ephemeral_row() {
let src = include_str!("spinner.rs");
let production = src.split(concat!("#[cfg(", "test)]")).next().unwrap_or(src);
for needle in [
concat!("Ephemeral", "Row"),
concat!("Line", "Lease"),
concat!("lease", ".paint("),
concat!("lease", ".erase("),
concat!("lease", ".emit_line("),
concat!("impl Ephemeral", " for"),
] {
assert!(
!production.contains(needle),
"spinner production code still references `{needle}` — the \
renderer owns the ephemeral row after D2b (#1895 constraint 1). \
Two writers on one row is the defect `tty` exists to prevent."
);
}
}
fn committed_by(state: &SpinnerState) -> Vec<crate::progress::Commit> {
state
.view
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.committed()
.to_vec()
}
fn row_is_free() -> bool {
super::TerminalProgressSink::animating(LineCaps::Own, Sink::Stdout, false).is_some()
}
#[test]
fn a_pending_interrupt_overrides_the_stage_label() {
assert_eq!(effective_label("thinking…", 0), "thinking…");
assert_eq!(effective_label("thinking…", 1), INTERRUPT_LABEL);
}
#[test]
fn a_repeated_press_is_labelled_with_its_count() {
assert_eq!(
effective_label("thinking…", 2),
"interrupting… (×2 heard — already stopping)"
);
assert_eq!(
effective_label("thinking…", 7),
"interrupting… (×7 heard — already stopping)"
);
}
#[serial_test::serial(interrupt_pending)]
#[test]
fn interrupt_pending_flag_sets_and_clears() {
set_interrupt_pending(true);
assert!(interrupt_pending());
assert_eq!(interrupt_presses(), 1);
set_interrupt_pending(false);
assert!(!interrupt_pending());
assert_eq!(interrupt_presses(), 0);
}
#[serial_test::serial(interrupt_pending)]
#[test]
fn every_interrupt_press_is_counted() {
set_interrupt_pending(false);
assert_eq!(note_interrupt_press(), 1);
assert_eq!(note_interrupt_press(), 2);
assert_eq!(note_interrupt_press(), 3);
assert_eq!(interrupt_presses(), 3);
set_interrupt_pending(false);
assert_eq!(interrupt_presses(), 0);
}
#[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();
drop(sp);
assert!(row_is_free(), "Drop must tear the spinner down");
state.finish();
assert!(row_is_free());
}
#[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_completed_detail_line_commits_as_an_indented_thinking_note() {
let sp = Spinner::start_with_caps(LineCaps::Own, "thinking…", Sink::Stdout, false)
.expect("spinner");
sp.detail("hello\nwor");
let committed = committed_by(&sp.state);
assert_eq!(
committed,
vec![Commit::Note(Note {
level: Level::Thinking,
glyph: "",
text: " hello".into(),
})],
"only the COMPLETE line commits; the partial one is still buffered"
);
assert_eq!(
super::TerminalProgressSink::line_of(&committed[0]),
" hello",
"the two-space dim indent survives the cutover"
);
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn the_trailing_partial_line_is_flushed_at_teardown_exactly_once() {
let sp = Spinner::start_with_caps(LineCaps::Own, "thinking…", Sink::Stdout, false)
.expect("spinner");
sp.detail("no newline yet");
assert!(
committed_by(&sp.state).is_empty(),
"a partial line is held, not committed"
);
let state = sp.state.clone();
drop(sp);
assert_eq!(
committed_by(&state),
vec![Commit::Note(Note {
level: Level::Thinking,
glyph: "",
text: " no newline yet".into(),
})],
"nothing the model said is lost to the erase"
);
state.finish();
assert_eq!(
committed_by(&state).len(),
1,
"teardown is idempotent — the buffer is drained, not read"
);
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn a_live_spinner_at_frame_rate_commits_nothing_and_retains_nothing() {
let sp = Spinner::start_with_caps(LineCaps::Own, "thinking…", Sink::Stdout, false)
.expect("spinner");
for _ in 0..1_000 {
sp.state.tick();
}
assert!(
committed_by(&sp.state).is_empty(),
"1k frames committed: {:?}",
committed_by(&sp.state)
);
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn the_published_frame_carries_the_stage_label_and_the_units() {
let sp = Spinner::start_with_caps(LineCaps::Own, "thinking…", Sink::Stdout, false)
.expect("spinner");
sp.detail("abcd");
let frame = sp.state.current_frame();
assert_eq!(frame.label, "thinking…");
assert_eq!(
frame.units, 4,
"the `· N chars` tail is a fact, not a string"
);
sp.set_label("compressing context…");
assert_eq!(sp.state.current_frame().label, "compressing context…");
}
#[serial_test::serial(tty_arbiter)]
#[serial_test::serial(interrupt_pending)]
#[test]
fn a_pending_interrupt_reaches_the_view_through_the_frame() {
let sp = Spinner::start_with_caps(LineCaps::Own, "thinking…", Sink::Stdout, false)
.expect("spinner");
set_interrupt_pending(false);
note_interrupt_press();
assert_eq!(sp.state.current_frame().label, INTERRUPT_LABEL);
note_interrupt_press();
assert_eq!(
sp.state.current_frame().label,
"interrupting… (×2 heard — already stopping)"
);
set_interrupt_pending(false);
assert_eq!(sp.state.current_frame().label, "thinking…");
}
#[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"
);
}
}