use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Direction {
#[default]
Horizontal,
Vertical,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Flow {
#[default]
Forwards,
Backwards,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LinearStyle {
#[default]
Classic,
Square,
Diamond,
Bar,
Braille,
Arrow,
}
impl LinearStyle {
#[must_use]
pub const fn symbols(self, direction: Direction) -> (&'static str, &'static str) {
match self {
Self::Classic => ("●", "·"),
Self::Square => ("■", "□"),
Self::Diamond => ("◆", "◇"),
Self::Bar => ("▰", "▱"),
Self::Braille => ("⣿", "⠀"),
Self::Arrow => match direction {
Direction::Horizontal => ("▶", "▷"),
Direction::Vertical => ("▼", "▽"),
},
}
}
#[must_use]
pub const fn columns_per_slot(self) -> u16 {
match self {
Self::Classic
| Self::Square
| Self::Diamond
| Self::Bar
| Self::Braille
| Self::Arrow => 1,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LinearSpinner<'a> {
tick: u64,
total_slots: usize,
lit_slots: usize,
ticks_per_step: u64,
direction: Direction,
flow: Flow,
linear_style: LinearStyle,
active_color: Color,
inactive_color: Color,
block: Option<Block<'a>>,
style: Style,
}
impl<'a> LinearSpinner<'a> {
#[must_use]
pub fn new(tick: u64) -> Self {
Self {
tick,
total_slots: 3,
lit_slots: 2,
ticks_per_step: 3,
direction: Direction::Horizontal,
flow: Flow::Forwards,
linear_style: LinearStyle::Classic,
active_color: Color::White,
inactive_color: Color::DarkGray,
block: None,
style: Style::default(),
}
}
#[must_use]
pub const fn direction(mut self, direction: Direction) -> Self {
self.direction = direction;
self
}
#[must_use]
pub const fn flow(mut self, flow: Flow) -> Self {
self.flow = flow;
self
}
#[must_use]
pub const fn linear_style(mut self, style: LinearStyle) -> Self {
self.linear_style = style;
self
}
#[must_use]
pub fn total_slots(mut self, n: usize) -> Self {
self.total_slots = n.max(1);
self
}
#[must_use]
pub fn lit_slots(mut self, n: usize) -> Self {
self.lit_slots = n.max(1);
self
}
#[must_use]
pub fn ticks_per_step(mut self, n: u64) -> Self {
self.ticks_per_step = n.max(1);
self
}
#[must_use]
pub const fn active_color(mut self, color: Color) -> Self {
self.active_color = color;
self
}
#[must_use]
pub const fn inactive_color(mut self, color: Color) -> Self {
self.inactive_color = color;
self
}
#[must_use]
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
#[must_use]
pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
self.style = style.into();
self
}
#[allow(clippy::cast_possible_truncation)]
fn step(&self) -> usize {
(self.tick / self.ticks_per_step) as usize
}
fn slot_span(&self, _idx: usize, is_lit: bool) -> Span<'static> {
let (on, off) = self.linear_style.symbols(self.direction);
if is_lit {
Span::styled(
on,
Style::default()
.fg(self.active_color)
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled(off, Style::default().fg(self.inactive_color))
}
}
fn build_horizontal_line(&self) -> Line<'static> {
let total = self.total_slots.max(1);
let lit = self.lit_slots.min(total);
let raw_step = self.step() % total;
let step = match self.flow {
Flow::Forwards => raw_step,
Flow::Backwards => (total - 1) - raw_step,
};
let spans: Vec<Span<'static>> = (0..total)
.map(|i| {
let is_lit = if step + lit <= total {
i >= step && i < step + lit
} else {
i >= step || i < (step + lit) % total
};
self.slot_span(i, is_lit)
})
.collect();
Line::from(spans)
}
fn bounce_index(&self) -> usize {
let n = self.total_slots.max(1);
if n == 1 {
return 0;
}
let cycle = 2 * (n - 1);
let pos = self.step() % cycle;
let idx = if pos < n { pos } else { cycle - pos };
match self.flow {
Flow::Forwards => idx,
Flow::Backwards => (n - 1) - idx,
}
}
fn build_vertical_lines(&self, height: usize) -> Vec<Line<'static>> {
let n = self.total_slots.max(1);
let active = self.bounce_index();
let mut lines: Vec<Line<'static>> = vec![Line::from(""); height];
if height >= n {
let start = height - n;
for (i, line) in lines.iter_mut().skip(start).enumerate() {
*line = Line::from(self.slot_span(i, i == active));
}
} else {
for (i, line) in lines.iter_mut().enumerate() {
*line = Line::from(self.slot_span(i, i == active));
}
}
lines
}
}
impl_styled_for!(LinearSpinner<'_>);
impl_widget_via_ref!(LinearSpinner<'_>);
impl Widget for &LinearSpinner<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
buf.set_style(area, self.style);
let inner = if let Some(ref block) = self.block {
let inner_area = block.inner(area);
block.clone().render(area, buf);
inner_area
} else {
area
};
if inner.height == 0 || inner.width == 0 {
return;
}
match self.direction {
Direction::Horizontal => {
Paragraph::new(self.build_horizontal_line()).render(inner, buf);
}
Direction::Vertical => {
let lines = self.build_vertical_lines(inner.height as usize);
Paragraph::new(lines).render(inner, buf);
}
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::needless_range_loop)]
use super::*;
#[test]
fn horizontal_first_step_lights_first_two() {
let s = LinearSpinner::new(0); let line = s.build_horizontal_line();
let content: Vec<&str> = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
let (on, off) = LinearStyle::Classic.symbols(Direction::Horizontal);
assert_eq!(content, &[on, on, off]);
}
#[test]
fn horizontal_second_step_advances() {
let s = LinearSpinner::new(3); let line = s.build_horizontal_line();
let content: Vec<&str> = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
let (on, off) = LinearStyle::Classic.symbols(Direction::Horizontal);
assert_eq!(content, &[off, on, on]);
}
#[test]
fn horizontal_window_wraps() {
let s = LinearSpinner::new(6); let line = s.build_horizontal_line();
let content: Vec<&str> = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
let (on, off) = LinearStyle::Classic.symbols(Direction::Horizontal);
assert_eq!(content, &[on, off, on]);
}
#[test]
fn horizontal_lit_clamped_to_total_at_render() {
let s = LinearSpinner::new(0).total_slots(2).lit_slots(99);
let line = s.build_horizontal_line();
let (on, _) = LinearStyle::Classic.symbols(Direction::Horizontal);
let lit = line
.spans
.iter()
.filter(|sp| sp.content.as_ref() == on)
.count();
assert!(lit <= 2, "lit count must not exceed total_slots");
}
#[test]
fn horizontal_single_dot_no_panic() {
let s = LinearSpinner::new(0).total_slots(1).lit_slots(1);
let _ = s.build_horizontal_line();
}
#[test]
fn vertical_bounce_sequence_3_dots() {
let expected = [0usize, 0, 0, 1, 1, 1, 2, 2, 2, 1, 1, 1, 0, 0, 0];
for (tick, &exp) in expected.iter().enumerate() {
let s = LinearSpinner::new(tick as u64).direction(Direction::Vertical);
assert_eq!(s.bounce_index(), exp, "tick={tick}");
}
}
#[test]
fn vertical_bounce_sequence_1_dot() {
for tick in 0..10u64 {
let s = LinearSpinner::new(tick)
.direction(Direction::Vertical)
.total_slots(1);
assert_eq!(s.bounce_index(), 0);
}
}
#[test]
fn horizontal_backwards_first_step_lights_last_two() {
let s = LinearSpinner::new(0).flow(Flow::Backwards);
let line = s.build_horizontal_line();
let content: Vec<&str> = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
let (on, off) = LinearStyle::Classic.symbols(Direction::Horizontal);
assert_eq!(content, &[on, off, on]);
}
#[test]
fn horizontal_backwards_second_step_reverses() {
let s = LinearSpinner::new(3).flow(Flow::Backwards);
let line = s.build_horizontal_line();
let content: Vec<&str> = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
let (on, off) = LinearStyle::Classic.symbols(Direction::Horizontal);
assert_eq!(content, &[off, on, on]);
}
#[test]
fn horizontal_backwards_third_step() {
let s = LinearSpinner::new(6).flow(Flow::Backwards);
let line = s.build_horizontal_line();
let content: Vec<&str> = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
let (on, off) = LinearStyle::Classic.symbols(Direction::Horizontal);
assert_eq!(content, &[on, on, off]);
}
#[test]
fn vertical_backwards_bounce_sequence_3_dots() {
let expected = [2usize, 2, 2, 1, 1, 1, 0, 0, 0, 1, 1, 1, 2, 2, 2];
for (tick, &exp) in expected.iter().enumerate() {
let s = LinearSpinner::new(tick as u64)
.direction(Direction::Vertical)
.flow(Flow::Backwards);
assert_eq!(s.bounce_index(), exp, "tick={tick}");
}
}
#[test]
fn vertical_backwards_bounce_sequence_1_dot() {
for tick in 0..10u64 {
let s = LinearSpinner::new(tick)
.direction(Direction::Vertical)
.flow(Flow::Backwards)
.total_slots(1);
assert_eq!(s.bounce_index(), 0);
}
}
#[test]
fn flow_forwards_is_default() {
let s = LinearSpinner::new(0);
assert_eq!(s.flow, Flow::Forwards);
}
#[test]
fn flow_default_trait() {
assert_eq!(Flow::default(), Flow::Forwards);
}
#[test]
fn vertical_ticks_per_step_one_faster() {
let s = LinearSpinner::new(1)
.direction(Direction::Vertical)
.ticks_per_step(1);
assert_eq!(s.bounce_index(), 1);
}
#[test]
fn vertical_lines_bottom_aligned() {
let s = LinearSpinner::new(0)
.direction(Direction::Vertical)
.total_slots(3);
let lines = s.build_vertical_lines(6);
assert_eq!(lines.len(), 6);
assert!(lines[0].spans.is_empty() || lines[0].to_string().is_empty());
assert!(!lines[3].spans.is_empty());
}
#[test]
fn vertical_lines_short_area_no_panic() {
let s = LinearSpinner::new(0)
.direction(Direction::Vertical)
.total_slots(5);
let lines = s.build_vertical_lines(2);
assert_eq!(lines.len(), 2);
}
#[test]
fn linear_style_arrow_changes_with_direction() {
let (h_on, _) = LinearStyle::Arrow.symbols(Direction::Horizontal);
let (v_on, _) = LinearStyle::Arrow.symbols(Direction::Vertical);
assert_ne!(h_on, v_on, "Arrow should differ between H and V");
}
#[test]
fn all_styles_return_non_empty_symbols() {
let styles = [
LinearStyle::Classic,
LinearStyle::Square,
LinearStyle::Diamond,
LinearStyle::Bar,
LinearStyle::Braille,
LinearStyle::Arrow,
];
for style in styles {
for dir in [Direction::Horizontal, Direction::Vertical] {
let (on, off) = style.symbols(dir);
assert!(!on.is_empty(), "{style:?}/{dir:?} active symbol empty");
assert!(!off.is_empty(), "{style:?}/{dir:?} inactive symbol empty");
}
}
}
#[test]
fn render_horizontal_does_not_panic_on_zero_area() {
let s = LinearSpinner::new(0);
let area = Rect::new(0, 0, 0, 0);
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
Widget::render(&s, area, &mut buf);
}
#[test]
fn render_vertical_does_not_panic_on_zero_area() {
let s = LinearSpinner::new(0).direction(Direction::Vertical);
let area = Rect::new(0, 0, 0, 0);
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
Widget::render(&s, area, &mut buf);
}
#[test]
fn render_vertical_does_not_panic_on_small_area() {
let s = LinearSpinner::new(5).direction(Direction::Vertical);
let area = Rect::new(0, 0, 1, 1);
let mut buf = Buffer::empty(area);
Widget::render(&s, area, &mut buf);
}
}