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; item
15/// W3 of [spec 0020] records why.
16///
17/// [spec 0020]: https://gitlab.com/phpboyscout/rust-tool-base/-/wikis/specs/0020-rtb-tui-v0.1
18pub struct Spinner {
19    /// `None` when stderr isn't a TTY — every method short-circuits.
20    term: Option<Term>,
21    /// Current message; tracked so [`Self::finish`] can clear the
22    /// previously-rendered line.
23    message: String,
24    /// Whether [`Self::finish`] has already run; the `Drop` impl
25    /// short-circuits when this is set.
26    finished: bool,
27}
28
29impl Spinner {
30    /// Construct a spinner with `msg` and emit the initial frame.
31    /// When stderr isn't a TTY, this constructs the no-op variant
32    /// and writes nothing.
33    #[must_use]
34    pub fn new(msg: impl Into<String>) -> Self {
35        let term = Term::stderr();
36        let active = term.is_term().then_some(term);
37        let message = msg.into();
38        let s = Self { term: active, message, finished: false };
39        s.draw();
40        s
41    }
42
43    /// Update the message and redraw. No-op when not a TTY.
44    pub fn set_message(&mut self, msg: impl Into<String>) {
45        self.message = msg.into();
46        self.draw();
47    }
48
49    /// Stop spinning and clear the rendered line. Consumes the
50    /// spinner; dropping it without calling `finish` clears the line
51    /// the same way.
52    pub fn finish(mut self) {
53        self.finish_inner();
54    }
55
56    fn finish_inner(&mut self) {
57        if self.finished {
58            return;
59        }
60        self.finished = true;
61        if let Some(term) = &self.term {
62            // Clear the current line so subsequent stderr output
63            // starts at column 0.
64            let _ = term.clear_line();
65        }
66    }
67
68    fn draw(&self) {
69        if let Some(term) = &self.term {
70            // `\r` + clear-line + write — keeps the spinner pinned
71            // to the same line across redraws.
72            let _ = term.clear_line();
73            let _ = term.write_str(&format!("⠋ {}", self.message));
74        }
75    }
76}
77
78impl Drop for Spinner {
79    fn drop(&mut self) {
80        self.finish_inner();
81    }
82}