Skip to main content

ftui_widgets/
spinner.rs

1#![forbid(unsafe_code)]
2
3//! Spinner widget.
4
5use crate::block::Block;
6use crate::{StatefulWidget, Widget, set_style_area};
7use ftui_core::geometry::Rect;
8use ftui_render::frame::Frame;
9use ftui_style::Style;
10use ftui_text::display_width;
11
12/// Braille dot spinner animation frames.
13pub const DOTS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
14/// ASCII line spinner animation frames.
15pub const LINE: &[&str] = &["|", "/", "-", "\\"];
16
17/// A widget to display a spinner.
18#[derive(Debug, Clone, Default)]
19pub struct Spinner<'a> {
20    block: Option<Block<'a>>,
21    style: Style,
22    frames: &'a [&'a str],
23    label: Option<&'a str>,
24}
25
26impl<'a> Spinner<'a> {
27    /// Create a new spinner with default dot frames.
28    pub fn new() -> Self {
29        Self {
30            block: None,
31            style: Style::default(),
32            frames: DOTS,
33            label: None,
34        }
35    }
36
37    /// Wrap the spinner in a [`Block`] container.
38    pub fn block(mut self, block: Block<'a>) -> Self {
39        self.block = Some(block);
40        self
41    }
42
43    /// Set the base style for the spinner.
44    pub fn style(mut self, style: Style) -> Self {
45        self.style = style;
46        self
47    }
48
49    /// Set the animation frame characters.
50    pub fn frames(mut self, frames: &'a [&'a str]) -> Self {
51        self.frames = frames;
52        self
53    }
54
55    /// Set a text label displayed next to the spinner.
56    pub fn label(mut self, label: &'a str) -> Self {
57        self.label = Some(label);
58        self
59    }
60}
61
62/// Mutable state for a [`Spinner`] widget.
63#[derive(Debug, Clone, Default)]
64pub struct SpinnerState {
65    /// Index of the currently displayed animation frame.
66    pub current_frame: usize,
67}
68
69impl SpinnerState {
70    /// Advance to the next animation frame.
71    pub fn tick(&mut self) {
72        self.current_frame = self.current_frame.wrapping_add(1);
73    }
74}
75
76impl<'a> StatefulWidget for Spinner<'a> {
77    type State = SpinnerState;
78
79    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
80        #[cfg(feature = "tracing")]
81        let _span = tracing::debug_span!(
82            "widget_render",
83            widget = "Spinner",
84            x = area.x,
85            y = area.y,
86            w = area.width,
87            h = area.height
88        )
89        .entered();
90
91        let deg = frame.buffer.degradation;
92
93        // Skeleton+: skip entirely (spinner is decorative)
94        if !deg.render_content() {
95            return;
96        }
97
98        // EssentialOnly: spinner is decorative, only show label text
99        if !deg.render_decorative() {
100            if let Some(label) = self.label {
101                crate::draw_text_span(frame, area.x, area.y, label, Style::default(), area.right());
102            }
103            return;
104        }
105
106        let spinner_area = match &self.block {
107            Some(b) => {
108                b.render(area, frame);
109                b.inner(area)
110            }
111            None => area,
112        };
113
114        if spinner_area.is_empty() {
115            return;
116        }
117
118        let style = if deg.apply_styling() {
119            self.style
120        } else {
121            Style::default()
122        };
123
124        if deg.apply_styling() {
125            set_style_area(&mut frame.buffer, spinner_area, self.style);
126        }
127
128        // At NoStyling, use static ASCII frame instead of animated Unicode
129        if self.frames.is_empty() {
130            return;
131        }
132        let frame_char = if deg.use_unicode_borders() {
133            let frame_idx = state.current_frame % self.frames.len();
134            self.frames[frame_idx]
135        } else {
136            // Use first ASCII-safe frame, or fallback to "*"
137            let frame_idx = state.current_frame % self.frames.len();
138            let candidate = self.frames[frame_idx];
139            if candidate.is_ascii() { candidate } else { "*" }
140        };
141
142        let mut x = spinner_area.left();
143        let y = spinner_area.top();
144
145        crate::draw_text_span(frame, x, y, frame_char, style, spinner_area.right());
146
147        let w = display_width(frame_char);
148        x += w as u16;
149
150        // Render label
151        if let Some(label) = self.label {
152            x += 1;
153            if x < spinner_area.right() {
154                crate::draw_text_span(frame, x, y, label, style, spinner_area.right());
155            }
156        }
157    }
158}
159
160impl<'a> Widget for Spinner<'a> {
161    fn render(&self, area: Rect, frame: &mut Frame) {
162        let mut state = SpinnerState::default();
163        StatefulWidget::render(self, area, frame, &mut state);
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use ftui_render::buffer::Buffer;
171    use ftui_render::grapheme_pool::GraphemePool;
172
173    fn cell_char(buf: &Buffer, x: u16, y: u16) -> Option<char> {
174        buf.get(x, y).and_then(|c| c.content.as_char())
175    }
176
177    // --- SpinnerState tests ---
178
179    #[test]
180    fn state_default() {
181        let state = SpinnerState::default();
182        assert_eq!(state.current_frame, 0);
183    }
184
185    #[test]
186    fn state_tick_increments() {
187        let mut state = SpinnerState::default();
188        state.tick();
189        assert_eq!(state.current_frame, 1);
190        state.tick();
191        assert_eq!(state.current_frame, 2);
192    }
193
194    #[test]
195    fn state_tick_wraps_on_overflow() {
196        let mut state = SpinnerState {
197            current_frame: usize::MAX,
198        };
199        state.tick();
200        assert_eq!(state.current_frame, 0);
201    }
202
203    // --- Builder tests ---
204
205    #[test]
206    fn default_uses_dots_frames() {
207        let spinner = Spinner::new();
208        assert_eq!(spinner.frames.len(), DOTS.len());
209        assert_eq!(spinner.frames, DOTS);
210    }
211
212    #[test]
213    fn custom_frames() {
214        let frames: &[&str] = &["A", "B", "C"];
215        let spinner = Spinner::new().frames(frames);
216        assert_eq!(spinner.frames.len(), 3);
217    }
218
219    #[test]
220    fn builder_label() {
221        let spinner = Spinner::new().label("Loading...");
222        assert_eq!(spinner.label, Some("Loading..."));
223    }
224
225    // --- Rendering tests ---
226
227    #[test]
228    fn render_zero_area() {
229        let spinner = Spinner::new();
230        let area = Rect::new(0, 0, 0, 0);
231        let mut pool = GraphemePool::new();
232        let mut frame = Frame::new(1, 1, &mut pool);
233        Widget::render(&spinner, area, &mut frame);
234        // Should not panic
235    }
236
237    #[test]
238    fn stateless_render_uses_frame_zero() {
239        let frames: &[&str] = &["A", "B", "C"];
240        let spinner = Spinner::new().frames(frames);
241        let area = Rect::new(0, 0, 5, 1);
242        let mut pool = GraphemePool::new();
243        let mut frame = Frame::new(5, 1, &mut pool);
244        Widget::render(&spinner, area, &mut frame);
245
246        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
247    }
248
249    #[test]
250    fn stateful_render_cycles_frames() {
251        let frames: &[&str] = &["X", "Y", "Z"];
252        let spinner = Spinner::new().frames(frames);
253        let area = Rect::new(0, 0, 5, 1);
254
255        // Frame 0 -> "X"
256        let mut pool = GraphemePool::new();
257        let mut frame = Frame::new(5, 1, &mut pool);
258        let mut state = SpinnerState { current_frame: 0 };
259        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
260        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('X'));
261
262        // Frame 1 -> "Y"
263        let mut pool = GraphemePool::new();
264        let mut frame = Frame::new(5, 1, &mut pool);
265        state.current_frame = 1;
266        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
267        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('Y'));
268
269        // Frame 2 -> "Z"
270        let mut pool = GraphemePool::new();
271        let mut frame = Frame::new(5, 1, &mut pool);
272        state.current_frame = 2;
273        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
274        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('Z'));
275
276        // Frame 3 wraps -> "X"
277        let mut pool = GraphemePool::new();
278        let mut frame = Frame::new(5, 1, &mut pool);
279        state.current_frame = 3;
280        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
281        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('X'));
282    }
283
284    #[test]
285    fn render_with_label() {
286        let frames: &[&str] = &["*"];
287        let spinner = Spinner::new().frames(frames).label("Go");
288        let area = Rect::new(0, 0, 10, 1);
289        let mut pool = GraphemePool::new();
290        let mut frame = Frame::new(10, 1, &mut pool);
291        let mut state = SpinnerState::default();
292        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
293
294        // "*" at x=0, then space, then "Go" at x=2
295        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('*'));
296        assert_eq!(cell_char(&frame.buffer, 2, 0), Some('G'));
297        assert_eq!(cell_char(&frame.buffer, 3, 0), Some('o'));
298    }
299
300    #[test]
301    fn render_with_block() {
302        let frames: &[&str] = &["!"];
303        let spinner = Spinner::new().frames(frames).block(Block::bordered());
304        let area = Rect::new(0, 0, 10, 3);
305        let mut pool = GraphemePool::new();
306        let mut frame = Frame::new(10, 3, &mut pool);
307        let mut state = SpinnerState::default();
308        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
309
310        // Inside the border at (1, 1)
311        assert_eq!(cell_char(&frame.buffer, 1, 1), Some('!'));
312    }
313
314    #[test]
315    fn render_line_frames() {
316        let spinner = Spinner::new().frames(LINE);
317        let area = Rect::new(0, 0, 5, 1);
318
319        let mut pool = GraphemePool::new();
320        let mut frame = Frame::new(5, 1, &mut pool);
321        let mut state = SpinnerState { current_frame: 0 };
322        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
323        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('|'));
324
325        let mut pool = GraphemePool::new();
326        let mut frame = Frame::new(5, 1, &mut pool);
327        state.current_frame = 1;
328        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
329        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('/'));
330    }
331
332    #[test]
333    fn large_frame_index_wraps_correctly() {
334        let frames: &[&str] = &["A", "B"];
335        let spinner = Spinner::new().frames(frames);
336        let area = Rect::new(0, 0, 5, 1);
337        let mut pool = GraphemePool::new();
338        let mut frame = Frame::new(5, 1, &mut pool);
339        let mut state = SpinnerState {
340            current_frame: 1000,
341        };
342        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
343        // 1000 % 2 = 0 -> "A"
344        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
345    }
346
347    #[test]
348    fn dots_frame_set_has_expected_length() {
349        assert_eq!(DOTS.len(), 10);
350    }
351
352    #[test]
353    fn line_frame_set_has_expected_length() {
354        assert_eq!(LINE.len(), 4);
355    }
356
357    // --- Degradation tests ---
358
359    #[test]
360    fn degradation_skeleton_skips_entirely() {
361        use ftui_render::budget::DegradationLevel;
362
363        let frames: &[&str] = &["*"];
364        let spinner = Spinner::new().frames(frames).label("Loading");
365        let area = Rect::new(0, 0, 10, 1);
366        let mut pool = GraphemePool::new();
367        let mut frame = Frame::new(10, 1, &mut pool);
368        frame.buffer.degradation = DegradationLevel::Skeleton;
369        let mut state = SpinnerState::default();
370        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
371
372        // Nothing rendered at Skeleton
373        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
374    }
375
376    #[test]
377    fn degradation_essential_only_shows_label_only() {
378        use ftui_render::budget::DegradationLevel;
379
380        let frames: &[&str] = &["*"];
381        let spinner = Spinner::new().frames(frames).label("Go");
382        let area = Rect::new(0, 0, 10, 1);
383        let mut pool = GraphemePool::new();
384        let mut frame = Frame::new(10, 1, &mut pool);
385        frame.buffer.degradation = DegradationLevel::EssentialOnly;
386        let mut state = SpinnerState::default();
387        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
388
389        // Label "Go" rendered, no spinner frame
390        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('G'));
391        assert_eq!(cell_char(&frame.buffer, 1, 0), Some('o'));
392    }
393
394    #[test]
395    fn degradation_simple_borders_uses_ascii_fallback() {
396        use ftui_render::budget::DegradationLevel;
397
398        // Use Unicode frames that should fall back to ASCII
399        let spinner = Spinner::new(); // default DOTS frames are Unicode
400        let area = Rect::new(0, 0, 5, 1);
401        let mut pool = GraphemePool::new();
402        let mut frame = Frame::new(5, 1, &mut pool);
403        frame.buffer.degradation = DegradationLevel::SimpleBorders;
404        let mut state = SpinnerState::default();
405        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
406
407        // Should use "*" fallback since DOTS are non-ASCII
408        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('*'));
409    }
410
411    #[test]
412    fn degradation_full_uses_unicode_frames() {
413        use ftui_render::budget::DegradationLevel;
414
415        let spinner = Spinner::new(); // DOTS frames
416        let area = Rect::new(0, 0, 5, 1);
417        let mut pool = GraphemePool::new();
418        let mut frame = Frame::new(5, 1, &mut pool);
419        frame.buffer.degradation = DegradationLevel::Full;
420        let mut state = SpinnerState::default();
421        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
422
423        // Should use the first DOTS frame '⠋'
424        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('⠋'));
425    }
426}