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
10pub const DEFAULT_TICK: time::Duration = time::Duration::from_millis(120);
12pub 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
54pub 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 pub fn finish(self) {
77 if let Ok(mut progress) = self.progress.lock() {
78 progress.state = State::Done;
79 }
80 }
81
82 pub fn failed(self) {
84 if let Ok(mut progress) = self.progress.lock() {
85 progress.state = State::Error;
86 }
87 }
88
89 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 pub fn warn(self) {
104 if let Ok(mut progress) = self.progress.lock() {
105 progress.state = State::Warn;
106 }
107 }
108
109 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
119pub 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
130pub 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 #[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 .unwrap();
271
272 Spinner {
273 progress,
274 handle: ManuallyDrop::new(handle),
275 }
276}