Skip to main content

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}