Skip to main content

rpi_tui/
loader.rs

1//! Loader component - loading indicator.
2//!
3//! Provides a visual loading indicator for TUI.
4
5use std::any::Any;
6use std::sync::{Arc, Mutex};
7use std::time::{Duration, Instant};
8
9use super::component::Component;
10
11/// Canonical working-spinner frames, in order.
12///
13/// Shared by [`Loader`] and the editor's working indicator so both animate
14/// identically. Mirrors upstream's `DEFAULT_FRAMES`.
15///
16/// [`Loader`]: crate::loader::Loader
17pub const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
18
19/// Frame interval for animated indicators. Mirrors upstream's
20/// `DEFAULT_INTERVAL_MS`: one frame per 80 ms, i.e. a full 10-frame cycle every
21/// 800 ms.
22pub const SPINNER_FRAME_MS: u64 = 80;
23
24/// Loader indicator options.
25#[derive(Debug, Clone)]
26pub struct LoaderIndicatorOptions {
27    /// Characters to cycle through for the spinner.
28    pub spinner_chars: Vec<char>,
29    /// Animation frame duration.
30    pub frame_duration_ms: u64,
31    /// Text to display next to the spinner.
32    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
45/// Loader component.
46pub struct Loader {
47    options: LoaderIndicatorOptions,
48    frame: Mutex<usize>,
49    start_time: Mutex<Option<Instant>>,
50    running: Mutex<bool>,
51}
52
53impl Loader {
54    /// Create a new loader.
55    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    /// Create a loader with options.
65    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    /// Create a loader with text.
75    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    /// Start the loader animation.
82    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    /// Stop the loader animation.
92    pub fn stop(&self) {
93        if let Ok(mut running) = self.running.lock() {
94            *running = false;
95        }
96    }
97
98    /// Check if the loader is running.
99    pub fn is_running(&self) -> bool {
100        *self.running.lock().unwrap()
101    }
102
103    /// Get the elapsed time since the loader started.
104    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    /// Advance to the next frame.
113    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    /// Get the current spinner character.
120    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    /// Set the text.
130    pub fn set_text(&mut self, text: &str) {
131        self.options.text = Some(text.to_string());
132    }
133
134    /// Clear the text.
135    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); // Reserve space for spinner and time
167            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        // Advance frame for next render
179        self.advance();
180
181        vec![line]
182    }
183
184    fn invalidate(&self) {
185        // No cached state
186    }
187
188    fn as_any(&self) -> &dyn Any {
189        self
190    }
191}
192
193/// Cancellable loader with a cancel callback.
194pub struct CancellableLoader {
195    loader: Loader,
196    on_cancel: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
197}
198
199impl CancellableLoader {
200    /// Create a new cancellable loader.
201    pub fn new() -> Self {
202        Self {
203            loader: Loader::new(),
204            on_cancel: Mutex::new(None),
205        }
206    }
207
208    /// Create with text.
209    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    /// Set cancel callback.
217    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    /// Cancel the loader.
224    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    /// Start the loader.
235    pub fn start(&self) {
236        self.loader.start();
237    }
238
239    /// Stop the loader.
240    pub fn stop(&self) {
241        self.loader.stop();
242    }
243
244    /// Check if running.
245    pub fn is_running(&self) -> bool {
246        self.loader.is_running()
247    }
248
249    /// Set text.
250    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        // Add cancel hint
266        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
283/// Progress loader with percentage.
284pub struct ProgressLoader {
285    loader: Loader,
286    progress: Mutex<f32>, // 0.0 to 1.0
287}
288
289impl ProgressLoader {
290    /// Create a new progress loader.
291    pub fn new() -> Self {
292        Self {
293            loader: Loader::new(),
294            progress: Mutex::new(0.0),
295        }
296    }
297
298    /// Create with text.
299    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    /// Set progress (0.0 to 1.0).
307    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    /// Get progress.
314    pub fn get_progress(&self) -> f32 {
315        *self.progress.lock().unwrap()
316    }
317
318    /// Start the loader.
319    pub fn start(&self) {
320        self.loader.start();
321    }
322
323    /// Stop the loader.
324    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        // Create progress bar
344        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        // Advance frame
365        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}