Skip to main content

saorsa_tui/widget/
loading_indicator.rs

1//! Animated loading indicator widget.
2//!
3//! Displays spinning/cycling characters to indicate an ongoing operation.
4//! Supports multiple animation styles and an optional message.
5
6use crate::buffer::ScreenBuffer;
7use crate::cell::Cell;
8use crate::geometry::Rect;
9use crate::style::Style;
10use crate::text::truncate_to_display_width;
11use unicode_width::UnicodeWidthStr;
12
13use super::Widget;
14
15/// Animation style for the loading indicator.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum IndicatorStyle {
18    /// Braille spinner: ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏
19    Spinner,
20    /// Braille dots: ⠁⠂⠄⡀⢀⠠⠐⠈
21    Dots,
22    /// Line rotation: ─\|/
23    Line,
24    /// Box rotation: ▖▘▝▗
25    Box,
26    /// Circle rotation: ◐◓◑◒
27    Circle,
28}
29
30impl IndicatorStyle {
31    /// Get the frame sequence for this style.
32    fn frames(self) -> &'static [&'static str] {
33        match self {
34            IndicatorStyle::Spinner => &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
35            IndicatorStyle::Dots => &["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"],
36            IndicatorStyle::Line => &["─", "\\", "|", "/"],
37            IndicatorStyle::Box => &["▖", "▘", "▝", "▗"],
38            IndicatorStyle::Circle => &["◐", "◓", "◑", "◒"],
39        }
40    }
41}
42
43/// An animated loading indicator widget.
44///
45/// Renders a cycling character animation to indicate progress.
46/// Call [`tick`](Self::tick) to advance the animation frame.
47pub struct LoadingIndicator {
48    /// Animation style.
49    style: IndicatorStyle,
50    /// Current animation frame index.
51    frame: usize,
52    /// Visual style for the indicator character.
53    indicator_style: Style,
54    /// Optional message displayed after the indicator.
55    message: Option<String>,
56}
57
58impl LoadingIndicator {
59    /// Create a new loading indicator with the default Spinner style.
60    pub fn new() -> Self {
61        Self {
62            style: IndicatorStyle::Spinner,
63            frame: 0,
64            indicator_style: Style::default(),
65            message: None,
66        }
67    }
68
69    /// Set the animation style.
70    #[must_use]
71    pub fn with_style(mut self, style: IndicatorStyle) -> Self {
72        self.style = style;
73        self.frame = 0;
74        self
75    }
76
77    /// Set the visual style for the indicator character.
78    #[must_use]
79    pub fn with_indicator_style(mut self, style: Style) -> Self {
80        self.indicator_style = style;
81        self
82    }
83
84    /// Set the message displayed next to the indicator.
85    #[must_use]
86    pub fn with_message(mut self, message: &str) -> Self {
87        self.message = Some(message.to_string());
88        self
89    }
90
91    /// Advance the animation to the next frame.
92    pub fn tick(&mut self) {
93        let len = self.style.frames().len();
94        if len > 0 {
95            self.frame = (self.frame + 1) % len;
96        }
97    }
98
99    /// Reset the animation to the first frame.
100    pub fn reset(&mut self) {
101        self.frame = 0;
102    }
103
104    /// Get the current frame index.
105    pub fn frame(&self) -> usize {
106        self.frame
107    }
108
109    /// Get the current animation style.
110    pub fn animation_style(&self) -> IndicatorStyle {
111        self.style
112    }
113}
114
115impl Default for LoadingIndicator {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121impl Widget for LoadingIndicator {
122    fn render(&self, area: Rect, buf: &mut ScreenBuffer) {
123        if area.size.width == 0 || area.size.height == 0 {
124            return;
125        }
126
127        let frames = self.style.frames();
128        if frames.is_empty() {
129            return;
130        }
131
132        let frame_idx = self.frame % frames.len();
133        let ch = frames[frame_idx];
134        let w = area.size.width as usize;
135        let x0 = area.position.x;
136        let y = area.position.y;
137
138        // Render indicator character
139        let char_w = UnicodeWidthStr::width(ch);
140        if char_w > w {
141            return;
142        }
143
144        buf.set(x0, y, Cell::new(ch, self.indicator_style.clone()));
145        let mut col = char_w as u16;
146
147        // Render message if present
148        if let Some(ref msg) = self.message
149            && (col as usize) < w
150        {
151            // Space between indicator and message
152            buf.set(x0 + col, y, Cell::new(" ", self.indicator_style.clone()));
153            col += 1;
154
155            if (col as usize) < w {
156                let remaining = w.saturating_sub(col as usize);
157                let truncated = truncate_to_display_width(msg, remaining);
158                for ch in truncated.chars() {
159                    if col as usize >= w {
160                        break;
161                    }
162                    let cw = UnicodeWidthStr::width(ch.encode_utf8(&mut [0; 4]) as &str);
163                    if col as usize + cw > w {
164                        break;
165                    }
166                    buf.set(
167                        x0 + col,
168                        y,
169                        Cell::new(ch.to_string(), self.indicator_style.clone()),
170                    );
171                    col += cw as u16;
172                }
173            }
174        }
175    }
176}
177
178#[cfg(test)]
179#[allow(clippy::unwrap_used)]
180mod tests {
181    use super::*;
182    use crate::geometry::Size;
183
184    #[test]
185    fn create_default() {
186        let li = LoadingIndicator::new();
187        assert_eq!(li.animation_style(), IndicatorStyle::Spinner);
188        assert_eq!(li.frame(), 0);
189    }
190
191    #[test]
192    fn default_trait() {
193        let li: LoadingIndicator = Default::default();
194        assert_eq!(li.animation_style(), IndicatorStyle::Spinner);
195    }
196
197    #[test]
198    fn each_indicator_style() {
199        let styles = [
200            IndicatorStyle::Spinner,
201            IndicatorStyle::Dots,
202            IndicatorStyle::Line,
203            IndicatorStyle::Box,
204            IndicatorStyle::Circle,
205        ];
206        for style in &styles {
207            let li = LoadingIndicator::new().with_style(*style);
208            assert_eq!(li.animation_style(), *style);
209            assert!(!style.frames().is_empty());
210        }
211    }
212
213    #[test]
214    fn render_at_different_frames() {
215        let mut li = LoadingIndicator::new().with_style(IndicatorStyle::Spinner);
216        let mut buf = ScreenBuffer::new(Size::new(5, 1));
217
218        li.render(Rect::new(0, 0, 5, 1), &mut buf);
219        let first = buf.get(0, 0).unwrap().grapheme.clone();
220        assert_eq!(first, "⠋");
221
222        li.tick();
223        let mut buf2 = ScreenBuffer::new(Size::new(5, 1));
224        li.render(Rect::new(0, 0, 5, 1), &mut buf2);
225        let second = buf2.get(0, 0).unwrap().grapheme.clone();
226        assert_eq!(second, "⠙");
227    }
228
229    #[test]
230    fn tick_advances_frame() {
231        let mut li = LoadingIndicator::new();
232        assert_eq!(li.frame(), 0);
233        li.tick();
234        assert_eq!(li.frame(), 1);
235        li.tick();
236        assert_eq!(li.frame(), 2);
237    }
238
239    #[test]
240    fn frame_wraps_at_end() {
241        let mut li = LoadingIndicator::new().with_style(IndicatorStyle::Line);
242        // Line has 4 frames
243        for _ in 0..4 {
244            li.tick();
245        }
246        assert_eq!(li.frame(), 0); // wrapped
247    }
248
249    #[test]
250    fn reset_returns_to_zero() {
251        let mut li = LoadingIndicator::new();
252        li.tick();
253        li.tick();
254        assert_eq!(li.frame(), 2);
255        li.reset();
256        assert_eq!(li.frame(), 0);
257    }
258
259    #[test]
260    fn message_displayed() {
261        let li = LoadingIndicator::new()
262            .with_style(IndicatorStyle::Spinner)
263            .with_message("Loading...");
264        let mut buf = ScreenBuffer::new(Size::new(20, 1));
265        li.render(Rect::new(0, 0, 20, 1), &mut buf);
266
267        let row: String = (0..20)
268            .map(|x| buf.get(x, 0).map(|c| c.grapheme.as_str()).unwrap_or(" "))
269            .collect();
270        assert!(row.contains("Loading..."));
271    }
272
273    #[test]
274    fn no_message_indicator_only() {
275        let li = LoadingIndicator::new().with_style(IndicatorStyle::Circle);
276        let mut buf = ScreenBuffer::new(Size::new(5, 1));
277        li.render(Rect::new(0, 0, 5, 1), &mut buf);
278
279        assert_eq!(buf.get(0, 0).unwrap().grapheme, "◐");
280        // Rest should be spaces (default)
281        assert_eq!(buf.get(1, 0).unwrap().grapheme, " ");
282    }
283
284    #[test]
285    fn style_applied() {
286        let style = Style::default().bold(true);
287        let li = LoadingIndicator::new().with_indicator_style(style.clone());
288        let mut buf = ScreenBuffer::new(Size::new(5, 1));
289        li.render(Rect::new(0, 0, 5, 1), &mut buf);
290
291        assert!(buf.get(0, 0).unwrap().style.bold);
292    }
293
294    #[test]
295    fn zero_area_no_panic() {
296        let li = LoadingIndicator::new();
297        let mut buf = ScreenBuffer::new(Size::new(1, 1));
298        li.render(Rect::new(0, 0, 0, 0), &mut buf);
299        // No panic
300    }
301}