Skip to main content

tui_spinner/
square_spinner.rs

1//! Square braille-arc spinner.
2//!
3//! A comet-like arc of braille dots travels around the perimeter of a square.
4//! The implementation is an exact port of a proven Go algorithm.
5//!
6//! # Examples
7//!
8//! ```no_run
9//! use ratatui::style::Color;
10//! use tui_spinner::{SquareSpinner, Spin, Centre};
11//!
12//! // Filled center, clockwise
13//! let spinner = SquareSpinner::new(42)
14//!     .size(3)
15//!     .arc_color(Color::Cyan)
16//!     .dim_color(Color::DarkGray)
17//!     .centre(Centre::Filled)
18//!     .spin(Spin::Clockwise);
19//!
20//! // Empty center, counter-clockwise
21//! let hollow = SquareSpinner::new(42)
22//!     .size(2)
23//!     .arc_color(Color::Green)
24//!     .centre(Centre::Empty)
25//!     .spin(Spin::CounterClockwise);
26//! ```
27
28use std::collections::HashMap;
29
30use ratatui::buffer::Buffer;
31use ratatui::layout::{Alignment, Rect};
32use ratatui::style::{Color, Style};
33use ratatui::text::{Line, Span};
34use ratatui::widgets::{Block, Widget};
35
36use crate::rect_spinner::Spin;
37
38// Re-export Centre so callers can use `tui_spinner::Centre`.
39pub use crate::rect_spinner::Centre;
40
41// ── Braille constants ─────────────────────────────────────────────────────────
42
43const BRAILLE_BASE: u32 = 0x2800;
44
45/// Bit index within a braille byte, indexed by `[row % 4][col % 2]`.
46const BRAILLE_MAP: [[u8; 2]; 4] = [
47    [0, 3], // row 0
48    [1, 4], // row 1
49    [2, 5], // row 2
50    [6, 7], // row 3
51];
52
53// ── Internal types ────────────────────────────────────────────────────────────
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56struct Coord {
57    row: isize,
58    col: isize,
59}
60
61impl Coord {
62    fn new(row: isize, col: isize) -> Self {
63        Self { row, col }
64    }
65}
66
67struct Grid {
68    cells: Vec<Vec<bool>>,
69    offset: isize,
70}
71
72impl Grid {
73    #[allow(clippy::cast_sign_loss)]
74    fn set(&mut self, row: isize, col: isize, value: bool) {
75        let r = (row + self.offset) as usize;
76        let c = col as usize;
77        if r < self.cells.len() && c < self.cells[0].len() {
78            self.cells[r][c] = value;
79        }
80    }
81
82    fn fill(&mut self, start: Coord, end: Coord) {
83        let x: isize = if end.col < start.col { -1 } else { 1 };
84        let y: isize = if end.row < start.row { -1 } else { 1 };
85
86        let mut row = start.row;
87        let mut col = start.col;
88        self.set(row, col, true);
89
90        while row != end.row {
91            row += y;
92            self.set(row, col, true);
93        }
94        while col != end.col {
95            col += x;
96            self.set(row, col, true);
97        }
98    }
99}
100
101// ── Geometry helpers ──────────────────────────────────────────────────────────
102
103fn calc_dimension(size: usize) -> usize {
104    8 + 5 * size.saturating_sub(2)
105}
106
107fn vertical_offset(size: usize) -> isize {
108    if size == 2 {
109        2
110    } else {
111        0
112    }
113}
114
115// ── Centre ────────────────────────────────────────────────────────────────────
116
117fn make_centre(size: isize, width: isize) -> (Vec<Coord>, Coord, Coord) {
118    let mid = width / 2;
119    let off = size / 2;
120    let start = Coord::new(mid - off, mid - off);
121
122    let mut cells = Vec::new();
123    for i in 0..size {
124        for j in 0..size {
125            cells.push(Coord::new(start.row + i, start.col + j));
126        }
127    }
128    let end = Coord::new(start.row + size - 1, start.col + size - 1);
129    (cells, start, end)
130}
131
132// ── Rotation maps ─────────────────────────────────────────────────────────────
133
134fn make_head_map(width: isize, height: isize, size: isize) -> HashMap<Coord, Coord> {
135    let mut m = HashMap::new();
136    let end_col = width - 1;
137    let end_row = height - 1;
138
139    for n in 0..size {
140        m.insert(Coord::new(n, end_col), Coord::new(size, end_col - n));
141    }
142    for n in 0..size {
143        m.insert(
144            Coord::new(end_row, end_col - n),
145            Coord::new(end_row - n, end_col - size),
146        );
147    }
148    for n in 0..size {
149        m.insert(Coord::new(end_row - n, 0), Coord::new(end_col - size, n));
150    }
151    for n in 0..size {
152        m.insert(Coord::new(0, n), Coord::new(n, size));
153    }
154    m
155}
156
157fn make_tail_map(width: isize, height: isize, size: isize) -> HashMap<Coord, Coord> {
158    let mut m = HashMap::new();
159    let end_col = width - 1;
160    let end_row = height - 1;
161
162    for n in 0..size {
163        m.insert(Coord::new(size, n), Coord::new(n, 0));
164    }
165    for n in 0..size {
166        m.insert(Coord::new(n, end_col - size), Coord::new(0, end_col - n));
167    }
168    for n in 0..size {
169        m.insert(
170            Coord::new(end_row - size, end_col - n),
171            Coord::new(end_row - n, end_col),
172        );
173    }
174    for n in 0..size {
175        m.insert(Coord::new(end_row - n, size), Coord::new(end_row, n));
176    }
177    m
178}
179
180// ── Step logic ────────────────────────────────────────────────────────────────
181
182fn rotate_nodes(nodes: &[Coord], rotation: &HashMap<Coord, Coord>) -> Option<Vec<Coord>> {
183    let mut transform = Vec::new();
184    for pos in nodes {
185        match rotation.get(pos) {
186            Some(&next) => transform.push(next),
187            None => return None,
188        }
189    }
190    Some(transform)
191}
192
193fn x_dir(nodes: &[Coord]) -> isize {
194    for pos in nodes {
195        if pos.row == 0 {
196            return 1;
197        }
198    }
199    -1
200}
201
202fn y_dir(nodes: &[Coord]) -> isize {
203    for pos in nodes {
204        if pos.col == 0 {
205            return -1;
206        }
207    }
208    1
209}
210
211fn traversing_x(nodes: &[Coord]) -> bool {
212    let first_col = nodes[0].col;
213    nodes.iter().skip(1).all(|n| n.col == first_col)
214}
215
216fn traversing_y(nodes: &[Coord]) -> bool {
217    let first_row = nodes[0].row;
218    nodes.iter().skip(1).all(|n| n.row == first_row)
219}
220
221fn step(nodes: &mut Vec<Coord>, rotate: &HashMap<Coord, Coord>) {
222    if let Some(next) = rotate_nodes(nodes, rotate) {
223        *nodes = next;
224        return;
225    }
226    if traversing_x(nodes) {
227        let dir = x_dir(nodes);
228        for n in nodes.iter_mut() {
229            n.col += dir;
230        }
231    }
232    if traversing_y(nodes) {
233        let dir = y_dir(nodes);
234        for n in nodes.iter_mut() {
235            n.row += dir;
236        }
237    }
238}
239
240// ── Centre bounds helper ──────────────────────────────────────────────────────
241
242fn should_switch(bounds: &[(usize, usize); 2], row: usize, col: usize) -> bool {
243    if row >= bounds[0].0 && row <= bounds[1].0 {
244        return col == bounds[0].1 || col == bounds[1].1;
245    }
246    false
247}
248
249// ── Engine ────────────────────────────────────────────────────────────────────
250
251struct SquareEngine {
252    grid: Grid,
253    head: Vec<Coord>,
254    tail: Vec<Coord>,
255    head_map: HashMap<Coord, Coord>,
256    tail_map: HashMap<Coord, Coord>,
257    centre_bounds: [(usize, usize); 2],
258    has_centre: bool,
259}
260
261impl SquareEngine {
262    #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
263    fn build(size: usize, centre: Centre) -> Self {
264        let size = size.clamp(2, 8);
265        let dm = calc_dimension(size);
266        let offset = vertical_offset(size);
267        let sz = size as isize;
268        let dm_i = dm as isize;
269
270        let total_rows = dm as isize + offset;
271        let mut grid = Grid {
272            cells: vec![vec![false; dm]; total_rows as usize],
273            offset,
274        };
275
276        let (centre_cells, c_start, c_end) = make_centre(sz, dm_i);
277
278        let centre_bounds = [
279            (
280                ((c_start.row + offset) / 4) as usize,
281                ((c_start.col / 2) - 1) as usize,
282            ),
283            (
284                ((c_end.row + offset) / 4) as usize,
285                (c_end.col / 2) as usize,
286            ),
287        ];
288
289        let rem = (dm % 2) + ((size - 2) / 2);
290        let mid = ((dm / 2) + rem) as isize;
291
292        let head: Vec<Coord> = (0..sz).map(|n| Coord::new(n, mid)).collect();
293        let tail: Vec<Coord> = (0..sz).map(|n| Coord::new(mid, n)).collect();
294
295        for i in 0..size {
296            grid.fill(tail[i], head[i]);
297        }
298
299        let has_centre = matches!(centre, Centre::Filled);
300        if has_centre {
301            for c in &centre_cells {
302                grid.set(c.row, c.col, true);
303            }
304        }
305
306        let width = dm_i;
307        let height = dm_i;
308
309        Self {
310            grid,
311            head,
312            tail,
313            head_map: make_head_map(width, height, sz),
314            tail_map: make_tail_map(width, height, sz),
315            centre_bounds,
316            has_centre,
317        }
318    }
319
320    fn walk(&mut self) {
321        step(&mut self.head, &self.head_map);
322
323        for pos in &self.head {
324            self.grid.set(pos.row, pos.col, true);
325        }
326        for pos in &self.tail {
327            self.grid.set(pos.row, pos.col, false);
328        }
329
330        step(&mut self.tail, &self.tail_map);
331    }
332
333    fn render_lines(&self, arc_color: Color, dim_color: Color) -> Vec<Line<'static>> {
334        let total_rows = self.grid.cells.len();
335        let total_cols = self.grid.cells[0].len();
336
337        let char_rows = total_rows.div_ceil(4);
338        let char_cols = total_cols.div_ceil(2);
339
340        let mut screen = vec![vec![0u8; char_cols]; char_rows];
341
342        for (row, row_cells) in self.grid.cells.iter().enumerate() {
343            for (col, &on) in row_cells.iter().enumerate() {
344                if !on {
345                    continue;
346                }
347                let i = row / 4;
348                let j = col / 2;
349                let bit = BRAILLE_MAP[row % 4][col % 2];
350                screen[i][j] |= 1 << bit;
351            }
352        }
353
354        let mut lines = Vec::with_capacity(char_rows);
355        let mut active = arc_color;
356
357        for (i, row) in screen.iter().enumerate() {
358            let mut spans = Vec::with_capacity(char_cols);
359            for (j, &b) in row.iter().enumerate() {
360                let ch = char::from_u32(BRAILLE_BASE + u32::from(b)).unwrap_or('\u{2800}');
361                spans.push(Span::styled(ch.to_string(), Style::default().fg(active)));
362
363                if self.has_centre && should_switch(&self.centre_bounds, i, j) {
364                    active = if active == arc_color {
365                        dim_color
366                    } else {
367                        arc_color
368                    };
369                }
370            }
371            lines.push(Line::from(spans));
372        }
373
374        lines
375    }
376}
377
378// ── Public widget ─────────────────────────────────────────────────────────────
379
380/// A rotating square braille-arc spinner.
381///
382/// A comet-like arc of braille dots travels around the perimeter of a square.
383/// Supports filled or empty center modes and clockwise/counter-clockwise
384/// directions.
385///
386/// # Quick start
387///
388/// ```no_run
389/// use ratatui::style::Color;
390/// use tui_spinner::{Centre, SquareSpinner, Spin};
391///
392/// let spinner = SquareSpinner::new(42)
393///     .size(3)
394///     .arc_color(Color::Cyan)
395///     .dim_color(Color::DarkGray)
396///     .centre(Centre::Filled);
397/// ```
398#[derive(Debug, Clone)]
399pub struct SquareSpinner<'a> {
400    tick: u64,
401    size: usize,
402    ticks_per_step: u64,
403    spin: Spin,
404    centre: Centre,
405    /// Colour of the rotating bright arc.
406    arc_color: Color,
407    /// Colour of the filled centre (when `Centre::Filled`).
408    dim_color: Color,
409    block: Option<Block<'a>>,
410    style: Style,
411    alignment: Alignment,
412}
413
414impl<'a> SquareSpinner<'a> {
415    /// Creates a new [`SquareSpinner`] with defaults: size 2, clockwise spin,
416    /// white arc, dark-gray dim, filled centre, 1 tick per step.
417    ///
418    /// # Examples
419    ///
420    /// ```
421    /// use tui_spinner::SquareSpinner;
422    ///
423    /// let spinner = SquareSpinner::new(42);
424    /// ```
425    #[must_use]
426    pub fn new(tick: u64) -> Self {
427        Self {
428            tick,
429            size: 2,
430            ticks_per_step: 1,
431            spin: Spin::Clockwise,
432            centre: Centre::Filled,
433            arc_color: Color::White,
434            dim_color: Color::DarkGray,
435            block: None,
436            style: Style::default(),
437            alignment: Alignment::Left,
438        }
439    }
440
441    /// Sets the arc thickness / square size (default: 2, range: 2–8).
442    ///
443    /// Larger values produce a bigger square with a thicker arc.
444    ///
445    /// # Examples
446    ///
447    /// ```
448    /// use tui_spinner::SquareSpinner;
449    ///
450    /// let large = SquareSpinner::new(0).size(4);
451    /// ```
452    #[must_use]
453    pub fn size(mut self, size: usize) -> Self {
454        self.size = size.clamp(2, 8);
455        self
456    }
457
458    /// Sets the spin direction (default: [`Spin::Clockwise`]).
459    ///
460    /// # Examples
461    ///
462    /// ```
463    /// use tui_spinner::{SquareSpinner, Spin};
464    ///
465    /// let ccw = SquareSpinner::new(0).spin(Spin::CounterClockwise);
466    /// ```
467    #[must_use]
468    pub const fn spin(mut self, spin: Spin) -> Self {
469        self.spin = spin;
470        self
471    }
472
473    /// Controls whether the centre is filled or empty (default: [`Centre::Filled`]).
474    ///
475    /// # Examples
476    ///
477    /// ```
478    /// use tui_spinner::{Centre, SquareSpinner};
479    ///
480    /// let hollow = SquareSpinner::new(0).centre(Centre::Empty);
481    /// ```
482    #[must_use]
483    pub const fn centre(mut self, centre: Centre) -> Self {
484        self.centre = centre;
485        self
486    }
487
488    /// Sets the colour of the rotating bright arc (default: [`Color::White`]).
489    ///
490    /// # Examples
491    ///
492    /// ```
493    /// use ratatui::style::Color;
494    /// use tui_spinner::SquareSpinner;
495    ///
496    /// let spinner = SquareSpinner::new(0).arc_color(Color::Cyan);
497    /// ```
498    #[must_use]
499    pub const fn arc_color(mut self, color: Color) -> Self {
500        self.arc_color = color;
501        self
502    }
503
504    /// Sets the colour of the filled centre region (default: [`Color::DarkGray`]).
505    ///
506    /// Only visible when [`Centre::Filled`] is active.
507    ///
508    /// # Examples
509    ///
510    /// ```
511    /// use ratatui::style::Color;
512    /// use tui_spinner::SquareSpinner;
513    ///
514    /// let spinner = SquareSpinner::new(0).dim_color(Color::DarkGray);
515    /// ```
516    #[must_use]
517    pub const fn dim_color(mut self, color: Color) -> Self {
518        self.dim_color = color;
519        self
520    }
521
522    /// Sets how many ticks each arc position is held (default: 1, higher = slower).
523    ///
524    /// # Examples
525    ///
526    /// ```
527    /// use tui_spinner::SquareSpinner;
528    ///
529    /// let slow = SquareSpinner::new(0).ticks_per_step(3);
530    /// ```
531    #[must_use]
532    pub fn ticks_per_step(mut self, n: u64) -> Self {
533        self.ticks_per_step = n.max(1);
534        self
535    }
536
537    /// Wraps the spinner in a [`Block`].
538    ///
539    /// # Examples
540    ///
541    /// ```
542    /// use ratatui::widgets::Block;
543    /// use tui_spinner::SquareSpinner;
544    ///
545    /// let spinner = SquareSpinner::new(0).block(Block::bordered().title("Loading…"));
546    /// ```
547    #[must_use]
548    pub fn block(mut self, block: Block<'a>) -> Self {
549        self.block = Some(block);
550        self
551    }
552
553    /// Sets the base style applied to the widget area.
554    #[must_use]
555    pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
556        self.style = style.into();
557        self
558    }
559
560    /// Sets the horizontal alignment of the rendered output (default: left).
561    #[must_use]
562    pub const fn alignment(mut self, alignment: Alignment) -> Self {
563        self.alignment = alignment;
564        self
565    }
566
567    /// Returns the exact rendered size in terminal characters `(cols, rows)`.
568    ///
569    /// # Examples
570    ///
571    /// ```
572    /// use tui_spinner::SquareSpinner;
573    ///
574    /// let (cols, rows) = SquareSpinner::new(0).size(2).char_size();
575    /// assert_eq!(cols, 4);
576    /// assert_eq!(rows, 3);
577    /// ```
578    #[must_use]
579    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
580    pub fn char_size(&self) -> (u16, u16) {
581        let dm = calc_dimension(self.size);
582        let offset = vertical_offset(self.size) as usize;
583        let total_rows = dm + offset;
584        let char_cols = dm.div_ceil(2);
585        let char_rows = total_rows.div_ceil(4);
586        (char_cols as u16, char_rows as u16)
587    }
588
589    fn build_lines(&self) -> Vec<Line<'static>> {
590        let mut engine = SquareEngine::build(self.size, self.centre);
591
592        #[allow(clippy::cast_possible_truncation)]
593        let steps = (self.tick / self.ticks_per_step) as usize;
594        for _ in 0..steps {
595            engine.walk();
596        }
597
598        let mut lines = engine.render_lines(self.arc_color, self.dim_color);
599
600        if matches!(self.spin, Spin::CounterClockwise) {
601            for line in &mut lines {
602                line.spans.reverse();
603            }
604        }
605
606        lines
607    }
608}
609
610impl_styled_for!(SquareSpinner<'_>);
611
612impl_widget_via_ref!(SquareSpinner<'_>);
613
614impl Widget for &SquareSpinner<'_> {
615    fn render(self, area: Rect, buf: &mut Buffer) {
616        render_spinner_body!(self, area, buf, self.build_lines());
617    }
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    #[test]
625    fn builds_all_sizes() {
626        for size in 2..=6 {
627            for centre in [Centre::Filled, Centre::Empty] {
628                let e = SquareEngine::build(size, centre);
629                assert!(!e.head.is_empty());
630                assert!(!e.tail.is_empty());
631            }
632        }
633    }
634
635    #[test]
636    fn walk_does_not_panic() {
637        for size in 2..=4 {
638            let mut e = SquareEngine::build(size, Centre::Filled);
639            let dm = calc_dimension(size);
640            for _ in 0..dm * 8 {
641                e.walk();
642            }
643        }
644    }
645
646    #[test]
647    fn filled_vs_empty_differ() {
648        let filled = SquareEngine::build(2, Centre::Filled);
649        let empty = SquareEngine::build(2, Centre::Empty);
650        let lf = filled.render_lines(Color::Cyan, Color::DarkGray);
651        let le = empty.render_lines(Color::Cyan, Color::DarkGray);
652        assert_ne!(lf, le);
653    }
654
655    #[test]
656    fn widget_renders_without_panic() {
657        let area = Rect::new(0, 0, 20, 10);
658        let mut buf = Buffer::empty(area);
659        Widget::render(&SquareSpinner::new(0), area, &mut buf);
660    }
661
662    #[test]
663    fn cw_and_ccw_differ() {
664        let area = Rect::new(0, 0, 20, 10);
665        let mut b1 = Buffer::empty(area);
666        let mut b2 = Buffer::empty(area);
667        Widget::render(&SquareSpinner::new(0).spin(Spin::Clockwise), area, &mut b1);
668        Widget::render(
669            &SquareSpinner::new(0).spin(Spin::CounterClockwise),
670            area,
671            &mut b2,
672        );
673        assert_ne!(b1, b2);
674    }
675
676    #[test]
677    fn different_ticks_differ() {
678        let area = Rect::new(0, 0, 20, 10);
679        let mut b0 = Buffer::empty(area);
680        let mut b5 = Buffer::empty(area);
681        Widget::render(&SquareSpinner::new(0), area, &mut b0);
682        Widget::render(&SquareSpinner::new(5), area, &mut b5);
683        assert_ne!(b0, b5);
684    }
685
686    #[test]
687    fn char_size_is_correct() {
688        let (cols, rows) = SquareSpinner::new(0).size(2).char_size();
689        assert_eq!(cols, 4);
690        assert_eq!(rows, 3);
691
692        let (cols3, rows3) = SquareSpinner::new(0).size(3).char_size();
693        assert!(cols3 > cols);
694        assert!(rows3 > rows);
695    }
696
697    #[test]
698    fn zero_area_no_panic() {
699        let area = Rect::new(0, 0, 0, 0);
700        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
701        Widget::render(&SquareSpinner::new(0), area, &mut buf);
702    }
703
704    #[test]
705    fn builder_chain() {
706        let s = SquareSpinner::new(10)
707            .size(4)
708            .spin(Spin::CounterClockwise)
709            .centre(Centre::Empty)
710            .arc_color(Color::Cyan)
711            .dim_color(Color::DarkGray)
712            .ticks_per_step(3)
713            .alignment(Alignment::Center);
714
715        assert_eq!(s.size, 4);
716        assert_eq!(s.spin, Spin::CounterClockwise);
717        assert_eq!(s.centre, Centre::Empty);
718        assert_eq!(s.arc_color, Color::Cyan);
719        assert_eq!(s.dim_color, Color::DarkGray);
720        assert_eq!(s.ticks_per_step, 3);
721    }
722}