1#![forbid(unsafe_code)]
2
3use 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
12pub const DOTS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
14pub const LINE: &[&str] = &["|", "/", "-", "\\"];
16
17#[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 pub fn new() -> Self {
29 Self {
30 block: None,
31 style: Style::default(),
32 frames: DOTS,
33 label: None,
34 }
35 }
36
37 #[must_use]
39 pub fn block(mut self, block: Block<'a>) -> Self {
40 self.block = Some(block);
41 self
42 }
43
44 #[must_use]
46 pub fn style(mut self, style: Style) -> Self {
47 self.style = style;
48 self
49 }
50
51 #[must_use]
53 pub fn frames(mut self, frames: &'a [&'a str]) -> Self {
54 self.frames = frames;
55 self
56 }
57
58 #[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#[derive(Debug, Clone, Default)]
90pub struct SpinnerState {
91 pub current_frame: usize,
93}
94
95impl SpinnerState {
96 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 if !deg.render_content() {
121 clear_text_area(frame, area, Style::default());
122 return;
123 }
124
125 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 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
183impl 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 #[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 #[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 #[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 }
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 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 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 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 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 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 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 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 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 #[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 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 let spinner = Spinner::new(); 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 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(); 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 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}