presentar_terminal/
cli.rs1use crossterm::{
24 cursor::{Hide, MoveToColumn, Show},
25 execute,
26 style::Print,
27 terminal::{Clear, ClearType},
28};
29use std::io::{self, Write};
30use std::sync::atomic::{AtomicBool, Ordering};
31use std::sync::Arc;
32use std::thread::{self, JoinHandle};
33use std::time::Duration;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum SpinnerStyle {
40 #[default]
42 Dots,
43 Line,
45 Growing,
47 Arc,
49 Bounce,
51}
52
53impl SpinnerStyle {
54 #[must_use]
56 pub const fn frames(&self) -> &'static [&'static str] {
57 match self {
58 Self::Dots => &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
59 Self::Line => &["|", "/", "-", "\\"],
60 Self::Growing => &[". ", ".. ", "..."],
61 Self::Arc => &["◐", "◓", "◑", "◒"],
62 Self::Bounce => &["⠁", "⠂", "⠄", "⠂"],
63 }
64 }
65
66 #[must_use]
68 pub const fn interval_ms(&self) -> u64 {
69 match self {
70 Self::Dots => 80,
71 Self::Line => 100,
72 Self::Growing => 300,
73 Self::Arc => 100,
74 Self::Bounce => 120,
75 }
76 }
77}
78
79#[derive(Debug)]
94pub struct Spinner {
95 style: SpinnerStyle,
96 message: Option<String>,
97}
98
99impl Default for Spinner {
100 fn default() -> Self {
101 Self::new()
102 }
103}
104
105impl Spinner {
106 #[must_use]
108 pub const fn new() -> Self {
109 Self {
110 style: SpinnerStyle::Dots,
111 message: None,
112 }
113 }
114
115 #[must_use]
117 pub const fn style(mut self, style: SpinnerStyle) -> Self {
118 self.style = style;
119 self
120 }
121
122 #[must_use]
124 pub fn message(mut self, msg: impl Into<String>) -> Self {
125 self.message = Some(msg.into());
126 self
127 }
128
129 pub fn start(self) -> SpinnerHandle {
133 let running = Arc::new(AtomicBool::new(true));
134 let running_clone = Arc::clone(&running);
135
136 let frames = self.style.frames();
137 let interval = Duration::from_millis(self.style.interval_ms());
138 let message = self.message;
139
140 let handle = thread::spawn(move || {
141 let mut stdout = io::stdout();
142 let mut frame_idx = 0;
143
144 let _ = execute!(stdout, Hide);
146
147 while running_clone.load(Ordering::Relaxed) {
148 let frame = frames[frame_idx % frames.len()];
149
150 let _ = execute!(
152 stdout,
153 MoveToColumn(0),
154 Clear(ClearType::CurrentLine),
155 Print(frame)
156 );
157
158 if let Some(ref msg) = message {
159 let _ = execute!(stdout, Print(" "), Print(msg));
160 }
161
162 let _ = stdout.flush();
163
164 frame_idx = frame_idx.wrapping_add(1);
165 thread::sleep(interval);
166 }
167
168 let _ = execute!(stdout, MoveToColumn(0), Clear(ClearType::CurrentLine), Show);
170 let _ = stdout.flush();
171 });
172
173 SpinnerHandle {
174 running,
175 handle: Some(handle),
176 }
177 }
178}
179
180#[derive(Debug)]
184pub struct SpinnerHandle {
185 running: Arc<AtomicBool>,
186 handle: Option<JoinHandle<()>>,
187}
188
189impl SpinnerHandle {
190 pub fn stop(mut self) {
194 self.stop_internal();
195 }
196
197 pub fn stop_with_message(mut self, msg: &str) {
199 self.stop_internal();
200 println!("{msg}");
201 }
202
203 fn stop_internal(&mut self) {
204 self.running.store(false, Ordering::Relaxed);
205 if let Some(handle) = self.handle.take() {
206 let _ = handle.join();
207 }
208 }
209}
210
211impl Drop for SpinnerHandle {
212 fn drop(&mut self) {
213 self.stop_internal();
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn test_spinner_style_frames() {
223 assert!(!SpinnerStyle::Dots.frames().is_empty());
224 assert!(!SpinnerStyle::Line.frames().is_empty());
225 assert!(!SpinnerStyle::Growing.frames().is_empty());
226 assert!(!SpinnerStyle::Arc.frames().is_empty());
227 assert!(!SpinnerStyle::Bounce.frames().is_empty());
228 }
229
230 #[test]
231 fn test_spinner_style_interval() {
232 assert!(SpinnerStyle::Dots.interval_ms() > 0);
233 assert!(SpinnerStyle::Line.interval_ms() > 0);
234 }
235
236 #[test]
237 fn test_spinner_builder() {
238 let spinner = Spinner::new()
239 .style(SpinnerStyle::Line)
240 .message("Loading...");
241
242 assert_eq!(spinner.style, SpinnerStyle::Line);
243 assert_eq!(spinner.message, Some("Loading...".to_string()));
244 }
245
246 #[test]
247 fn test_spinner_default() {
248 let spinner = Spinner::default();
249 assert_eq!(spinner.style, SpinnerStyle::Dots);
250 assert!(spinner.message.is_none());
251 }
252
253 #[test]
254 fn test_spinner_start_stop() {
255 let handle = Spinner::new().start();
257 std::thread::sleep(Duration::from_millis(100));
258 handle.stop();
259 }
260
261 #[test]
262 fn test_spinner_drop_stops() {
263 {
265 let _handle = Spinner::new().start();
266 std::thread::sleep(Duration::from_millis(50));
267 }
269 }
271}