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, clear_text_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    #[must_use]
39    pub fn block(mut self, block: Block<'a>) -> Self {
40        self.block = Some(block);
41        self
42    }
43
44    /// Set the base style for the spinner.
45    #[must_use]
46    pub fn style(mut self, style: Style) -> Self {
47        self.style = style;
48        self
49    }
50
51    /// Set the animation frame characters.
52    #[must_use]
53    pub fn frames(mut self, frames: &'a [&'a str]) -> Self {
54        self.frames = frames;
55        self
56    }
57
58    /// Set a text label displayed next to the spinner.
59    #[must_use]
60    pub fn label(mut self, label: &'a str) -> Self {
61        self.label = Some(label);
62        self
63    }
64
65    fn frame_for_render(&self, current_frame: usize, use_unicode: bool) -> Option<&'a str> {
66        if self.frames.is_empty() {
67            return None;
68        }
69
70        let frame_idx = current_frame % self.frames.len();
71        if use_unicode {
72            return Some(self.frames[frame_idx]);
73        }
74
75        let candidate = self.frames[frame_idx];
76        if candidate.is_ascii() {
77            Some(candidate)
78        } else {
79            self.frames
80                .iter()
81                .copied()
82                .find(|frame| frame.is_ascii())
83                .or(Some("*"))
84        }
85    }
86}
87
88/// Mutable state for a [`Spinner`] widget.
89#[derive(Debug, Clone, Default)]
90pub struct SpinnerState {
91    /// Index of the currently displayed animation frame.
92    pub current_frame: usize,
93}
94
95impl SpinnerState {
96    /// Advance to the next animation frame.
97    pub fn tick(&mut self) {
98        self.current_frame = self.current_frame.wrapping_add(1);
99    }
100}
101
102impl<'a> StatefulWidget for Spinner<'a> {
103    type State = SpinnerState;
104
105    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
106        #[cfg(feature = "tracing")]
107        let _span = tracing::debug_span!(
108            "widget_render",
109            widget = "Spinner",
110            x = area.x,
111            y = area.y,
112            w = area.width,
113            h = area.height
114        )
115        .entered();
116
117        let deg = frame.buffer.degradation;
118
119        // Skeleton+: skip entirely (spinner is decorative)
120        if !deg.render_content() {
121            clear_text_area(frame, area, Style::default());
122            return;
123        }
124
125        // EssentialOnly: spinner is decorative, only show label text
126        if !deg.render_decorative() {
127            clear_text_area(frame, area, Style::default());
128            if let Some(label) = self.label {
129                crate::draw_text_span(frame, area.x, area.y, label, Style::default(), area.right());
130            }
131            return;
132        }
133
134        let style = if deg.apply_styling() {
135            self.style
136        } else {
137            Style::default()
138        };
139
140        clear_text_area(frame, area, style);
141
142        let spinner_area = match &self.block {
143            Some(b) => {
144                b.render(area, frame);
145                b.inner(area)
146            }
147            None => area,
148        };
149
150        if spinner_area.is_empty() {
151            return;
152        }
153
154        let mut x = spinner_area.left();
155        let y = spinner_area.top();
156        if let Some(frame_char) =
157            self.frame_for_render(state.current_frame, deg.use_unicode_borders())
158        {
159            crate::draw_text_span(frame, x, y, frame_char, style, spinner_area.right());
160            let w = display_width(frame_char);
161            x += w as u16;
162        }
163
164        // Render label
165        if let Some(label) = self.label {
166            if x > spinner_area.left() {
167                x += 1;
168            }
169            if x < spinner_area.right() {
170                crate::draw_text_span(frame, x, y, label, style, spinner_area.right());
171            }
172        }
173    }
174}
175
176impl<'a> Widget for Spinner<'a> {
177    fn render(&self, area: Rect, frame: &mut Frame) {
178        let mut state = SpinnerState::default();
179        StatefulWidget::render(self, area, frame, &mut state);
180    }
181}
182
183// ============================================================================
184// Accessibility
185// ============================================================================
186
187impl ftui_a11y::Accessible for Spinner<'_> {
188    fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
189        use ftui_a11y::node::{A11yNodeInfo, A11yRole, A11yState};
190
191        let id = crate::a11y_node_id(area);
192        let name = self
193            .label
194            .map(|l| format!("Loading: {l}"))
195            .unwrap_or_else(|| "Loading...".to_owned());
196        let node = A11yNodeInfo::new(id, A11yRole::ProgressBar, area)
197            .with_name(name)
198            .with_state(A11yState {
199                busy: true,
200                ..A11yState::default()
201            });
202        vec![node]
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use ftui_render::buffer::Buffer;
210    use ftui_render::grapheme_pool::GraphemePool;
211
212    fn cell_char(buf: &Buffer, x: u16, y: u16) -> Option<char> {
213        buf.get(x, y).and_then(|c| c.content.as_char())
214    }
215
216    fn raw_row_text(buf: &Buffer, y: u16, width: u16) -> String {
217        (0..width)
218            .map(|x| {
219                buf.get(x, y)
220                    .and_then(|c| c.content.as_char())
221                    .unwrap_or(' ')
222            })
223            .collect()
224    }
225
226    // --- SpinnerState tests ---
227
228    #[test]
229    fn state_default() {
230        let state = SpinnerState::default();
231        assert_eq!(state.current_frame, 0);
232    }
233
234    #[test]
235    fn state_tick_increments() {
236        let mut state = SpinnerState::default();
237        state.tick();
238        assert_eq!(state.current_frame, 1);
239        state.tick();
240        assert_eq!(state.current_frame, 2);
241    }
242
243    #[test]
244    fn state_tick_wraps_on_overflow() {
245        let mut state = SpinnerState {
246            current_frame: usize::MAX,
247        };
248        state.tick();
249        assert_eq!(state.current_frame, 0);
250    }
251
252    // --- Builder tests ---
253
254    #[test]
255    fn default_uses_dots_frames() {
256        let spinner = Spinner::new();
257        assert_eq!(spinner.frames.len(), DOTS.len());
258        assert_eq!(spinner.frames, DOTS);
259    }
260
261    #[test]
262    fn custom_frames() {
263        let frames: &[&str] = &["A", "B", "C"];
264        let spinner = Spinner::new().frames(frames);
265        assert_eq!(spinner.frames.len(), 3);
266    }
267
268    #[test]
269    fn builder_label() {
270        let spinner = Spinner::new().label("Loading...");
271        assert_eq!(spinner.label, Some("Loading..."));
272    }
273
274    // --- Rendering tests ---
275
276    #[test]
277    fn render_zero_area() {
278        let spinner = Spinner::new();
279        let area = Rect::new(0, 0, 0, 0);
280        let mut pool = GraphemePool::new();
281        let mut frame = Frame::new(1, 1, &mut pool);
282        Widget::render(&spinner, area, &mut frame);
283        // Should not panic
284    }
285
286    #[test]
287    fn stateless_render_uses_frame_zero() {
288        let frames: &[&str] = &["A", "B", "C"];
289        let spinner = Spinner::new().frames(frames);
290        let area = Rect::new(0, 0, 5, 1);
291        let mut pool = GraphemePool::new();
292        let mut frame = Frame::new(5, 1, &mut pool);
293        Widget::render(&spinner, area, &mut frame);
294
295        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
296    }
297
298    #[test]
299    fn stateful_render_cycles_frames() {
300        let frames: &[&str] = &["X", "Y", "Z"];
301        let spinner = Spinner::new().frames(frames);
302        let area = Rect::new(0, 0, 5, 1);
303
304        // Frame 0 -> "X"
305        let mut pool = GraphemePool::new();
306        let mut frame = Frame::new(5, 1, &mut pool);
307        let mut state = SpinnerState { current_frame: 0 };
308        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
309        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('X'));
310
311        // Frame 1 -> "Y"
312        let mut pool = GraphemePool::new();
313        let mut frame = Frame::new(5, 1, &mut pool);
314        state.current_frame = 1;
315        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
316        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('Y'));
317
318        // Frame 2 -> "Z"
319        let mut pool = GraphemePool::new();
320        let mut frame = Frame::new(5, 1, &mut pool);
321        state.current_frame = 2;
322        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
323        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('Z'));
324
325        // Frame 3 wraps -> "X"
326        let mut pool = GraphemePool::new();
327        let mut frame = Frame::new(5, 1, &mut pool);
328        state.current_frame = 3;
329        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
330        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('X'));
331    }
332
333    #[test]
334    fn render_with_label() {
335        let frames: &[&str] = &["*"];
336        let spinner = Spinner::new().frames(frames).label("Go");
337        let area = Rect::new(0, 0, 10, 1);
338        let mut pool = GraphemePool::new();
339        let mut frame = Frame::new(10, 1, &mut pool);
340        let mut state = SpinnerState::default();
341        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
342
343        // "*" at x=0, then space, then "Go" at x=2
344        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('*'));
345        assert_eq!(cell_char(&frame.buffer, 2, 0), Some('G'));
346        assert_eq!(cell_char(&frame.buffer, 3, 0), Some('o'));
347    }
348
349    #[test]
350    fn render_with_block() {
351        let frames: &[&str] = &["!"];
352        let spinner = Spinner::new().frames(frames).block(Block::bordered());
353        // Block::bordered() includes 1 cell padding on each side, so we need
354        // at least 5 rows to have a 1-row inner content area.
355        let area = Rect::new(0, 0, 10, 5);
356        let mut pool = GraphemePool::new();
357        let mut frame = Frame::new(10, 5, &mut pool);
358        let mut state = SpinnerState::default();
359        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
360
361        // Inside the border + padding at (2, 2)
362        assert_eq!(cell_char(&frame.buffer, 2, 2), Some('!'));
363    }
364
365    #[test]
366    fn render_line_frames() {
367        let spinner = Spinner::new().frames(LINE);
368        let area = Rect::new(0, 0, 5, 1);
369
370        let mut pool = GraphemePool::new();
371        let mut frame = Frame::new(5, 1, &mut pool);
372        let mut state = SpinnerState { current_frame: 0 };
373        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
374        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('|'));
375
376        let mut pool = GraphemePool::new();
377        let mut frame = Frame::new(5, 1, &mut pool);
378        state.current_frame = 1;
379        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
380        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('/'));
381    }
382
383    #[test]
384    fn large_frame_index_wraps_correctly() {
385        let frames: &[&str] = &["A", "B"];
386        let spinner = Spinner::new().frames(frames);
387        let area = Rect::new(0, 0, 5, 1);
388        let mut pool = GraphemePool::new();
389        let mut frame = Frame::new(5, 1, &mut pool);
390        let mut state = SpinnerState {
391            current_frame: 1000,
392        };
393        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
394        // 1000 % 2 = 0 -> "A"
395        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
396    }
397
398    #[test]
399    fn dots_frame_set_has_expected_length() {
400        assert_eq!(DOTS.len(), 10);
401    }
402
403    #[test]
404    fn line_frame_set_has_expected_length() {
405        assert_eq!(LINE.len(), 4);
406    }
407
408    // --- Degradation tests ---
409
410    #[test]
411    fn degradation_skeleton_skips_entirely() {
412        use ftui_render::budget::DegradationLevel;
413
414        let frames: &[&str] = &["*"];
415        let spinner = Spinner::new().frames(frames).label("Loading");
416        let area = Rect::new(0, 0, 10, 1);
417        let mut pool = GraphemePool::new();
418        let mut frame = Frame::new(10, 1, &mut pool);
419        let mut state = SpinnerState::default();
420        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
421
422        frame.buffer.degradation = DegradationLevel::Skeleton;
423        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
424
425        assert_eq!(raw_row_text(&frame.buffer, 0, 10), "          ");
426    }
427
428    #[test]
429    fn degradation_essential_only_shows_label_only() {
430        use ftui_render::budget::DegradationLevel;
431
432        let frames: &[&str] = &["*"];
433        let spinner = Spinner::new().frames(frames).label("Go");
434        let area = Rect::new(0, 0, 10, 1);
435        let mut pool = GraphemePool::new();
436        let mut frame = Frame::new(10, 1, &mut pool);
437        frame.buffer.degradation = DegradationLevel::EssentialOnly;
438        let mut state = SpinnerState::default();
439        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
440
441        // Label "Go" rendered, no spinner frame
442        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('G'));
443        assert_eq!(cell_char(&frame.buffer, 1, 0), Some('o'));
444    }
445
446    #[test]
447    fn degradation_simple_borders_uses_ascii_fallback() {
448        use ftui_render::budget::DegradationLevel;
449
450        // Use Unicode frames that should fall back to ASCII
451        let spinner = Spinner::new(); // default DOTS frames are Unicode
452        let area = Rect::new(0, 0, 5, 1);
453        let mut pool = GraphemePool::new();
454        let mut frame = Frame::new(5, 1, &mut pool);
455        frame.buffer.degradation = DegradationLevel::SimpleBorders;
456        let mut state = SpinnerState::default();
457        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
458
459        // Should use "*" fallback since DOTS are non-ASCII
460        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('*'));
461    }
462
463    #[test]
464    fn degradation_full_uses_unicode_frames() {
465        use ftui_render::budget::DegradationLevel;
466
467        let spinner = Spinner::new(); // DOTS frames
468        let area = Rect::new(0, 0, 5, 1);
469        let mut pool = GraphemePool::new();
470        let mut frame = Frame::new(5, 1, &mut pool);
471        frame.buffer.degradation = DegradationLevel::Full;
472        let mut state = SpinnerState::default();
473        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
474
475        // Should use the first DOTS frame '⠋'
476        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('⠋'));
477    }
478
479    #[test]
480    fn degradation_ascii_fallback_prefers_available_ascii_frame() {
481        use ftui_render::budget::DegradationLevel;
482
483        let frames: &[&str] = &["⠋", "-", "\\"];
484        let spinner = Spinner::new().frames(frames);
485        let area = Rect::new(0, 0, 5, 1);
486        let mut pool = GraphemePool::new();
487        let mut frame = Frame::new(5, 1, &mut pool);
488        frame.buffer.degradation = DegradationLevel::SimpleBorders;
489        let mut state = SpinnerState { current_frame: 0 };
490        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
491
492        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('-'));
493    }
494
495    #[test]
496    fn empty_frames_still_render_label() {
497        let frames: &[&str] = &[];
498        let spinner = Spinner::new().frames(frames).label("Loading");
499        let area = Rect::new(0, 0, 10, 1);
500        let mut pool = GraphemePool::new();
501        let mut frame = Frame::new(10, 1, &mut pool);
502        let mut state = SpinnerState::default();
503        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
504
505        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('L'));
506        assert_eq!(cell_char(&frame.buffer, 1, 0), Some('o'));
507    }
508
509    #[test]
510    fn render_shorter_label_clears_stale_suffix() {
511        let frames: &[&str] = &["*"];
512        let area = Rect::new(0, 0, 10, 1);
513        let mut pool = GraphemePool::new();
514        let mut frame = Frame::new(10, 1, &mut pool);
515        let mut state = SpinnerState::default();
516
517        StatefulWidget::render(
518            &Spinner::new().frames(frames).label("Loading"),
519            area,
520            &mut frame,
521            &mut state,
522        );
523        StatefulWidget::render(
524            &Spinner::new().frames(frames).label("Go"),
525            area,
526            &mut frame,
527            &mut state,
528        );
529
530        assert_eq!(raw_row_text(&frame.buffer, 0, 10), "* Go      ");
531    }
532
533    #[test]
534    fn degradation_essential_only_clears_previous_spinner_frame() {
535        use ftui_render::budget::DegradationLevel;
536
537        let frames: &[&str] = &["*"];
538        let spinner = Spinner::new().frames(frames).label("Go");
539        let area = Rect::new(0, 0, 10, 1);
540        let mut pool = GraphemePool::new();
541        let mut frame = Frame::new(10, 1, &mut pool);
542        let mut state = SpinnerState::default();
543
544        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
545        frame.buffer.degradation = DegradationLevel::EssentialOnly;
546        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
547
548        assert_eq!(raw_row_text(&frame.buffer, 0, 10), "Go        ");
549    }
550}