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 #[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
66#[derive(Debug, Clone, Default)]
68pub struct SpinnerState {
69 pub current_frame: usize,
71}
72
73impl SpinnerState {
74 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 if !deg.render_content() {
99 return;
100 }
101
102 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 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 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 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 #[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 #[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 #[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 }
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 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 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 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 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 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 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 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 #[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 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 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 let spinner = Spinner::new(); 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 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(); 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 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('⠋'));
429 }
430}