1#![forbid(unsafe_code)]
2
3use 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
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 pub fn block(mut self, block: Block<'a>) -> Self {
39 self.block = Some(block);
40 self
41 }
42
43 pub fn style(mut self, style: Style) -> Self {
45 self.style = style;
46 self
47 }
48
49 pub fn frames(mut self, frames: &'a [&'a str]) -> Self {
51 self.frames = frames;
52 self
53 }
54
55 pub fn label(mut self, label: &'a str) -> Self {
57 self.label = Some(label);
58 self
59 }
60}
61
62#[derive(Debug, Clone, Default)]
64pub struct SpinnerState {
65 pub current_frame: usize,
67}
68
69impl SpinnerState {
70 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 if !deg.render_content() {
95 return;
96 }
97
98 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 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 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 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 #[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 #[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 #[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 }
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 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 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 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 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 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 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 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 #[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 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 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 let spinner = Spinner::new(); 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 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(); 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 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('⠋'));
425 }
426}