Skip to content

Spinner

A one-line progress indicator on stderr that turns itself off when stderr is not a terminal.

pub struct Spinner { /* private fields */ }

impl Spinner {
    #[must_use]
    pub fn new(msg: impl Into<String>) -> Self;

    pub fn set_message(&mut self, msg: impl Into<String>);

    pub fn finish(self);
}

impl Drop for Spinner { /* calls the same cleanup as finish */ }

What Spinner writes to the terminal

Every draw is a clear-line followed by and the current message. The frame character is the single Braille glyph and it never changes — there is no frame sequence and no animation. See Why the spinner does not animate.

Everything goes to stderr. Nothing is written to stdout, which is what makes it safe to pipe a command's stdout into another program while the spinner is running.

finish clears the line and writes nothing in its place. The cursor ends at column 0 of a now-empty line, so whatever you print next starts cleanly. If you want a "done" message, print it yourself after finishing.

Write failures are ignored. Every terminal call is discarded with let _ = ..., so a closed or failing stderr silently produces no output rather than returning an error or panicking.

What happens when stderr is not a terminal

Spinner::new asks console::Term::stderr() whether it is a terminal. If it is not, the spinner stores no terminal handle and every method becomes a no-op: no escape sequences, no frame characters, no whitespace. That covers CI job logs, output redirected to a file, and tools speaking a protocol over stdio such as MCP.

This is decided once, in the constructor. Changing the terminal state afterwards has no effect on a spinner that already exists.

The check is also why the crate's own tests can construct and drive a spinner: cargo test captures stderr, so is_term() is false and the spinner is inert.

Calling finish and the Drop impl

finish takes self by value. It can therefore be called at most once on a given spinner — a second call will not compile, because the first one moved it.

What is idempotent is the cleanup itself. Internally a finished flag guards it, and Drop runs the same cleanup, so:

  • calling finish and then letting the value drop clears the line once, not twice;
  • dropping a spinner without calling finish still clears the line.

finish exists to let you control when the line is cleared rather than leaving it to the end of the enclosing scope.

The rustdoc on finish describes it as "safe to call multiple times", which the signature does not permit — see Known documentation defects.

Making the spinner move

Nothing in the crate advances the spinner on a timer. The glyph is redrawn only when you call set_message, and it is the same glyph each time, so the visible change is the text:

let mut spinner = Spinner::new("resolving dependencies…");
let manifest = fetch_manifest().await?;
spinner.set_message("downloading…");
let archive = download(&manifest).await?;
spinner.set_message("verifying signature…");
verify(&archive)?;
spinner.finish();

A long await between two set_message calls shows a static line for its whole duration. If that matters, break the work into more stages and call set_message more often.

Threading and concurrency

set_message takes &mut self, so the spinner is driven from one place at a time. There is no background task, no thread and no internal locking; if two async tasks need to report progress, give them one owner between them rather than two spinners.