1use std::any::Any;
6use std::sync::{Arc, Mutex};
7use std::time::{Duration, Instant};
8
9use super::component::Component;
10
11pub const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
18
19pub const SPINNER_FRAME_MS: u64 = 80;
23
24#[derive(Debug, Clone)]
26pub struct LoaderIndicatorOptions {
27 pub spinner_chars: Vec<char>,
29 pub frame_duration_ms: u64,
31 pub text: Option<String>,
33}
34
35impl Default for LoaderIndicatorOptions {
36 fn default() -> Self {
37 Self {
38 spinner_chars: SPINNER_FRAMES.to_vec(),
39 frame_duration_ms: SPINNER_FRAME_MS,
40 text: None,
41 }
42 }
43}
44
45pub struct Loader {
47 options: LoaderIndicatorOptions,
48 frame: Mutex<usize>,
49 start_time: Mutex<Option<Instant>>,
50 running: Mutex<bool>,
51}
52
53impl Loader {
54 pub fn new() -> Self {
56 Self {
57 options: LoaderIndicatorOptions::default(),
58 frame: Mutex::new(0),
59 start_time: Mutex::new(None),
60 running: Mutex::new(false),
61 }
62 }
63
64 pub fn with_options(options: LoaderIndicatorOptions) -> Self {
66 Self {
67 options,
68 frame: Mutex::new(0),
69 start_time: Mutex::new(None),
70 running: Mutex::new(false),
71 }
72 }
73
74 pub fn with_text(text: &str) -> Self {
76 let mut options = LoaderIndicatorOptions::default();
77 options.text = Some(text.to_string());
78 Self::with_options(options)
79 }
80
81 pub fn start(&self) {
83 if let Ok(mut running) = self.running.lock() {
84 *running = true;
85 }
86 if let Ok(mut start_time) = self.start_time.lock() {
87 *start_time = Some(Instant::now());
88 }
89 }
90
91 pub fn stop(&self) {
93 if let Ok(mut running) = self.running.lock() {
94 *running = false;
95 }
96 }
97
98 pub fn is_running(&self) -> bool {
100 *self.running.lock().unwrap()
101 }
102
103 pub fn elapsed(&self) -> Duration {
105 self.start_time
106 .lock()
107 .ok()
108 .and_then(|t| t.map(|s| s.elapsed()))
109 .unwrap_or_default()
110 }
111
112 pub fn advance(&self) {
114 if let Ok(mut frame) = self.frame.lock() {
115 *frame = (*frame + 1) % self.options.spinner_chars.len();
116 }
117 }
118
119 fn current_char(&self) -> char {
121 let frame = *self.frame.lock().unwrap();
122 self.options
123 .spinner_chars
124 .get(frame)
125 .copied()
126 .unwrap_or('⠋')
127 }
128
129 pub fn set_text(&mut self, text: &str) {
131 self.options.text = Some(text.to_string());
132 }
133
134 pub fn clear_text(&mut self) {
136 self.options.text = None;
137 }
138}
139
140impl Default for Loader {
141 fn default() -> Self {
142 Self::new()
143 }
144}
145
146impl Component for Loader {
147 fn render(&self, width: usize) -> Vec<String> {
148 let spinner = self.current_char();
149 let text = self.options.text.clone().unwrap_or_default();
150
151 let elapsed = self.elapsed();
152 let seconds = elapsed.as_secs();
153 let minutes = seconds / 60;
154 let secs = seconds % 60;
155
156 let time_str = if minutes > 0 {
157 format!("{}m {}s", minutes, secs)
158 } else {
159 format!("{}s", secs)
160 };
161
162 let line = if text.is_empty() {
163 format!("\x1b[36m{}\x1b[0m \x1b[90m{}\x1b[0m", spinner, time_str)
164 } else {
165 let text_width = crate::utils::visible_width(&text);
166 let max_text_width = width.saturating_sub(15); let display_text = if text_width > max_text_width {
168 crate::utils::truncate_to_width(&text, max_text_width, "...")
169 } else {
170 text
171 };
172 format!(
173 "\x1b[36m{}\x1b[0m \x1b[1m{}\x1b[0m \x1b[90m{}\x1b[0m",
174 spinner, display_text, time_str
175 )
176 };
177
178 self.advance();
180
181 vec![line]
182 }
183
184 fn invalidate(&self) {
185 }
187
188 fn as_any(&self) -> &dyn Any {
189 self
190 }
191}
192
193pub struct CancellableLoader {
195 loader: Loader,
196 on_cancel: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
197}
198
199impl CancellableLoader {
200 pub fn new() -> Self {
202 Self {
203 loader: Loader::new(),
204 on_cancel: Mutex::new(None),
205 }
206 }
207
208 pub fn with_text(text: &str) -> Self {
210 Self {
211 loader: Loader::with_text(text),
212 on_cancel: Mutex::new(None),
213 }
214 }
215
216 pub fn on_cancel(&self, callback: Arc<dyn Fn() + Send + Sync>) {
218 if let Ok(mut cb) = self.on_cancel.lock() {
219 *cb = Some(callback);
220 }
221 }
222
223 pub fn cancel(&self) {
225 self.loader.stop();
226
227 if let Ok(cb) = self.on_cancel.lock() {
228 if let Some(callback) = cb.as_ref() {
229 callback();
230 }
231 }
232 }
233
234 pub fn start(&self) {
236 self.loader.start();
237 }
238
239 pub fn stop(&self) {
241 self.loader.stop();
242 }
243
244 pub fn is_running(&self) -> bool {
246 self.loader.is_running()
247 }
248
249 pub fn set_text(&mut self, text: &str) {
251 self.loader.set_text(text);
252 }
253}
254
255impl Default for CancellableLoader {
256 fn default() -> Self {
257 Self::new()
258 }
259}
260
261impl Component for CancellableLoader {
262 fn render(&self, width: usize) -> Vec<String> {
263 let mut lines = self.loader.render(width);
264
265 if self.is_running() {
267 let hint = "\x1b[90mPress Ctrl+C to cancel\x1b[0m";
268 lines.push(hint.to_string());
269 }
270
271 lines
272 }
273
274 fn invalidate(&self) {
275 self.loader.invalidate();
276 }
277
278 fn as_any(&self) -> &dyn Any {
279 self
280 }
281}
282
283pub struct ProgressLoader {
285 loader: Loader,
286 progress: Mutex<f32>, }
288
289impl ProgressLoader {
290 pub fn new() -> Self {
292 Self {
293 loader: Loader::new(),
294 progress: Mutex::new(0.0),
295 }
296 }
297
298 pub fn with_text(text: &str) -> Self {
300 Self {
301 loader: Loader::with_text(text),
302 progress: Mutex::new(0.0),
303 }
304 }
305
306 pub fn set_progress(&self, progress: f32) {
308 if let Ok(mut p) = self.progress.lock() {
309 *p = progress.clamp(0.0, 1.0);
310 }
311 }
312
313 pub fn get_progress(&self) -> f32 {
315 *self.progress.lock().unwrap()
316 }
317
318 pub fn start(&self) {
320 self.loader.start();
321 }
322
323 pub fn stop(&self) {
325 self.loader.stop();
326 }
327}
328
329impl Default for ProgressLoader {
330 fn default() -> Self {
331 Self::new()
332 }
333}
334
335impl Component for ProgressLoader {
336 fn render(&self, width: usize) -> Vec<String> {
337 let progress = self.get_progress();
338 let percent = (progress * 100.0) as usize;
339
340 let spinner = self.loader.current_char();
341 let text = self.loader.options.text.clone().unwrap_or_default();
342
343 let bar_width = width.saturating_sub(20).min(30);
345 let filled = (bar_width as f32 * progress) as usize;
346 let empty = bar_width - filled;
347
348 let bar = format!(
349 "\x1b[36m{}\x1b[44m{}\x1b[40m{}\x1b[0m {}%",
350 spinner,
351 "█".repeat(filled),
352 "░".repeat(empty),
353 percent
354 );
355
356 let line = if text.is_empty() {
357 bar
358 } else {
359 let text_display =
360 crate::utils::truncate_to_width(&text, width.saturating_sub(bar_width + 5), "...");
361 format!("{} {}", text_display, bar)
362 };
363
364 self.loader.advance();
366
367 vec![line]
368 }
369
370 fn invalidate(&self) {
371 self.loader.invalidate();
372 }
373
374 fn as_any(&self) -> &dyn Any {
375 self
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn test_loader_new() {
385 let loader = Loader::new();
386 assert!(!loader.is_running());
387 }
388
389 #[test]
390 fn test_loader_start_stop() {
391 let loader = Loader::new();
392 loader.start();
393 assert!(loader.is_running());
394 loader.stop();
395 assert!(!loader.is_running());
396 }
397
398 #[test]
399 fn test_loader_render() {
400 let loader = Loader::with_text("Loading...");
401 loader.start();
402 let lines = loader.render(40);
403 assert!(!lines.is_empty());
404 assert!(lines[0].contains("Loading"));
405 }
406
407 #[test]
408 fn test_cancellable_loader() {
409 let loader = CancellableLoader::new();
410 let cancelled = Arc::new(Mutex::new(false));
411 let cancelled_clone = cancelled.clone();
412
413 loader.on_cancel(Arc::new(move || {
414 *cancelled_clone.lock().unwrap() = true;
415 }));
416
417 loader.cancel();
418 assert!(*cancelled.lock().unwrap());
419 }
420
421 #[test]
422 fn test_progress_loader() {
423 let loader = ProgressLoader::new();
424 loader.set_progress(0.5);
425 assert_eq!(loader.get_progress(), 0.5);
426 }
427}