#![forbid(unsafe_code)]
use crate::block::Block;
use crate::{StatefulWidget, Widget, clear_text_area};
use ftui_core::geometry::Rect;
use ftui_render::frame::Frame;
use ftui_style::Style;
use ftui_text::display_width;
pub const DOTS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub const LINE: &[&str] = &["|", "/", "-", "\\"];
#[derive(Debug, Clone, Default)]
pub struct Spinner<'a> {
block: Option<Block<'a>>,
style: Style,
frames: &'a [&'a str],
label: Option<&'a str>,
}
impl<'a> Spinner<'a> {
pub fn new() -> Self {
Self {
block: None,
style: Style::default(),
frames: DOTS,
label: None,
}
}
#[must_use]
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
#[must_use]
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub fn frames(mut self, frames: &'a [&'a str]) -> Self {
self.frames = frames;
self
}
#[must_use]
pub fn label(mut self, label: &'a str) -> Self {
self.label = Some(label);
self
}
fn frame_for_render(&self, current_frame: usize, use_unicode: bool) -> Option<&'a str> {
if self.frames.is_empty() {
return None;
}
let frame_idx = current_frame % self.frames.len();
if use_unicode {
return Some(self.frames[frame_idx]);
}
let candidate = self.frames[frame_idx];
if candidate.is_ascii() {
Some(candidate)
} else {
self.frames
.iter()
.copied()
.find(|frame| frame.is_ascii())
.or(Some("*"))
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SpinnerState {
pub current_frame: usize,
}
impl SpinnerState {
pub fn tick(&mut self) {
self.current_frame = self.current_frame.wrapping_add(1);
}
}
impl<'a> StatefulWidget for Spinner<'a> {
type State = SpinnerState;
fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
#[cfg(feature = "tracing")]
let _span = tracing::debug_span!(
"widget_render",
widget = "Spinner",
x = area.x,
y = area.y,
w = area.width,
h = area.height
)
.entered();
let deg = frame.buffer.degradation;
if !deg.render_content() {
clear_text_area(frame, area, Style::default());
return;
}
if !deg.render_decorative() {
clear_text_area(frame, area, Style::default());
if let Some(label) = self.label {
crate::draw_text_span(frame, area.x, area.y, label, Style::default(), area.right());
}
return;
}
let style = if deg.apply_styling() {
self.style
} else {
Style::default()
};
clear_text_area(frame, area, style);
let spinner_area = match &self.block {
Some(b) => {
b.render(area, frame);
b.inner(area)
}
None => area,
};
if spinner_area.is_empty() {
return;
}
let mut x = spinner_area.left();
let y = spinner_area.top();
if let Some(frame_char) =
self.frame_for_render(state.current_frame, deg.use_unicode_borders())
{
crate::draw_text_span(frame, x, y, frame_char, style, spinner_area.right());
let w = display_width(frame_char);
x += w as u16;
}
if let Some(label) = self.label {
if x > spinner_area.left() {
x += 1;
}
if x < spinner_area.right() {
crate::draw_text_span(frame, x, y, label, style, spinner_area.right());
}
}
}
}
impl<'a> Widget for Spinner<'a> {
fn render(&self, area: Rect, frame: &mut Frame) {
let mut state = SpinnerState::default();
StatefulWidget::render(self, area, frame, &mut state);
}
}
impl ftui_a11y::Accessible for Spinner<'_> {
fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
use ftui_a11y::node::{A11yNodeInfo, A11yRole, A11yState};
let id = crate::a11y_node_id(area);
let name = self
.label
.map(|l| format!("Loading: {l}"))
.unwrap_or_else(|| "Loading...".to_owned());
let node = A11yNodeInfo::new(id, A11yRole::ProgressBar, area)
.with_name(name)
.with_state(A11yState {
busy: true,
..A11yState::default()
});
vec![node]
}
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_render::buffer::Buffer;
use ftui_render::grapheme_pool::GraphemePool;
fn cell_char(buf: &Buffer, x: u16, y: u16) -> Option<char> {
buf.get(x, y).and_then(|c| c.content.as_char())
}
fn raw_row_text(buf: &Buffer, y: u16, width: u16) -> String {
(0..width)
.map(|x| {
buf.get(x, y)
.and_then(|c| c.content.as_char())
.unwrap_or(' ')
})
.collect()
}
#[test]
fn state_default() {
let state = SpinnerState::default();
assert_eq!(state.current_frame, 0);
}
#[test]
fn state_tick_increments() {
let mut state = SpinnerState::default();
state.tick();
assert_eq!(state.current_frame, 1);
state.tick();
assert_eq!(state.current_frame, 2);
}
#[test]
fn state_tick_wraps_on_overflow() {
let mut state = SpinnerState {
current_frame: usize::MAX,
};
state.tick();
assert_eq!(state.current_frame, 0);
}
#[test]
fn default_uses_dots_frames() {
let spinner = Spinner::new();
assert_eq!(spinner.frames.len(), DOTS.len());
assert_eq!(spinner.frames, DOTS);
}
#[test]
fn custom_frames() {
let frames: &[&str] = &["A", "B", "C"];
let spinner = Spinner::new().frames(frames);
assert_eq!(spinner.frames.len(), 3);
}
#[test]
fn builder_label() {
let spinner = Spinner::new().label("Loading...");
assert_eq!(spinner.label, Some("Loading..."));
}
#[test]
fn render_zero_area() {
let spinner = Spinner::new();
let area = Rect::new(0, 0, 0, 0);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 1, &mut pool);
Widget::render(&spinner, area, &mut frame);
}
#[test]
fn stateless_render_uses_frame_zero() {
let frames: &[&str] = &["A", "B", "C"];
let spinner = Spinner::new().frames(frames);
let area = Rect::new(0, 0, 5, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
Widget::render(&spinner, area, &mut frame);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
}
#[test]
fn stateful_render_cycles_frames() {
let frames: &[&str] = &["X", "Y", "Z"];
let spinner = Spinner::new().frames(frames);
let area = Rect::new(0, 0, 5, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
let mut state = SpinnerState { current_frame: 0 };
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('X'));
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
state.current_frame = 1;
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('Y'));
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
state.current_frame = 2;
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('Z'));
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
state.current_frame = 3;
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('X'));
}
#[test]
fn render_with_label() {
let frames: &[&str] = &["*"];
let spinner = Spinner::new().frames(frames).label("Go");
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
let mut state = SpinnerState::default();
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('*'));
assert_eq!(cell_char(&frame.buffer, 2, 0), Some('G'));
assert_eq!(cell_char(&frame.buffer, 3, 0), Some('o'));
}
#[test]
fn render_with_block() {
let frames: &[&str] = &["!"];
let spinner = Spinner::new().frames(frames).block(Block::bordered());
let area = Rect::new(0, 0, 10, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
let mut state = SpinnerState::default();
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 2, 2), Some('!'));
}
#[test]
fn render_line_frames() {
let spinner = Spinner::new().frames(LINE);
let area = Rect::new(0, 0, 5, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
let mut state = SpinnerState { current_frame: 0 };
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('|'));
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
state.current_frame = 1;
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('/'));
}
#[test]
fn large_frame_index_wraps_correctly() {
let frames: &[&str] = &["A", "B"];
let spinner = Spinner::new().frames(frames);
let area = Rect::new(0, 0, 5, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
let mut state = SpinnerState {
current_frame: 1000,
};
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
}
#[test]
fn dots_frame_set_has_expected_length() {
assert_eq!(DOTS.len(), 10);
}
#[test]
fn line_frame_set_has_expected_length() {
assert_eq!(LINE.len(), 4);
}
#[test]
fn degradation_skeleton_skips_entirely() {
use ftui_render::budget::DegradationLevel;
let frames: &[&str] = &["*"];
let spinner = Spinner::new().frames(frames).label("Loading");
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
let mut state = SpinnerState::default();
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
frame.buffer.degradation = DegradationLevel::Skeleton;
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
assert_eq!(raw_row_text(&frame.buffer, 0, 10), " ");
}
#[test]
fn degradation_essential_only_shows_label_only() {
use ftui_render::budget::DegradationLevel;
let frames: &[&str] = &["*"];
let spinner = Spinner::new().frames(frames).label("Go");
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
frame.buffer.degradation = DegradationLevel::EssentialOnly;
let mut state = SpinnerState::default();
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('G'));
assert_eq!(cell_char(&frame.buffer, 1, 0), Some('o'));
}
#[test]
fn degradation_simple_borders_uses_ascii_fallback() {
use ftui_render::budget::DegradationLevel;
let spinner = Spinner::new(); let area = Rect::new(0, 0, 5, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
frame.buffer.degradation = DegradationLevel::SimpleBorders;
let mut state = SpinnerState::default();
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('*'));
}
#[test]
fn degradation_full_uses_unicode_frames() {
use ftui_render::budget::DegradationLevel;
let spinner = Spinner::new(); let area = Rect::new(0, 0, 5, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
frame.buffer.degradation = DegradationLevel::Full;
let mut state = SpinnerState::default();
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('⠋'));
}
#[test]
fn degradation_ascii_fallback_prefers_available_ascii_frame() {
use ftui_render::budget::DegradationLevel;
let frames: &[&str] = &["⠋", "-", "\\"];
let spinner = Spinner::new().frames(frames);
let area = Rect::new(0, 0, 5, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
frame.buffer.degradation = DegradationLevel::SimpleBorders;
let mut state = SpinnerState { current_frame: 0 };
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('-'));
}
#[test]
fn empty_frames_still_render_label() {
let frames: &[&str] = &[];
let spinner = Spinner::new().frames(frames).label("Loading");
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
let mut state = SpinnerState::default();
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
assert_eq!(cell_char(&frame.buffer, 0, 0), Some('L'));
assert_eq!(cell_char(&frame.buffer, 1, 0), Some('o'));
}
#[test]
fn render_shorter_label_clears_stale_suffix() {
let frames: &[&str] = &["*"];
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
let mut state = SpinnerState::default();
StatefulWidget::render(
&Spinner::new().frames(frames).label("Loading"),
area,
&mut frame,
&mut state,
);
StatefulWidget::render(
&Spinner::new().frames(frames).label("Go"),
area,
&mut frame,
&mut state,
);
assert_eq!(raw_row_text(&frame.buffer, 0, 10), "* Go ");
}
#[test]
fn degradation_essential_only_clears_previous_spinner_frame() {
use ftui_render::budget::DegradationLevel;
let frames: &[&str] = &["*"];
let spinner = Spinner::new().frames(frames).label("Go");
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
let mut state = SpinnerState::default();
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
frame.buffer.degradation = DegradationLevel::EssentialOnly;
StatefulWidget::render(&spinner, area, &mut frame, &mut state);
assert_eq!(raw_row_text(&frame.buffer, 0, 10), "Go ");
}
}