radicle_term/
spinner.rs

1use std::io::IsTerminal;
2use std::mem::ManuallyDrop;
3use std::sync::{Arc, LazyLock, Mutex};
4use std::{fmt, io, thread, time};
5
6use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
7
8use crate::{Paint, PaintTarget};
9
10/// How much time to wait between spinner animation updates.
11pub const DEFAULT_TICK: time::Duration = time::Duration::from_millis(120);
12/// The spinner animation strings.
13pub const DEFAULT_STYLE: [Paint<&'static str>; 4] = [
14    Paint::magenta("◢"),
15    Paint::cyan("◣"),
16    Paint::magenta("◤"),
17    Paint::blue("◥"),
18];
19static TEMPLATE: LazyLock<ProgressStyle> =
20    LazyLock::new(|| ProgressStyle::with_template("{spinner:.blue} {msg}").unwrap());
21
22impl From<PaintTarget> for ProgressDrawTarget {
23    fn from(value: PaintTarget) -> Self {
24        match value {
25            PaintTarget::Stdout => ProgressDrawTarget::stdout(),
26            PaintTarget::Stderr => ProgressDrawTarget::stderr(),
27            PaintTarget::Hidden => ProgressDrawTarget::hidden(),
28        }
29    }
30}
31
32enum State {
33    Running,
34    Canceled,
35    Done,
36    Warn,
37    Error,
38}
39
40struct Progress {
41    state: State,
42    message: Paint<String>,
43}
44
45impl Progress {
46    fn new(message: Paint<String>) -> Self {
47        Self {
48            state: State::Running,
49            message,
50        }
51    }
52}
53
54/// A progress spinner.
55pub struct Spinner {
56    progress: Arc<Mutex<Progress>>,
57    handle: ManuallyDrop<thread::JoinHandle<()>>,
58}
59
60impl Drop for Spinner {
61    fn drop(&mut self) {
62        if let Ok(mut progress) = self.progress.lock() {
63            if let State::Running = progress.state {
64                progress.state = State::Canceled;
65            }
66        }
67
68        unsafe { ManuallyDrop::take(&mut self.handle) }
69            .join()
70            .unwrap();
71    }
72}
73
74impl Spinner {
75    /// Mark the spinner as successfully completed.
76    pub fn finish(self) {
77        if let Ok(mut progress) = self.progress.lock() {
78            progress.state = State::Done;
79        }
80    }
81
82    /// Mark the spinner as failed. This cancels the spinner.
83    pub fn failed(self) {
84        if let Ok(mut progress) = self.progress.lock() {
85            progress.state = State::Error;
86        }
87    }
88
89    /// Cancel the spinner with an error.
90    pub fn error(self, msg: impl fmt::Display) {
91        if let Ok(mut progress) = self.progress.lock() {
92            progress.state = State::Error;
93            progress.message = Paint::new(format!(
94                "{} {} {}",
95                progress.message,
96                Paint::red("error:"),
97                msg
98            ));
99        }
100    }
101
102    /// Cancel the spinner with a warning sign.
103    pub fn warn(self) {
104        if let Ok(mut progress) = self.progress.lock() {
105            progress.state = State::Warn;
106        }
107    }
108
109    /// Set the spinner's message.
110    pub fn message(&mut self, msg: impl fmt::Display) {
111        let msg = msg.to_string();
112
113        if let Ok(mut progress) = self.progress.lock() {
114            progress.message = Paint::new(msg);
115        }
116    }
117}
118
119/// Create a new spinner with the given message. Sends animation output to `stderr` and success or
120/// failure messages to `stdout`. This function handles signals, with there being only one
121/// element handling signals at a time, and is a wrapper to [`spinner_to()`].
122pub fn spinner(message: impl ToString) -> Spinner {
123    if io::stderr().is_terminal() {
124        spinner_to(message, PaintTarget::Stderr, PaintTarget::Stdout)
125    } else {
126        spinner_to(message, PaintTarget::Hidden, PaintTarget::Stdout)
127    }
128}
129
130/// Create a new spinner with the given message, and send output to the given writers.
131///
132/// # Signal Handling
133///
134/// This will install handlers for the spinner until cancelled or dropped, with there
135/// being only one element handling signals at a time. If the spinner cannot install
136/// handlers, then it will not attempt to install handlers again, and continue running.
137pub fn spinner_to(
138    message: impl ToString,
139    progress_target: PaintTarget,
140    completion_target: PaintTarget,
141) -> Spinner {
142    let message = message.to_string();
143    let progress = Arc::new(Mutex::new(Progress::new(Paint::new(message.clone()))));
144
145    #[cfg(unix)]
146    let (sig_tx, sig_rx) = crossbeam_channel::unbounded();
147
148    #[cfg(unix)]
149    let sig_result = radicle_signals::install(sig_tx);
150
151    let handle = thread::Builder::new()
152        .name(String::from("spinner"))
153        .spawn({
154            let progress = progress.clone();
155            let spinner = ProgressBar::new_spinner();
156
157            spinner.set_draw_target(progress_target.into());
158            spinner.set_message(message.to_string());
159            spinner.set_style(TEMPLATE.clone().tick_strings(&[
160                DEFAULT_STYLE[0].to_string().as_str(),
161                DEFAULT_STYLE[1].to_string().as_str(),
162                DEFAULT_STYLE[2].to_string().as_str(),
163                DEFAULT_STYLE[3].to_string().as_str(),
164            ]));
165
166            move || {
167                loop {
168                    let Ok(mut progress) = progress.lock() else {
169                        break;
170                    };
171                    // If were unable to install handles, skip signal processing entirely.
172                    #[cfg(unix)]
173                    if sig_result.is_ok() {
174                        match sig_rx.try_recv() {
175                            Ok(sig)
176                                if sig == radicle_signals::Signal::Interrupt
177                                    || sig == radicle_signals::Signal::Terminate =>
178                            {
179                                spinner.finish_and_clear();
180                                writeln!(
181                                    completion_target.writer(),
182                                    "{} {message} {}",
183                                    super::PREFIX_ERROR,
184                                    Paint::red("<canceled>")
185                                )
186                                .ok();
187                                std::process::exit(-1);
188                            }
189                            Ok(_) => {}
190                            Err(_) => {}
191                        }
192                    }
193                    match &mut *progress {
194                        Progress {
195                            state: State::Running,
196                            message,
197                        } => {
198                            spinner.set_message(message.to_string());
199                            spinner.inc(1);
200                        }
201
202                        Progress {
203                            state: State::Done,
204                            message,
205                        } => {
206                            spinner.finish_and_clear();
207                            writeln!(
208                                completion_target.writer(),
209                                "{} {message}",
210                                super::PREFIX_SUCCESS
211                            )
212                            .ok();
213                            break;
214                        }
215
216                        Progress {
217                            state: State::Canceled,
218                            message,
219                        } => {
220                            spinner.finish_and_clear();
221                            writeln!(
222                                completion_target.writer(),
223                                "{} {message} {}",
224                                super::PREFIX_ERROR,
225                                Paint::red("<canceled>")
226                            )
227                            .ok();
228                            break;
229                        }
230
231                        Progress {
232                            state: State::Warn,
233                            message,
234                        } => {
235                            spinner.finish_and_clear();
236                            writeln!(
237                                completion_target.writer(),
238                                "{} {message}",
239                                super::PREFIX_WARNING
240                            )
241                            .ok();
242                            break;
243                        }
244
245                        Progress {
246                            state: State::Error,
247                            message,
248                        } => {
249                            spinner.finish_and_clear();
250                            writeln!(
251                                completion_target.writer(),
252                                "{} {message}",
253                                super::PREFIX_ERROR
254                            )
255                            .ok();
256                            break;
257                        }
258                    }
259                    drop(progress);
260                    thread::sleep(DEFAULT_TICK);
261                }
262
263                #[cfg(unix)]
264                if sig_result.is_ok() {
265                    let _ = radicle_signals::uninstall();
266                }
267            }
268        })
269        // SAFETY: Only panics if the thread name contains `null` bytes, which isn't the case here.
270        .unwrap();
271
272    Spinner {
273        progress,
274        handle: ManuallyDrop::new(handle),
275    }
276}