1use std::{
20 io::{IsTerminal, Write, stderr},
21 sync::{
22 Arc, Mutex, OnceLock,
23 atomic::{AtomicBool, Ordering},
24 },
25 thread::{self, JoinHandle},
26 time::Duration,
27};
28
29use crossterm::{
30 QueueableCommand, cursor,
31 style::{Color, Print, PrintStyledContent, Stylize},
32 terminal::{Clear, ClearType},
33};
34
35const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
36
37const FRAME_INTERVAL: Duration = Duration::from_millis(80);
38
39pub struct Spinner {
41 message: Arc<Mutex<String>>,
42 stop: Arc<AtomicBool>,
43 handle: Option<JoinHandle<()>>,
44}
45
46impl Spinner {
47 pub fn start(message: impl Into<String>) -> Self {
60 let message = Arc::new(Mutex::new(message.into()));
61 let stop = Arc::new(AtomicBool::new(false));
62
63 let handle = if stderr().is_terminal() {
64 install_terminal_guards();
65 let message = message.clone();
66 let stop = stop.clone();
67 Some(thread::spawn(move || render_loop(message, stop)))
68 } else {
69 None
70 };
71
72 Self {
73 message,
74 stop,
75 handle,
76 }
77 }
78
79 pub fn set_message(&self, message: impl Into<String>) {
81 if let Ok(mut current) = self.message.lock() {
82 *current = message.into();
83 }
84 }
85
86 pub fn success(self, message: impl AsRef<str>) {
88 self.finish("✓", Color::Green, message.as_ref());
89 }
90
91 pub fn failure(self, message: impl AsRef<str>) {
93 self.finish("✗", Color::Red, message.as_ref());
94 }
95
96 pub fn clear(mut self) {
99 self.shutdown();
100 }
101
102 fn finish(mut self, glyph: &str, color: Color, message: &str) {
103 self.shutdown();
104
105 let mut out = stderr();
106 out.queue(PrintStyledContent(glyph.with(color))).ok();
107 out.queue(Print(format!(" {message}\n"))).ok();
108 out.flush().ok();
109 }
110
111 fn shutdown(&mut self) {
112 self.stop.store(true, Ordering::Relaxed);
113 if let Some(handle) = self.handle.take() {
114 handle.join().ok();
115 }
116 }
117}
118
119impl Drop for Spinner {
120 fn drop(&mut self) {
121 self.shutdown();
122 }
123}
124
125static TERMINAL_GUARDS: OnceLock<()> = OnceLock::new();
126
127fn install_terminal_guards() {
128 TERMINAL_GUARDS.get_or_init(|| {
129 let _ = ctrlc::set_handler(|| {
136 restore_terminal();
137 std::process::exit(130);
138 });
139
140 let default_hook = std::panic::take_hook();
144 std::panic::set_hook(Box::new(move |info| {
145 restore_terminal();
146 default_hook(info);
147 }));
148 });
149}
150
151fn restore_terminal() {
152 let mut out = stderr();
153 out.queue(cursor::MoveToColumn(0)).ok();
154 out.queue(Clear(ClearType::CurrentLine)).ok();
155 out.queue(cursor::Show).ok();
156 out.flush().ok();
157}
158
159fn render_loop(message: Arc<Mutex<String>>, stop: Arc<AtomicBool>) {
160 let mut out = stderr();
161
162 out.queue(cursor::Hide).ok();
163 out.flush().ok();
164
165 let mut frame = 0;
166 while !stop.load(Ordering::Relaxed) {
167 let snapshot = message.lock().map(|m| m.clone()).unwrap_or_default();
168
169 out.queue(cursor::MoveToColumn(0)).ok();
170 out.queue(Clear(ClearType::CurrentLine)).ok();
171 out.queue(PrintStyledContent(FRAMES[frame].with(Color::Cyan)))
172 .ok();
173 out.queue(Print(format!(" {snapshot}"))).ok();
174 out.flush().ok();
175
176 frame = (frame + 1) % FRAMES.len();
177 thread::sleep(FRAME_INTERVAL);
178 }
179
180 out.queue(cursor::MoveToColumn(0)).ok();
181 out.queue(Clear(ClearType::CurrentLine)).ok();
182 out.queue(cursor::Show).ok();
183 out.flush().ok();
184}