radicle_term/
spinner.rs

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