use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use crate::Spin;
const FADE: [u8; 4] = [0x09, 0x1B, 0x3F, 0xFF];
const DIM_BYTE: u8 = 0xC0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BarTrack {
#[default]
Rail,
Full,
Empty,
Custom(u8),
}
impl BarTrack {
fn byte(self) -> u8 {
match self {
Self::Rail => DIM_BYTE,
Self::Full => 0xFF,
Self::Empty => 0x00,
Self::Custom(b) => b,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BarStyle {
#[default]
Braille,
Block,
Shade,
Dot,
Diamond,
Square,
Star,
Heart,
Arrow,
Circle,
Spark,
Cross,
Progress,
Thick,
Wave,
Pip,
}
impl BarStyle {
pub(crate) fn chars(self) -> Option<(char, char)> {
match self {
Self::Braille => None,
Self::Block => Some(('█', '░')),
Self::Shade => Some(('▓', '░')),
Self::Dot => Some(('●', '·')),
Self::Diamond => Some(('◆', '◇')),
Self::Square => Some(('■', '□')),
Self::Star => Some(('★', '☆')),
Self::Heart => Some(('♥', '♡')),
Self::Arrow => Some(('▶', '▷')),
Self::Circle => Some(('◉', '○')),
Self::Spark => Some(('✦', '✧')),
Self::Cross => Some(('✚', '✛')),
Self::Progress => Some(('▰', '▱')),
Self::Thick => Some(('━', '─')),
Self::Wave => Some(('≈', '˜')),
Self::Pip => Some(('▪', '·')),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BarMotion {
#[default]
Bounce,
Loop,
}
#[inline]
fn fade_byte(from_edge: usize, fade_width: usize, arc_byte: u8) -> u8 {
if fade_width == 0 || from_edge >= fade_width {
arc_byte
} else {
FADE[(from_edge * 3).div_ceil(fade_width).min(2)]
}
}
struct RectEngine {
char_w: usize,
char_h: usize,
arc_cols: usize,
anchor: usize,
going_forward: bool,
motion: BarMotion,
}
impl RectEngine {
fn build(
char_w: usize,
char_h: usize,
arc_width: usize,
spin: Spin,
motion: BarMotion,
) -> Self {
let char_w = char_w.max(3);
let char_h = char_h.max(1);
let arc_cols = if arc_width > 0 {
arc_width.min(char_w.saturating_sub(1))
} else {
char_w.div_ceil(3).max(4)
};
let going_forward = matches!(spin, Spin::Clockwise);
let anchor = if going_forward {
0
} else {
char_w.saturating_sub(arc_cols)
};
Self {
char_w,
char_h,
arc_cols,
anchor,
going_forward,
motion,
}
}
fn walk(&mut self) {
match self.motion {
BarMotion::Bounce => {
let max_anchor = self.char_w.saturating_sub(self.arc_cols);
if self.going_forward {
if self.anchor < max_anchor {
self.anchor += 1;
} else {
self.going_forward = false;
}
} else if self.anchor > 0 {
self.anchor -= 1;
} else {
self.going_forward = true;
}
}
BarMotion::Loop => {
if self.going_forward {
self.anchor = (self.anchor + 1) % self.char_w;
} else {
self.anchor = (self.anchor + self.char_w - 1) % self.char_w;
}
}
}
}
fn render_lines(
&self,
arc_color: Color,
dim_color: Color,
fade_width: usize,
track_byte: u8,
arc_byte: u8,
style_chars: Option<(char, char)>,
) -> Vec<Line<'static>> {
let char_w = self.char_w;
let arc_cols = self.arc_cols;
(0..self.char_h)
.map(|_| {
let spans: Vec<Span<'static>> = (0..char_w)
.map(|ci| {
let (in_arc, from_edge) = match self.motion {
BarMotion::Bounce => {
let arc_end = self.anchor + arc_cols;
if ci >= self.anchor && ci < arc_end {
let fe = (ci - self.anchor).min(arc_end - 1 - ci);
(true, fe)
} else {
(false, 0)
}
}
BarMotion::Loop => {
let offset = (ci + char_w - self.anchor) % char_w;
if offset < arc_cols {
let fe = offset.min(arc_cols - 1 - offset);
(true, fe)
} else {
(false, 0)
}
}
};
let (ch, color) = if let Some((arc_ch, track_ch)) = style_chars {
if in_arc {
(arc_ch, arc_color)
} else {
(track_ch, dim_color)
}
} else if in_arc {
let byte = fade_byte(from_edge, fade_width, arc_byte);
let ch = char::from_u32(0x2800 + u32::from(byte)).unwrap_or('\u{2800}');
(ch, arc_color)
} else {
let ch = char::from_u32(0x2800 + u32::from(track_byte))
.unwrap_or('\u{2800}');
(ch, dim_color)
};
Span::styled(ch.to_string(), Style::default().fg(color))
})
.collect();
Line::from(spans)
})
.collect()
}
}
#[derive(Debug, Clone)]
pub struct BarSpinner<'a> {
tick: u64,
width: usize,
height: usize,
arc_width: usize,
spin: Spin,
ticks_per_step: u64,
arc_color: Color,
dim_color: Color,
track: BarTrack,
fade_width: usize,
arc_byte: u8,
bar_style: BarStyle,
motion: BarMotion,
block: Option<Block<'a>>,
style: Style,
alignment: Alignment,
}
impl<'a> BarSpinner<'a> {
#[must_use]
pub fn zed(tick: u64) -> Self {
Self::new(tick)
.height(1)
.arc_color(Color::Cyan)
.dim_color(Color::DarkGray)
}
#[must_use]
pub fn claude(tick: u64) -> Self {
Self::new(tick)
.height(2)
.arc_color(Color::Rgb(255, 165, 0))
.dim_color(Color::DarkGray)
}
#[must_use]
pub fn minimal(tick: u64) -> Self {
Self::new(tick)
.height(1)
.arc_color(Color::White)
.dim_color(Color::Black)
.track(BarTrack::Empty)
}
#[must_use]
pub fn solid(tick: u64) -> Self {
Self::new(tick)
.height(1)
.arc_color(Color::Cyan)
.dim_color(Color::DarkGray)
.track(BarTrack::Full)
.fade_width(0)
}
#[must_use]
pub fn new(tick: u64) -> Self {
Self {
tick,
width: 0,
height: 1,
arc_width: 0,
spin: Spin::Clockwise,
ticks_per_step: 1,
arc_color: Color::Cyan,
dim_color: Color::DarkGray,
track: BarTrack::Rail,
fade_width: 3,
arc_byte: 0xFF,
bar_style: BarStyle::Braille,
motion: BarMotion::Bounce,
block: None,
style: Style::default(),
alignment: Alignment::Left,
}
}
#[must_use]
pub fn width(mut self, w: usize) -> Self {
self.width = w;
self
}
#[must_use]
pub fn height(mut self, h: usize) -> Self {
self.height = h.max(1);
self
}
#[must_use]
pub fn arc_width(mut self, w: usize) -> Self {
self.arc_width = w;
self
}
#[must_use]
pub const fn spin(mut self, spin: Spin) -> Self {
self.spin = spin;
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 arc_color(mut self, color: Color) -> Self {
self.arc_color = color;
self
}
#[must_use]
pub const fn dim_color(mut self, color: Color) -> Self {
self.dim_color = color;
self
}
#[must_use]
pub fn with_colors(mut self, arc_color: Color, dim_color: Color) -> Self {
self.arc_color = arc_color;
self.dim_color = dim_color;
self
}
#[must_use]
pub fn track(mut self, track: BarTrack) -> Self {
self.track = track;
self
}
#[must_use]
pub fn fade_width(mut self, w: usize) -> Self {
self.fade_width = w;
self
}
#[must_use]
pub fn arc_char(mut self, byte: u8) -> Self {
self.arc_byte = byte;
self
}
#[must_use]
pub fn bar_style(mut self, style: BarStyle) -> Self {
self.bar_style = style;
self
}
#[must_use]
pub fn motion(mut self, motion: BarMotion) -> Self {
self.motion = motion;
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
}
#[must_use]
pub const fn alignment(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self
}
#[must_use]
pub fn char_size(&self) -> Option<(usize, usize)> {
if self.width == 0 {
None
} else {
Some((self.width.max(3), self.height.max(1)))
}
}
fn build_lines(&self, actual_width: usize) -> Vec<Line<'static>> {
let w = actual_width.max(3);
let mut engine = RectEngine::build(w, self.height, self.arc_width, self.spin, self.motion);
#[allow(clippy::cast_possible_truncation)]
let steps = (self.tick / self.ticks_per_step) as usize;
for _ in 0..steps {
engine.walk();
}
engine.render_lines(
self.arc_color,
self.dim_color,
self.fade_width,
self.track.byte(),
self.arc_byte,
self.bar_style.chars(),
)
}
}
impl_styled_for!(BarSpinner<'_>);
impl_widget_via_ref!(BarSpinner<'_>);
impl Widget for &BarSpinner<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.area() == 0 {
return;
}
buf.set_style(area, self.style);
let inner_area = self.block.as_ref().map_or(area, |b| {
let inner = b.inner(area);
Widget::render(b.clone(), area, buf);
inner
});
if inner_area.area() == 0 {
return;
}
let actual_width = if self.width == 0 {
inner_area.width as usize
} else {
self.width
};
let lines = self.build_lines(actual_width);
Paragraph::new(lines)
.alignment(self.alignment)
.render(inner_area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{backend::TestBackend, Terminal};
#[test]
fn engine_builds_without_panic() {
for w in 3..=30usize {
for h in 1..=5usize {
for spin in [Spin::Clockwise, Spin::CounterClockwise] {
let _ = RectEngine::build(w, h, 0, spin, BarMotion::Bounce);
}
}
}
}
#[test]
fn engine_walk_does_not_panic() {
let mut e = RectEngine::build(20, 1, 0, Spin::Clockwise, BarMotion::Bounce);
for _ in 0..1000 {
e.walk();
}
}
#[test]
fn engine_anchor_stays_in_bounds() {
let mut e = RectEngine::build(20, 1, 0, Spin::Clockwise, BarMotion::Bounce);
let max_anchor = e.char_w.saturating_sub(e.arc_cols);
for _ in 0..500 {
e.walk();
assert!(
e.anchor <= max_anchor,
"anchor={} exceeds max={max_anchor}",
e.anchor
);
}
}
#[test]
fn engine_bounces_direction() {
let mut e = RectEngine::build(20, 1, 0, Spin::Clockwise, BarMotion::Bounce);
assert!(e.going_forward, "should start going forward (CW)");
let mut reversed = false;
for _ in 0..100 {
e.walk();
if !e.going_forward {
reversed = true;
break;
}
}
assert!(reversed, "engine never reversed to backward");
let mut re_reversed = false;
for _ in 0..100 {
e.walk();
if e.going_forward {
re_reversed = true;
break;
}
}
assert!(re_reversed, "engine never reversed back to forward");
}
#[test]
fn cw_starts_at_left_ccw_at_right() {
let cw = RectEngine::build(20, 1, 0, Spin::Clockwise, BarMotion::Bounce);
let ccw = RectEngine::build(20, 1, 0, Spin::CounterClockwise, BarMotion::Bounce);
assert_eq!(cw.anchor, 0, "CW should start at column 0");
assert!(
ccw.anchor > 0,
"CCW should start at the right (anchor={})",
ccw.anchor
);
assert!(cw.going_forward);
assert!(!ccw.going_forward);
}
#[test]
fn arc_edges_use_fade_bytes() {
let e = RectEngine::build(20, 1, 12, Spin::Clockwise, BarMotion::Bounce);
let lines = e.render_lines(Color::Cyan, Color::DarkGray, 3, DIM_BYTE, 0xFF, None);
assert_eq!(lines.len(), 1);
let spans = &lines[0].spans;
let outer = char::from_u32(0x2800 + u32::from(FADE[0])).unwrap();
assert_eq!(
spans[e.anchor].content.chars().next(),
Some(outer),
"outermost arc edge should be FADE[0]"
);
let centre_idx = e.anchor + e.arc_cols / 2;
let full = char::from_u32(0x2800 + u32::from(FADE[3])).unwrap();
assert_eq!(
spans[centre_idx].content.chars().next(),
Some(full),
"arc centre should be full density FADE[3]"
);
}
#[test]
fn dim_columns_use_dim_byte() {
let e = RectEngine::build(20, 1, 6, Spin::Clockwise, BarMotion::Bounce);
let lines = e.render_lines(Color::Cyan, Color::DarkGray, 3, DIM_BYTE, 0xFF, None);
let spans = &lines[0].spans;
let dim_char = char::from_u32(0x2800 + u32::from(DIM_BYTE)).unwrap();
for i in 0..e.anchor {
assert_eq!(
spans[i].content.chars().next(),
Some(dim_char),
"column {i} should be dim"
);
}
}
#[test]
fn build_lines_height_matches() {
for h in 1..=5usize {
let lines = BarSpinner::new(0).width(20).height(h).build_lines(20);
assert_eq!(lines.len(), h, "height={h}");
}
}
#[test]
fn build_lines_width_matches() {
let w = 24usize;
let lines = BarSpinner::new(0).width(w).height(1).build_lines(w);
assert_eq!(lines[0].spans.len(), w, "each line should have {w} spans");
}
#[test]
fn different_ticks_produce_different_output() {
let a = BarSpinner::new(0).width(20).height(1).build_lines(20);
let b = BarSpinner::new(8).width(20).height(1).build_lines(20);
assert_ne!(a, b, "tick=0 and tick=8 should differ");
}
#[test]
fn cw_and_ccw_differ_at_same_tick() {
let cw = BarSpinner::new(5)
.width(20)
.height(1)
.spin(Spin::Clockwise)
.build_lines(20);
let ccw = BarSpinner::new(5)
.width(20)
.height(1)
.spin(Spin::CounterClockwise)
.build_lines(20);
assert_ne!(cw, ccw, "CW and CCW should differ at the same tick");
}
#[test]
fn ticks_per_step_slows_animation() {
let fast = BarSpinner::new(10)
.width(20)
.height(1)
.ticks_per_step(1)
.build_lines(20);
let slow = BarSpinner::new(10)
.width(20)
.height(1)
.ticks_per_step(5)
.build_lines(20);
assert_ne!(
fast, slow,
"different speeds should produce different output"
);
}
#[test]
fn arc_width_override_respected() {
let e = RectEngine::build(20, 1, 7, Spin::Clockwise, BarMotion::Bounce);
assert_eq!(e.arc_cols, 7);
}
#[test]
fn widget_renders_without_panic() {
let backend = TestBackend::new(40, 3);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
frame.render_widget(BarSpinner::new(42), frame.area());
})
.unwrap();
}
#[test]
fn widget_fixed_width_renders_without_panic() {
let backend = TestBackend::new(40, 3);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
frame.render_widget(BarSpinner::new(42).width(24).height(2), frame.area());
})
.unwrap();
}
#[test]
fn widget_zero_area_no_panic() {
let backend = TestBackend::new(0, 0);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
frame.render_widget(BarSpinner::new(0), frame.area());
})
.unwrap();
}
#[test]
fn char_size_fixed_width() {
let s = BarSpinner::new(0).width(20).height(2);
assert_eq!(s.char_size(), Some((20, 2)));
}
#[test]
fn char_size_auto_width_is_none() {
let s = BarSpinner::new(0); assert_eq!(s.char_size(), None);
}
#[test]
fn char_size_clamps_minimum() {
let s = BarSpinner::new(0).width(1).height(0);
if let Some((w, h)) = s.char_size() {
assert!(w >= 3, "width clamped to at least 3");
assert!(h >= 1, "height clamped to at least 1");
}
}
#[test]
fn builder_chain() {
use ratatui::widgets::Block;
let s = BarSpinner::new(0)
.width(24)
.height(2)
.arc_width(8)
.spin(Spin::CounterClockwise)
.ticks_per_step(3)
.arc_color(Color::Blue)
.dim_color(Color::Black)
.block(Block::bordered())
.alignment(Alignment::Center);
assert_eq!(s.width, 24);
assert_eq!(s.height, 2);
assert_eq!(s.arc_width, 8);
assert!(matches!(s.spin, Spin::CounterClockwise));
assert_eq!(s.ticks_per_step, 3);
assert_eq!(s.arc_color, Color::Blue);
assert_eq!(s.dim_color, Color::Black);
}
#[test]
fn arc_char_changes_centre_byte() {
let e = RectEngine::build(20, 1, 10, Spin::Clockwise, BarMotion::Bounce);
let lines_default = e.render_lines(Color::Cyan, Color::DarkGray, 3, DIM_BYTE, 0xFF, None);
let lines_custom = e.render_lines(Color::Cyan, Color::DarkGray, 3, DIM_BYTE, 0x3F, None);
assert_ne!(
lines_default, lines_custom,
"different arc_byte produces different output"
);
let centre_idx = e.anchor + e.arc_cols / 2;
let centre_char = lines_custom[0].spans[centre_idx]
.content
.chars()
.next()
.unwrap();
assert_eq!(
centre_char, '\u{283F}',
"centre cell should be ⠿ when arc_byte=0x3F"
);
}
#[test]
fn preset_zed_defaults() {
let s = BarSpinner::zed(0);
assert_eq!(s.height, 1);
assert_eq!(s.arc_color, Color::Cyan);
}
#[test]
fn preset_solid_has_full_track_and_zero_fade() {
let s = BarSpinner::solid(0);
assert_eq!(s.track, BarTrack::Full);
assert_eq!(s.fade_width, 0);
}
#[test]
fn track_and_fade_width_builder() {
let s = BarSpinner::new(0).track(BarTrack::Full).fade_width(0);
assert_eq!(s.track, BarTrack::Full);
assert_eq!(s.fade_width, 0);
let lines = s.width(12).build_lines(12);
for line in &lines {
for span in &line.spans {
let ch = span.content.chars().next().unwrap();
assert_eq!(ch, '\u{28FF}', "sharp fade + Full track → every cell is ⣿");
}
}
}
#[test]
fn with_colors_sets_both() {
use ratatui::style::Color;
let s = BarSpinner::new(0).with_colors(Color::Red, Color::Blue);
assert_eq!(s.arc_color, Color::Red);
assert_eq!(s.dim_color, Color::Blue);
}
#[test]
fn arc_width_larger_than_bar_is_clamped() {
let e = RectEngine::build(10, 1, 99, Spin::Clockwise, BarMotion::Bounce);
assert!(e.arc_cols < e.char_w, "arc_cols must be < char_w");
}
#[test]
fn arc_width_zero_uses_auto() {
let e = RectEngine::build(30, 1, 0, Spin::Clockwise, BarMotion::Bounce);
assert!(e.arc_cols >= 4, "auto arc should be at least 4 cols");
assert!(e.arc_cols < e.char_w);
}
#[test]
fn loop_all_columns_lit_over_full_cycle() {
let width = 12usize;
let arc_cols = 4usize;
let mut e = RectEngine::build(width, 1, arc_cols, Spin::Clockwise, BarMotion::Loop);
let mut ever_lit = vec![false; width];
for _ in 0..width {
let lines = e.render_lines(
ratatui::style::Color::Cyan,
ratatui::style::Color::DarkGray,
3,
DIM_BYTE,
0xFF,
None,
);
for (ci, span) in lines[0].spans.iter().enumerate() {
if span.style.fg == Some(ratatui::style::Color::Cyan) {
ever_lit[ci] = true;
}
}
e.walk();
}
for (ci, &lit) in ever_lit.iter().enumerate() {
assert!(lit, "column {ci} was never lit during a full Loop cycle");
}
}
#[test]
fn non_braille_style_chars_match_declaration() {
for (style, expected_arc, expected_track) in [
(BarStyle::Block, '\u{2588}', '\u{2591}'),
(BarStyle::Dot, '\u{25CF}', '\u{00B7}'),
(BarStyle::Star, '\u{2605}', '\u{2606}'),
(BarStyle::Progress, '\u{25B0}', '\u{25B1}'),
] {
let Some((arc, track)) = style.chars() else {
panic!("{style:?} should have char pair");
};
assert_eq!(arc, expected_arc, "{style:?} arc char mismatch");
assert_eq!(track, expected_track, "{style:?} track char mismatch");
}
}
#[test]
fn bar_style_block_produces_non_braille_chars() {
let lines = BarSpinner::new(0)
.width(20)
.bar_style(BarStyle::Block)
.build_lines(20);
for line in &lines {
for span in &line.spans {
let ch = span.content.chars().next().unwrap();
assert!(
ch == '█' || ch == '░',
"Block style: unexpected char U+{:04X}",
ch as u32
);
}
}
}
#[test]
fn all_non_braille_styles_have_char_pairs() {
let styles = [
BarStyle::Block,
BarStyle::Shade,
BarStyle::Dot,
BarStyle::Diamond,
BarStyle::Square,
BarStyle::Star,
BarStyle::Heart,
BarStyle::Arrow,
BarStyle::Circle,
BarStyle::Spark,
BarStyle::Cross,
BarStyle::Progress,
BarStyle::Thick,
BarStyle::Wave,
BarStyle::Pip,
];
for style in styles {
assert!(style.chars().is_some(), "{style:?} should have a char pair");
}
assert!(BarStyle::Braille.chars().is_none());
}
#[test]
fn bar_style_builder() {
let s = BarSpinner::new(0).bar_style(BarStyle::Dot);
assert_eq!(s.bar_style, BarStyle::Dot);
}
#[test]
fn loop_motion_wraps_at_right_edge() {
let mut e = RectEngine::build(20, 1, 4, Spin::Clockwise, BarMotion::Loop);
let last = e.char_w - 1;
for _ in 0..200 {
if e.anchor == last {
break;
}
e.walk();
}
assert_eq!(e.anchor, last, "anchor should reach char_w-1");
e.walk();
assert_eq!(e.anchor, 0, "Loop CW should wrap to 0");
assert!(e.going_forward, "Loop must not flip direction");
}
#[test]
fn loop_motion_wraps_at_left_edge_ccw() {
let mut e = RectEngine::build(20, 1, 4, Spin::CounterClockwise, BarMotion::Loop);
for _ in 0..200 {
if e.anchor == 0 {
break;
}
e.walk();
}
assert_eq!(e.anchor, 0);
e.walk();
assert_eq!(e.anchor, e.char_w - 1, "CCW Loop should wrap to char_w-1");
assert!(!e.going_forward);
}
#[test]
fn bounce_still_reverses() {
let mut e = RectEngine::build(20, 1, 4, Spin::Clockwise, BarMotion::Bounce);
let max = e.char_w - e.arc_cols;
for _ in 0..100 {
if e.anchor == max {
break;
}
e.walk();
}
e.walk();
assert!(!e.going_forward, "Bounce must reverse at max_anchor");
}
#[test]
fn motion_builder() {
let s = BarSpinner::new(0).motion(BarMotion::Loop);
assert_eq!(s.motion, BarMotion::Loop);
}
}