rtb_tui/spinner.rs
1//! [`Spinner`] — TTY-aware progress indicator.
2
3use console::Term;
4
5/// TTY-aware spinner.
6///
7/// When stderr isn't a terminal, every method on this type is a
8/// no-op — important for CI logs and tools running behind an
9/// MCP-stdio transport (where stderr is captured by the MCP client
10/// and arbitrary control sequences would corrupt logs).
11///
12/// The spinner is single-threaded by design: there is no internal
13/// `tokio::task::spawn` that animates frames. Callers tick the
14/// spinner manually via [`Self::set_message`] between awaits — see
15/// W3 in the spec for the rationale.
16pub struct Spinner {
17 /// `None` when stderr isn't a TTY — every method short-circuits.
18 term: Option<Term>,
19 /// Current message; tracked so [`Self::finish`] can clear the
20 /// previously-rendered line.
21 message: String,
22 /// Whether [`Self::finish`] has already run; the `Drop` impl
23 /// short-circuits when this is set.
24 finished: bool,
25}
26
27impl Spinner {
28 /// Construct a spinner with `msg` and emit the initial frame.
29 /// When stderr isn't a TTY, this constructs the no-op variant
30 /// and writes nothing.
31 #[must_use]
32 pub fn new(msg: impl Into<String>) -> Self {
33 let term = Term::stderr();
34 let active = term.is_term().then_some(term);
35 let message = msg.into();
36 let s = Self { term: active, message, finished: false };
37 s.draw();
38 s
39 }
40
41 /// Update the message and redraw. No-op when not a TTY.
42 pub fn set_message(&mut self, msg: impl Into<String>) {
43 self.message = msg.into();
44 self.draw();
45 }
46
47 /// Stop spinning and clear the rendered line. Idempotent —
48 /// safe to call multiple times. The `Drop` impl invokes this
49 /// automatically.
50 pub fn finish(mut self) {
51 self.finish_inner();
52 }
53
54 fn finish_inner(&mut self) {
55 if self.finished {
56 return;
57 }
58 self.finished = true;
59 if let Some(term) = &self.term {
60 // Clear the current line so subsequent stderr output
61 // starts at column 0.
62 let _ = term.clear_line();
63 }
64 }
65
66 fn draw(&self) {
67 if let Some(term) = &self.term {
68 // `\r` + clear-line + write — keeps the spinner pinned
69 // to the same line across redraws.
70 let _ = term.clear_line();
71 let _ = term.write_str(&format!("⠋ {}", self.message));
72 }
73 }
74}
75
76impl Drop for Spinner {
77 fn drop(&mut self) {
78 self.finish_inner();
79 }
80}