Skip to main content

rab/tui/components/
loader.rs

1use std::time::Instant;
2
3use crate::tui::Component;
4use crate::tui::components::text::Text;
5use crate::tui::util::visible_width;
6
7/// Options for Loader indicator appearance.
8pub struct LoaderIndicatorOptions {
9    /// Animation frames. Use an empty vec to hide the indicator.
10    pub frames: Vec<String>,
11    /// Frame interval in milliseconds for animated indicators.
12    pub interval_ms: u64,
13}
14
15impl Default for LoaderIndicatorOptions {
16    fn default() -> Self {
17        Self {
18            frames: vec![
19                "⠋".into(),
20                "⠙".into(),
21                "⠹".into(),
22                "⠸".into(),
23                "⠼".into(),
24                "⠴".into(),
25                "⠦".into(),
26                "⠧".into(),
27                "⠇".into(),
28                "⠏".into(),
29            ],
30            interval_ms: 80,
31        }
32    }
33}
34
35/// Loader component with optional spinning animation.
36/// Port of pi's `packages/tui/src/components/loader.ts`.
37///
38/// pi's Loader extends Text. In rab we wrap Text via composition.
39pub struct Loader {
40    text: Text,
41    frames: Vec<String>,
42    interval_ms: u64,
43    current_frame: usize,
44    started: bool,
45    last_tick: Instant,
46    message: String,
47    spinner_color_fn: crate::tui::Style,
48    message_color_fn: crate::tui::Style,
49    render_indicator_verbatim: bool,
50}
51
52impl Loader {
53    pub fn new(
54        spinner_color_fn: crate::tui::Style,
55        message_color_fn: crate::tui::Style,
56        message: impl Into<String>,
57    ) -> Self {
58        let indicator = LoaderIndicatorOptions::default();
59        Self {
60            text: Text::new("", 1, 0, None),
61            frames: indicator.frames,
62            interval_ms: indicator.interval_ms,
63            current_frame: 0,
64            started: false,
65            last_tick: Instant::now(),
66            message: message.into(),
67            spinner_color_fn,
68            message_color_fn,
69            render_indicator_verbatim: false,
70        }
71    }
72
73    pub fn start(&mut self) {
74        self.started = true;
75        self.last_tick = Instant::now();
76        self.update_display();
77    }
78
79    pub fn stop(&mut self) {
80        self.started = false;
81    }
82
83    pub fn set_message(&mut self, message: impl Into<String>) {
84        self.message = message.into();
85        self.update_display();
86    }
87
88    pub fn set_indicator(&mut self, indicator: LoaderIndicatorOptions) {
89        self.render_indicator_verbatim = true;
90        self.frames = if indicator.frames.is_empty() {
91            vec![] // hide indicator
92        } else {
93            indicator.frames
94        };
95        self.interval_ms = if indicator.interval_ms > 0 {
96            indicator.interval_ms
97        } else {
98            80
99        };
100        self.current_frame = 0;
101        self.update_display();
102    }
103
104    /// Advance to next frame if interval elapsed. Returns true if display changed.
105    pub fn tick(&mut self) -> bool {
106        if !self.started || self.frames.is_empty() || self.frames.len() <= 1 {
107            return false;
108        }
109        let elapsed = self.last_tick.elapsed();
110        if elapsed.as_millis() >= self.interval_ms as u128 {
111            self.current_frame = (self.current_frame + 1) % self.frames.len();
112            self.last_tick = Instant::now();
113            self.update_display();
114            return true;
115        }
116        false
117    }
118
119    fn update_display(&self) -> String {
120        let frame = self
121            .frames
122            .get(self.current_frame)
123            .map(|s| s.as_str())
124            .unwrap_or("");
125        let rendered_frame = if frame.is_empty() {
126            String::new()
127        } else if self.render_indicator_verbatim {
128            frame.to_string()
129        } else {
130            self.spinner_color_fn.apply(frame)
131        };
132        let indicator = if frame.is_empty() {
133            String::new()
134        } else {
135            format!("{} ", rendered_frame)
136        };
137        let display = format!(
138            "{}{}",
139            indicator,
140            self.message_color_fn.apply(&self.message)
141        );
142        display
143    }
144}
145
146impl Component for Loader {
147    fn render(&mut self, width: usize) -> Vec<String> {
148        // Pi: renderer returns ["", ...super.render(width)] — one blank line above for spacing
149        let display = self.update_display();
150        let mut lines = vec![String::new()]; // blank line above
151        let display_line = {
152            let vw = visible_width(&display);
153            if vw < width {
154                format!("{}{}", display, " ".repeat(width - vw))
155            } else {
156                display
157            }
158        };
159        lines.push(display_line);
160        lines
161    }
162
163    fn handle_input(&mut self, _key: &crossterm::event::KeyEvent) -> bool {
164        false
165    }
166
167    fn invalidate(&mut self) {
168        self.text.invalidate();
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_loader_renders_with_spacing() {
178        let mut loader = Loader::new(
179            crate::tui::Style::new(),
180            crate::tui::Style::new(),
181            "Loading...",
182        );
183        let lines = loader.render(40);
184        assert!(lines.len() >= 2, "Should have blank line + content");
185        assert_eq!(lines[0], "", "First line should be blank");
186    }
187
188    #[test]
189    fn test_loader_message() {
190        let mut loader = Loader::new(
191            crate::tui::Style::new(),
192            crate::tui::Style::new(),
193            "Working...",
194        );
195        let lines = loader.render(40);
196        assert!(lines[1].contains("Working..."));
197    }
198
199    #[test]
200    fn test_loader_tick() {
201        let mut loader = Loader::new(crate::tui::Style::new(), crate::tui::Style::new(), "test");
202        loader.start();
203        // Immediate tick should not change (interval not elapsed)
204        assert!(!loader.tick());
205    }
206}