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