fix: fix spinner ctrl-c early exit not clearing message and restoring cursor

This commit is contained in:
Dark-Alex-17 committed 2026-09-11 13:01:27 -06:00
1 parent 3e91813cff
commit 2f85462e2a
1 file changed
+97 -1
+97 -1
View File
@@ -2,6 +2,11 @@ use super::{AbortSignal, IS_STDOUT_TERMINAL, poll_abort_signal, wait_abort_signa
use anyhow::{Result, bail};
use crossterm::{cursor, queue, style, terminal};
#[cfg(test)]
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use std::{
future::Future,
io::{Write, stdout},
@@ -19,11 +24,22 @@ use tokio::{
pub struct SpinnerInner {
index: usize,
message: String,
#[cfg(test)]
cleared: Option<Arc<AtomicUsize>>,
}
impl SpinnerInner {
const DATA: [&'static str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
#[cfg(test)]
fn observed(cleared: Arc<AtomicUsize>) -> Self {
Self {
index: 0,
message: String::new(),
cleared: Some(cleared),
}
}
fn step(&mut self) -> Result<()> {
if !*IS_STDOUT_TERMINAL || self.message.is_empty() {
return Ok(());
@@ -50,10 +66,17 @@ impl SpinnerInner {
}
fn clear_message(&mut self) -> Result<()> {
if !*IS_STDOUT_TERMINAL || self.message.is_empty() {
if self.message.is_empty() {
return Ok(());
}
self.message.clear();
#[cfg(test)]
if let Some(cleared) = &self.cleared {
cleared.fetch_add(1, Ordering::SeqCst);
}
if !*IS_STDOUT_TERMINAL {
return Ok(());
}
let mut writer = stdout();
queue!(
writer,
@@ -66,6 +89,17 @@ impl SpinnerInner {
}
}
/// Restores the terminal when the spinner goes away by any route, not just the
/// loop's normal exit: a future cancelled mid-spin (the surrounding task was
/// aborted) or an early `?` return would otherwise leave the last frame on
/// screen and the cursor hidden. `clear_message` is a no-op once the message
/// is gone, so a normal-path clear leaves nothing for the drop to redo.
impl Drop for SpinnerInner {
fn drop(&mut self) {
let _ = self.clear_message();
}
}
#[derive(Clone)]
pub struct Spinner(mpsc::UnboundedSender<SpinnerEvent>);
@@ -215,3 +249,65 @@ async fn run_abortable_spinner(
spinner.clear_message()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn observed(message: &str) -> (SpinnerInner, Arc<AtomicUsize>) {
let cleared = Arc::new(AtomicUsize::new(0));
let mut spinner = SpinnerInner::observed(cleared.clone());
spinner.set_message(message.to_string()).unwrap();
(spinner, cleared)
}
#[test]
fn drop_with_active_message_clears_once() {
let (spinner, cleared) = observed("Thinking");
assert_eq!(cleared.load(Ordering::SeqCst), 0);
drop(spinner);
assert_eq!(cleared.load(Ordering::SeqCst), 1);
}
#[test]
fn explicit_clear_then_drop_clears_exactly_once() {
let (mut spinner, cleared) = observed("Thinking");
spinner.clear_message().unwrap();
assert_eq!(cleared.load(Ordering::SeqCst), 1);
drop(spinner);
assert_eq!(cleared.load(Ordering::SeqCst), 1);
}
#[test]
fn drop_without_message_is_silent() {
let (spinner, cleared) = observed("");
drop(spinner);
assert_eq!(cleared.load(Ordering::SeqCst), 0);
}
#[test]
fn set_message_replaces_and_clears_previous_once() {
let (mut spinner, cleared) = observed("Thinking");
spinner.set_message("Fetching".to_string()).unwrap();
assert_eq!(cleared.load(Ordering::SeqCst), 1);
drop(spinner);
assert_eq!(cleared.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn cancelled_task_restores_terminal_on_drop() {
let (spinner, cleared) = observed("Thinking");
let task = tokio::spawn(async move {
let _spinner = spinner;
std::future::pending::<()>().await;
});
tokio::task::yield_now().await;
assert_eq!(cleared.load(Ordering::SeqCst), 0);
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
assert_eq!(cleared.load(Ordering::SeqCst), 1);
}
}