use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Widget};
use crate::Spin;
pub struct FluxFrames;
impl FluxFrames {
pub const BRAILLE: &'static [char] = &['⣾', '⣷', '⣯', '⣟', '⡿', '⢿', '⣽', '⣻'];
pub const ORBIT: &'static [char] = &['⠁', '⠈', '⠐', '⠠', '⢀', '⡀', '⠄', '⠂'];
pub const CLASSIC: &'static [char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
pub const LINE: &'static [char] = &['│', '╱', '─', '╲'];
pub const BLOCK: &'static [char] = &['▖', '▘', '▝', '▗'];
pub const ARC: &'static [char] = &['◜', '◝', '◞', '◟'];
pub const CLOCK: &'static [char] = &['◷', '◶', '◵', '◴'];
pub const MOON: &'static [char] = &['◓', '◑', '◒', '◐'];
pub const TRIANGLES: &'static [char] = &['▲', '▶', '▼', '◀'];
pub const PULSE: &'static [char] = &['⣀', '⣤', '⣶', '⣾', '⣿', '⣾', '⣶', '⣤'];
pub const BOUNCE: &'static [char] = &['⠉', '⠒', '⣀', '⠒'];
pub const HALF: &'static [char] = &['▀', '▐', '▄', '▌'];
pub const SQUARE: &'static [char] = &['◰', '◳', '◲', '◱'];
pub const DICE: &'static [char] = &['⚀', '⚁', '⚂', '⚃', '⚄', '⚅'];
pub const BAR: &'static [char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
pub const CORNERS: &'static [char] = &['┌', '┐', '┘', '└'];
pub const CIRCLE_FILL: &'static [char] = &['○', '◔', '◑', '◕', '●'];
pub const PISTON: &'static [char] = &['▁', '▃', '▅', '▇', '█', '▇', '▅', '▃'];
pub const STAR: &'static [char] = &['✶', '✷', '✸', '✹'];
pub const PAIR: &'static [char] = &['⠉', '⠘', '⠰', '⢠', '⣀', '⡄', '⠆', '⠃'];
pub const DIAMOND: &'static [char] = &['◇', '◈', '◆', '◈'];
}
#[derive(Debug, Clone)]
pub struct FluxSpinner<'a> {
tick: u64,
width: usize,
height: usize,
spin: Spin,
color: Color,
ticks_per_step: u64,
phase_step: u8,
frames: &'static [char],
block: Option<Block<'a>>,
style: Style,
alignment: Alignment,
}
impl<'a> FluxSpinner<'a> {
#[must_use]
pub fn new(tick: u64) -> Self {
Self {
tick,
width: 1,
height: 1,
spin: Spin::Clockwise,
color: Color::Cyan,
ticks_per_step: 1,
phase_step: 1,
frames: FluxFrames::BRAILLE,
block: None,
style: Style::default(),
alignment: Alignment::Left,
}
}
#[must_use]
pub fn width(mut self, w: usize) -> Self {
self.width = w.max(1);
self
}
#[must_use]
pub fn height(mut self, h: usize) -> Self {
self.height = h.max(1);
self
}
#[must_use]
pub const fn spin(mut self, spin: Spin) -> Self {
self.spin = spin;
self
}
#[must_use]
pub const fn color(mut self, color: Color) -> Self {
self.color = color;
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 phase_step(mut self, step: u8) -> Self {
self.phase_step = step;
self
}
#[must_use]
pub fn frames(mut self, frames: &'static [char]) -> Self {
self.frames = frames;
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) -> (usize, usize) {
(self.width.max(1), self.height.max(1))
}
fn build_lines(&self) -> Vec<Line<'static>> {
let n = self.frames.len();
if n == 0 {
return vec![];
}
#[allow(clippy::cast_possible_truncation)]
let base = (self.tick / self.ticks_per_step) as usize;
let ccw = matches!(self.spin, Spin::CounterClockwise);
(0..self.height)
.map(|r| {
let spans: Vec<Span<'static>> = (0..self.width)
.map(|c| {
let cell_idx = r * self.width + c;
let phase = cell_idx * usize::from(self.phase_step);
let raw = base.wrapping_add(phase);
let frame_idx = if ccw { (n - raw % n) % n } else { raw % n };
let ch = self.frames[frame_idx];
Span::styled(ch.to_string(), Style::default().fg(self.color))
})
.collect();
Line::from(spans)
})
.collect()
}
}
impl_styled_for!(FluxSpinner<'_>);
impl_widget_via_ref!(FluxSpinner<'_>);
impl Widget for &FluxSpinner<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
render_spinner_body!(self, area, buf, self.build_lines());
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{backend::TestBackend, Terminal};
#[test]
fn braille_preset_has_eight_frames() {
assert_eq!(FluxFrames::BRAILLE.len(), 8);
}
#[test]
fn all_presets_non_empty() {
assert!(!FluxFrames::BRAILLE.is_empty());
assert!(!FluxFrames::ORBIT.is_empty());
assert!(!FluxFrames::CLASSIC.is_empty());
assert!(!FluxFrames::LINE.is_empty());
assert!(!FluxFrames::BLOCK.is_empty());
assert!(!FluxFrames::ARC.is_empty());
assert!(!FluxFrames::BOUNCE.is_empty());
assert!(!FluxFrames::HALF.is_empty());
assert!(!FluxFrames::SQUARE.is_empty());
assert!(!FluxFrames::DICE.is_empty());
assert!(!FluxFrames::CLOCK.is_empty());
assert!(!FluxFrames::MOON.is_empty());
assert!(!FluxFrames::TRIANGLES.is_empty());
assert!(!FluxFrames::PULSE.is_empty());
assert!(!FluxFrames::BAR.is_empty());
assert!(!FluxFrames::CORNERS.is_empty());
assert!(!FluxFrames::CIRCLE_FILL.is_empty());
assert!(!FluxFrames::PISTON.is_empty());
assert!(!FluxFrames::STAR.is_empty());
assert!(!FluxFrames::PAIR.is_empty());
assert!(!FluxFrames::DIAMOND.is_empty());
}
#[test]
fn all_presets_have_distinct_chars_within_set() {
for (name, preset) in [
("BRAILLE", FluxFrames::BRAILLE),
("ORBIT", FluxFrames::ORBIT),
("CLASSIC", FluxFrames::CLASSIC),
("LINE", FluxFrames::LINE),
("BLOCK", FluxFrames::BLOCK),
("ARC", FluxFrames::ARC),
("CLOCK", FluxFrames::CLOCK),
("MOON", FluxFrames::MOON),
("TRIANGLES", FluxFrames::TRIANGLES),
("HALF", FluxFrames::HALF),
("SQUARE", FluxFrames::SQUARE),
("DICE", FluxFrames::DICE),
("BAR", FluxFrames::BAR),
("CORNERS", FluxFrames::CORNERS),
("CIRCLE_FILL", FluxFrames::CIRCLE_FILL),
("STAR", FluxFrames::STAR),
("PAIR", FluxFrames::PAIR),
] {
let unique: std::collections::HashSet<char> = preset.iter().copied().collect();
assert_eq!(unique.len(), preset.len(), "{name} has duplicate chars");
}
}
#[test]
fn cw_advances_each_tick() {
let f0 = FluxSpinner::new(0).spin(Spin::Clockwise).build_lines();
let f1 = FluxSpinner::new(1).spin(Spin::Clockwise).build_lines();
assert_ne!(f0, f1, "consecutive ticks should produce different frames");
}
#[test]
fn cw_wraps_after_eight_steps() {
let f0 = FluxSpinner::new(0).spin(Spin::Clockwise).build_lines();
let f8 = FluxSpinner::new(8).spin(Spin::Clockwise).build_lines();
assert_eq!(f0, f8, "should wrap back to frame 0 after 8 ticks");
}
#[test]
fn ticks_per_step_slows_animation() {
let a = FluxSpinner::new(0).ticks_per_step(4).build_lines();
let b = FluxSpinner::new(3).ticks_per_step(4).build_lines();
assert_eq!(
a, b,
"ticks 0–3 should all be frame 0 when ticks_per_step=4"
);
let c = FluxSpinner::new(4).ticks_per_step(4).build_lines();
assert_ne!(a, c, "tick 4 should advance to frame 1");
}
#[test]
fn cw_and_ccw_differ_at_same_tick() {
let cw = FluxSpinner::new(1).spin(Spin::Clockwise).build_lines();
let ccw = FluxSpinner::new(1)
.spin(Spin::CounterClockwise)
.build_lines();
assert_ne!(
cw, ccw,
"CW and CCW should produce different frames at tick 1"
);
}
#[test]
fn cw_and_ccw_agree_at_tick_zero() {
let cw = FluxSpinner::new(0).spin(Spin::Clockwise).build_lines();
let ccw = FluxSpinner::new(0)
.spin(Spin::CounterClockwise)
.build_lines();
assert_eq!(cw, ccw, "both directions share frame 0 at tick 0");
}
#[test]
fn ccw_is_reverse_of_cw() {
let cw_1 = FluxSpinner::new(1).spin(Spin::Clockwise).build_lines();
let ccw_7 = FluxSpinner::new(7)
.spin(Spin::CounterClockwise)
.build_lines();
assert_eq!(cw_1, ccw_7, "CW tick 1 should equal CCW tick 7");
}
#[test]
fn ccw_wraps_after_eight_steps() {
let f0 = FluxSpinner::new(0)
.spin(Spin::CounterClockwise)
.build_lines();
let f8 = FluxSpinner::new(8)
.spin(Spin::CounterClockwise)
.build_lines();
assert_eq!(f0, f8, "CCW should wrap back to frame 0 after 8 ticks");
}
#[test]
fn ccw_wave_differs_from_cw_wave() {
let cw = FluxSpinner::new(1)
.width(4)
.phase_step(1)
.spin(Spin::Clockwise)
.build_lines();
let ccw = FluxSpinner::new(1)
.width(4)
.phase_step(1)
.spin(Spin::CounterClockwise)
.build_lines();
assert_ne!(
cw, ccw,
"CW and CCW waves should differ for width>1 at tick 1"
);
}
#[test]
fn phase_step_zero_all_cells_same() {
let lines = FluxSpinner::new(0).width(4).phase_step(0).build_lines();
let spans = &lines[0].spans;
let first = &spans[0].content;
for s in spans.iter().skip(1) {
assert_eq!(&s.content, first, "phase_step=0 → all cells identical");
}
}
#[test]
fn phase_step_one_cells_differ() {
let lines = FluxSpinner::new(0).width(4).phase_step(1).build_lines();
let spans = &lines[0].spans;
for pair in spans.windows(2) {
assert_ne!(
pair[0].content, pair[1].content,
"adjacent cells should differ with phase_step=1"
);
}
}
#[test]
fn phase_step_eight_wraps_to_same() {
let base = FluxSpinner::new(0).width(3).phase_step(0).build_lines();
let wrap = FluxSpinner::new(0).width(3).phase_step(8).build_lines();
assert_eq!(base, wrap, "phase_step=8 should behave like phase_step=0");
}
#[test]
fn output_dimensions_match_width_height() {
for w in 1..=5usize {
for h in 1..=3usize {
let lines = FluxSpinner::new(0).width(w).height(h).build_lines();
assert_eq!(lines.len(), h, "height={h}");
for (i, line) in lines.iter().enumerate() {
assert_eq!(line.spans.len(), w, "row {i}: width={w}");
}
}
}
}
#[test]
fn char_size_returns_width_height() {
let s = FluxSpinner::new(0).width(4).height(2);
assert_eq!(s.char_size(), (4, 2));
}
#[test]
fn char_size_clamps_to_one() {
let s = FluxSpinner::new(0).width(0).height(0);
let (w, h) = s.char_size();
assert!(w >= 1);
assert!(h >= 1);
}
#[test]
fn widget_renders_without_panic() {
let backend = TestBackend::new(10, 5);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
frame.render_widget(FluxSpinner::new(42).width(3).height(2), frame.area());
})
.unwrap();
}
#[test]
fn widget_single_char_renders() {
let backend = TestBackend::new(1, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
frame.render_widget(FluxSpinner::new(0), 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(FluxSpinner::new(0), frame.area());
})
.unwrap();
}
#[test]
fn builder_chain() {
use ratatui::widgets::Block;
let s = FluxSpinner::new(99)
.width(6)
.height(2)
.spin(Spin::CounterClockwise)
.color(Color::Green)
.ticks_per_step(3)
.phase_step(2)
.frames(FluxFrames::LINE)
.block(Block::bordered())
.alignment(Alignment::Center);
assert_eq!(s.width, 6);
assert_eq!(s.height, 2);
assert!(matches!(s.spin, Spin::CounterClockwise));
assert_eq!(s.color, Color::Green);
assert_eq!(s.ticks_per_step, 3);
assert_eq!(s.phase_step, 2);
assert_eq!(s.frames, FluxFrames::LINE);
}
#[test]
fn output_chars_come_from_frame_set() {
for tick in 0..8u64 {
for spin in [Spin::Clockwise, Spin::CounterClockwise] {
let lines = FluxSpinner::new(tick)
.width(4)
.height(2)
.spin(spin)
.build_lines();
let frame_set: std::collections::HashSet<char> =
FluxFrames::BRAILLE.iter().copied().collect();
for line in &lines {
for span in &line.spans {
let ch = span.content.chars().next().unwrap();
assert!(
frame_set.contains(&ch),
"U+{:04X} not in BRAILLE preset",
ch as u32
);
}
}
}
}
}
#[test]
fn custom_frames_respected() {
let frames: &'static [char] = &['a', 'b', 'c', 'd'];
let lines = FluxSpinner::new(0).frames(frames).build_lines();
let ch = lines[0].spans[0].content.chars().next().unwrap();
assert_eq!(ch, 'a', "tick=0 should show first custom frame");
let lines4 = FluxSpinner::new(4).frames(frames).build_lines();
let ch4 = lines4[0].spans[0].content.chars().next().unwrap();
assert_eq!(ch4, 'a', "tick=4 (n=4) wraps back to first frame");
}
#[test]
fn frames_builder_changes_output() {
let braille = FluxSpinner::new(1)
.frames(FluxFrames::BRAILLE)
.build_lines();
let line = FluxSpinner::new(1).frames(FluxFrames::LINE).build_lines();
assert_ne!(
braille, line,
"different frame sets produce different output"
);
}
}