Why the spinner does not animate¶
What it actually does¶
Spinner draws one line on stderr: the Braille glyph ⠋, a space,
and the current message. It redraws that line when you call
set_message, and it clears it when it is finished or dropped. There
is no frame sequence, no timer, and no thread or task advancing
anything.
Calling it a spinner is a small lie. It is a status line that happens to start with a spinner-shaped character.
Why there is no background task¶
The obvious implementation spawns a task that redraws a rotating glyph every eighty milliseconds. That buys a moving indicator and costs three things this crate is not willing to pay.
It picks a runtime for you. Spawning needs an executor.
tokio::task::spawn in the library body would make tokio a hard
dependency of every consumer and would panic outside a tokio runtime.
As it stands tokio is a dev-dependency only, and Wizard::run is a
plain async fn that any executor can drive.
It races the thing it is decorating. A background writer to stderr and a foreground writer to stderr interleave. The moment your code logs a warning mid-operation, the log line and a spinner frame land on the same row and both become unreadable. A spinner that only draws when you tell it to cannot do that, because the drawing happens on your thread between your own writes.
It needs a shutdown path. A spawned task has to be cancelled on
every exit route — success, error, panic, early return — and getting
that wrong leaves a task drawing frames over the shell prompt after the
program has finished. The manual-tick design has no such path to get
wrong: the Drop impl clears the line, and it runs wherever the value
goes out of scope.
What that costs¶
A long await between two set_message calls shows a static line for
its whole duration, and a user cannot tell a slow download from a hung
one. That is a real loss, and the mitigation is coarse: split the work
into more stages so there is something to report more often.
If a genuinely moving indicator matters more than the three problems
above, this is not the crate for it. indicatif does the spawned-task
version well, and nothing stops you using it directly.
Why the TTY check is in the constructor¶
Spinner::new calls console::Term::stderr() and stores the handle
only if is_term() is true. Every method then short-circuits on the
None.
Deciding once, at construction, rather than on every draw means the answer cannot change halfway through an operation and produce a line that is half-drawn and half-suppressed. It also means the cost is paid once.
The reason it matters at all is the transports these tools run behind. A CI job log keeps every escape sequence verbatim, so an animated spinner becomes several thousand lines of noise in the artefact you read when something breaks. Worse, a tool speaking MCP over stdio has its stderr captured by the client — arbitrary control sequences in that stream corrupt the client's own logs. Neither of those is an environment where "just render it anyway" is acceptable.
The same check is why the crate's test suite can drive a spinner at
all: cargo test captures stderr, is_term() is false, and the
spinner is inert. That is also its blind spot — the drawing path is
never exercised by an automated test, because there is no way to make
the spinner believe it has a terminal.
Why the cleanup is idempotent but finish is not repeatable¶
finish takes self by value, so the compiler prevents a second call.
Underneath, a finished flag guards the actual clear-line, and Drop
runs the same guarded cleanup.
That combination is deliberate. finish is there so you can decide
when the line disappears rather than having it happen at the end of
the scope, and Drop is there so that an early return or a ? on an
error still leaves the terminal clean. The flag is what stops the two
paths clearing the line twice.
The rustdoc describing finish as "safe to call multiple times"
describes the flag rather than the method, and reads as a promise the
signature does not keep. It is listed in
Known documentation defects.