radicle_term/
spinner.rs

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