Skip to main content

rab/tui/components/
cancellable_loader.rs

1use crate::tui::Component;
2use crate::tui::components::loader::Loader;
3use crate::tui::keybindings::{ACTION_SELECT_CANCEL, get_keybindings};
4
5/// Loader with escape-to-cancel functionality.
6/// Port of pi's `packages/tui/src/components/cancellable-loader.ts`.
7pub struct CancellableLoader {
8    loader: Loader,
9    cancelled: bool,
10    pub on_abort: Option<Box<dyn FnMut()>>,
11}
12
13impl CancellableLoader {
14    pub fn new(
15        spinner_color_fn: Box<dyn Fn(&str) -> String>,
16        message_color_fn: Box<dyn Fn(&str) -> String>,
17        message: impl Into<String>,
18    ) -> Self {
19        Self {
20            loader: Loader::new(spinner_color_fn, message_color_fn, message),
21            cancelled: false,
22            on_abort: None,
23        }
24    }
25
26    pub fn start(&mut self) {
27        self.loader.start();
28    }
29
30    pub fn stop(&mut self) {
31        self.loader.stop();
32    }
33
34    /// Stop the animation and clean up. Matches pi's `dispose()`.
35    pub fn dispose(&mut self) {
36        self.loader.stop();
37    }
38
39    pub fn is_cancelled(&self) -> bool {
40        self.cancelled
41    }
42
43    pub fn tick(&mut self) -> bool {
44        self.loader.tick()
45    }
46
47    pub fn set_message(&mut self, message: impl Into<String>) {
48        self.loader.set_message(message);
49    }
50}
51
52impl Component for CancellableLoader {
53    fn render(&self, width: usize) -> Vec<String> {
54        self.loader.render(width)
55    }
56
57    fn handle_input(&mut self, key: &crossterm::event::KeyEvent) -> bool {
58        let kb = get_keybindings();
59        if kb.matches(key, ACTION_SELECT_CANCEL) {
60            self.cancelled = true;
61            if let Some(ref mut cb) = self.on_abort {
62                cb();
63            }
64            return true;
65        }
66        false
67    }
68
69    fn invalidate(&mut self) {
70        self.loader.invalidate();
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
78
79    #[test]
80    fn test_cancel_on_escape() {
81        let mut cl = CancellableLoader::new(
82            Box::new(|s| s.to_string()),
83            Box::new(|s| s.to_string()),
84            "Working...",
85        );
86        let escape = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
87        assert!(!cl.is_cancelled());
88        cl.handle_input(&escape);
89        assert!(cl.is_cancelled());
90    }
91
92    #[test]
93    fn test_dispose_stops() {
94        let mut cl = CancellableLoader::new(
95            Box::new(|s| s.to_string()),
96            Box::new(|s| s.to_string()),
97            "Working...",
98        );
99        cl.start();
100        cl.dispose();
101        // After dispose, tick should not advance
102        assert!(!cl.tick());
103    }
104}