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}