Skip to main content

tui_spinner/
rect_spinner.rs

1//! Square braille-arc spinner — exact port of the Go implementation.
2
3use std::collections::HashMap;
4
5use ratatui::buffer::Buffer;
6use ratatui::layout::{Alignment, Rect};
7use ratatui::style::{Color, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{Block, Widget};
10
11const BRAILLE_BASE: u32 = 0x2800;
12
13/// Braille bit index, indexed by `[row % 4][col % 2]`.
14const BRAILLE_MAP: [[u8; 2]; 4] = [
15    [0, 3], // row 0
16    [1, 4], // row 1
17    [2, 5], // row 2
18    [6, 7], // row 3
19];
20
21// ── Public enums ──────────────────────────────────────────────────────────────
22
23/// Rotation direction of the arc.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum Spin {
26    /// Arc travels clockwise around the perimeter (default).
27    #[default]
28    Clockwise,
29    /// Arc travels counter-clockwise around the perimeter.
30    CounterClockwise,
31}
32
33/// Whether the centre of the spinner is filled or empty.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum Centre {
36    /// The interior is filled with a solid block.
37    #[default]
38    Filled,
39    /// The interior is left empty — only the moving arc is visible.
40    Empty,
41}
42
43/// Shape of the spinner.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum RectShape {
46    /// A square spinner with arc thickness parameter (size 2–8).
47    Square(usize),
48}
49
50impl Default for RectShape {
51    fn default() -> Self {
52        Self::Square(2)
53    }
54}
55
56// ── Internal types — exact port of Go structs ─────────────────────────────────
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59struct Coord {
60    row: isize,
61    col: isize,
62}
63
64impl Coord {
65    fn new(row: isize, col: isize) -> Self {
66        Self { row, col }
67    }
68}
69
70struct Grid {
71    cells: Vec<Vec<bool>>,
72    offset: isize,
73}
74
75impl Grid {
76    /// `set` applies the offset, matching `grid.cells[row+grid.offset][col]`.
77    #[allow(clippy::cast_sign_loss)]
78    fn set(&mut self, row: isize, col: isize, value: bool) {
79        let r = (row + self.offset) as usize;
80        let c = col as usize;
81        if r < self.cells.len() && c < self.cells[0].len() {
82            self.cells[r][c] = value;
83        }
84    }
85
86    /// `fill` walks an L-shaped path from `start` to `end`, setting every cell
87    /// to `true`.  Exact port of Go `Grid.fill`.
88    fn fill(&mut self, start: Coord, end: Coord) {
89        let x: isize = if end.col < start.col { -1 } else { 1 };
90        let y: isize = if end.row < start.row { -1 } else { 1 };
91
92        let mut row = start.row;
93        let mut col = start.col;
94        self.set(row, col, true);
95
96        while row != end.row {
97            row += y;
98            self.set(row, col, true);
99        }
100        while col != end.col {
101            col += x;
102            self.set(row, col, true);
103        }
104    }
105}
106
107// ── Geometry helpers ──────────────────────────────────────────────────────────
108
109/// `calc_dimension` — Go: `low := 8; x := 5 * (size - 2); return low + x`
110fn calc_dimension(size: usize) -> usize {
111    8 + 5 * size.saturating_sub(2)
112}
113
114/// `vertical_offset` — Go: `if size == 2 { return 2 } return 0`
115fn vertical_offset(size: usize) -> isize {
116    if size == 2 {
117        2
118    } else {
119        0
120    }
121}
122
123// ── Centre ────────────────────────────────────────────────────────────────────
124
125/// Go: `make_centre(size, width)` returns `(map, start, end)`.
126/// Here we return the centre cell set and the two bounding `Coord`s.
127fn make_centre(size: isize, width: isize) -> (Vec<Coord>, Coord, Coord) {
128    let mid = width / 2;
129    let off = size / 2;
130
131    let start = Coord::new(mid - off, mid - off);
132
133    let mut cells = Vec::new();
134    for i in 0..size {
135        for j in 0..size {
136            cells.push(Coord::new(start.row + i, start.col + j));
137        }
138    }
139    let end = Coord::new(start.row + size - 1, start.col + size - 1);
140    (cells, start, end)
141}
142
143// ── Rotation maps ─────────────────────────────────────────────────────────────
144
145/// Exact port of Go `make_head_map`.
146fn make_head_map(width: isize, height: isize, size: isize) -> HashMap<Coord, Coord> {
147    let mut m = HashMap::new();
148    let end_col = width - 1;
149    let end_row = height - 1;
150
151    for n in 0..size {
152        m.insert(Coord::new(n, end_col), Coord::new(size, end_col - n));
153    }
154    for n in 0..size {
155        m.insert(
156            Coord::new(end_row, end_col - n),
157            Coord::new(end_row - n, end_col - size),
158        );
159    }
160    for n in 0..size {
161        m.insert(Coord::new(end_row - n, 0), Coord::new(end_col - size, n));
162    }
163    for n in 0..size {
164        m.insert(Coord::new(0, n), Coord::new(n, size));
165    }
166    m
167}
168
169/// Exact port of Go `make_tail_map`.
170fn make_tail_map(width: isize, height: isize, size: isize) -> HashMap<Coord, Coord> {
171    let mut m = HashMap::new();
172    let end_col = width - 1;
173    let end_row = height - 1;
174
175    for n in 0..size {
176        m.insert(Coord::new(size, n), Coord::new(n, 0));
177    }
178    for n in 0..size {
179        m.insert(Coord::new(n, end_col - size), Coord::new(0, end_col - n));
180    }
181    for n in 0..size {
182        m.insert(
183            Coord::new(end_row - size, end_col - n),
184            Coord::new(end_row - n, end_col),
185        );
186    }
187    for n in 0..size {
188        m.insert(Coord::new(end_row - n, size), Coord::new(end_row, n));
189    }
190    m
191}
192
193// ── Step logic — exact port ───────────────────────────────────────────────────
194
195fn rotate_nodes(nodes: &[Coord], rotation: &HashMap<Coord, Coord>) -> Option<Vec<Coord>> {
196    let mut transform = Vec::new();
197    for pos in nodes {
198        match rotation.get(pos) {
199            Some(&next) => transform.push(next),
200            None => return None,
201        }
202    }
203    Some(transform)
204}
205
206fn x_dir(nodes: &[Coord]) -> isize {
207    for pos in nodes {
208        if pos.row == 0 {
209            return 1;
210        }
211    }
212    -1
213}
214
215fn y_dir(nodes: &[Coord]) -> isize {
216    for pos in nodes {
217        if pos.col == 0 {
218            return -1;
219        }
220    }
221    1
222}
223
224fn traversing_x(nodes: &[Coord]) -> bool {
225    let first_col = nodes[0].col;
226    nodes.iter().skip(1).all(|n| n.col == first_col)
227}
228
229fn traversing_y(nodes: &[Coord]) -> bool {
230    let first_row = nodes[0].row;
231    nodes.iter().skip(1).all(|n| n.row == first_row)
232}
233
234/// Exact port of Go `step`.  Note: Go uses **two separate `if`s**, not
235/// `if / else if`.  We replicate that.
236fn step(nodes: &mut Vec<Coord>, rotate: &HashMap<Coord, Coord>) {
237    if let Some(next) = rotate_nodes(nodes, rotate) {
238        *nodes = next;
239        return;
240    }
241    if traversing_x(nodes) {
242        let dir = x_dir(nodes);
243        for n in nodes.iter_mut() {
244            n.col += dir;
245        }
246    }
247    if traversing_y(nodes) {
248        let dir = y_dir(nodes);
249        for n in nodes.iter_mut() {
250            n.row += dir;
251        }
252    }
253}
254
255// ── Centre bounds helper ──────────────────────────────────────────────────────
256
257/// Go: `should_switch(bounds [2]Coord, row, col)`
258fn should_switch(bounds: &[(usize, usize); 2], row: usize, col: usize) -> bool {
259    if row >= bounds[0].0 && row <= bounds[1].0 {
260        return col == bounds[0].1 || col == bounds[1].1;
261    }
262    false
263}
264
265// ── Engine — combines Grid + head/tail + rotation maps ────────────────────────
266
267struct SquareEngine {
268    grid: Grid,
269    head: Vec<Coord>,
270    tail: Vec<Coord>,
271    head_map: HashMap<Coord, Coord>,
272    tail_map: HashMap<Coord, Coord>,
273    centre_bounds: [(usize, usize); 2],
274    has_centre: bool,
275}
276
277impl SquareEngine {
278    /// Exact port of Go `makeSpinner`.
279    #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
280    fn build(size: usize, centre: Centre, _spin: Spin) -> Self {
281        let size = size.clamp(2, 8);
282        let dm = calc_dimension(size);
283        let offset = vertical_offset(size);
284        let sz = size as isize;
285        let dm_i = dm as isize;
286
287        // Go: cells = make([][]bool, dm+offset); cells[i] = make([]bool, dm)
288        let total_rows = dm as isize + offset;
289        let mut grid = Grid {
290            cells: vec![vec![false; dm]; total_rows as usize],
291            offset,
292        };
293
294        // Go: centre_map, start, end := make_centre(size, dm)
295        let (centre_cells, c_start, c_end) = make_centre(sz, dm_i);
296
297        // Go: bounds := [2]Coord{
298        //   {row: (start.row + grid.offset) / 4, col: (start.col / 2) - 1},
299        //   {row: (end.row + grid.offset) / 4,   col: end.col / 2},
300        // }
301        let centre_bounds = [
302            (
303                ((c_start.row + offset) / 4) as usize,
304                ((c_start.col / 2) - 1) as usize,
305            ),
306            (
307                ((c_end.row + offset) / 4) as usize,
308                (c_end.col / 2) as usize,
309            ),
310        ];
311
312        // Go: head, tail := key_nodes(dm, size)
313        let rem = (dm % 2) + ((size - 2) / 2);
314        let mid = ((dm / 2) + rem) as isize;
315
316        let head: Vec<Coord> = (0..sz).map(|n| Coord::new(n, mid)).collect();
317        let tail: Vec<Coord> = (0..sz).map(|n| Coord::new(mid, n)).collect();
318
319        // Go: for i := range head { grid.fill(tail[i], head[i]) }
320        for i in 0..size {
321            grid.fill(tail[i], head[i]);
322        }
323
324        // Go: for c := range centre_map { grid.set(c.row, c.col, true) }
325        let has_centre = matches!(centre, Centre::Filled);
326        if has_centre {
327            for c in &centre_cells {
328                grid.set(c.row, c.col, true);
329            }
330        }
331
332        // Go: width, height := len(cells)-offset, len(cells[0])
333        // len(cells) = dm+offset, so width = dm.   len(cells[0]) = dm, so height = dm.
334        let width = dm_i;
335        let height = dm_i;
336
337        Self {
338            grid,
339            head,
340            tail,
341            head_map: make_head_map(width, height, sz),
342            tail_map: make_tail_map(width, height, sz),
343            centre_bounds,
344            has_centre,
345        }
346    }
347
348    /// Exact port of Go `walk`.
349    fn walk(&mut self) {
350        step(&mut self.head, &self.head_map);
351
352        for pos in &self.head {
353            self.grid.set(pos.row, pos.col, true);
354        }
355        for pos in &self.tail {
356            self.grid.set(pos.row, pos.col, false);
357        }
358
359        step(&mut self.tail, &self.tail_map);
360    }
361
362    /// Exact port of Go `render_frame`.
363    fn render_frame(&self, outer_color: Color, inner_color: Color) -> Vec<Line<'static>> {
364        let total_rows = self.grid.cells.len();
365        let total_cols = self.grid.cells[0].len();
366
367        // Go: height := (len(sp.grid.cells) + 3) / 4
368        //     width  := (len(sp.grid.cells[0]) + 1) / 2
369        let char_rows = total_rows.div_ceil(4);
370        let char_cols = total_cols.div_ceil(2);
371
372        let mut screen = vec![vec![0u8; char_cols]; char_rows];
373
374        // Pack dots into braille bytes
375        for (row, row_cells) in self.grid.cells.iter().enumerate() {
376            for (col, &on) in row_cells.iter().enumerate() {
377                if !on {
378                    continue;
379                }
380                let i = row / 4;
381                let j = col / 2;
382                let bit = BRAILLE_MAP[row % 4][col % 2];
383                screen[i][j] |= 1 << bit;
384            }
385        }
386
387        // Render into Lines
388        let mut lines = Vec::with_capacity(char_rows);
389        let mut active = outer_color;
390
391        for (i, row) in screen.iter().enumerate() {
392            let mut spans = Vec::with_capacity(char_cols);
393            for (j, &b) in row.iter().enumerate() {
394                let ch = char::from_u32(BRAILLE_BASE + u32::from(b)).unwrap_or('\u{2800}');
395                spans.push(Span::styled(ch.to_string(), Style::default().fg(active)));
396
397                if self.has_centre && should_switch(&self.centre_bounds, i, j) {
398                    active = if active == outer_color {
399                        inner_color
400                    } else {
401                        outer_color
402                    };
403                }
404            }
405            lines.push(Line::from(spans));
406        }
407
408        lines
409    }
410}
411
412// ── Public widget ─────────────────────────────────────────────────────────────
413
414/// A simple square braille-arc spinner with filled or empty center.
415#[derive(Debug, Clone)]
416pub struct RectSpinner<'a> {
417    tick: u64,
418    shape: RectShape,
419    spin: Spin,
420    ticks_per_step: u64,
421    outer_color: Color,
422    inner_color: Color,
423    centre: Centre,
424    block: Option<Block<'a>>,
425    style: Style,
426    alignment: Alignment,
427}
428
429impl<'a> RectSpinner<'a> {
430    /// Creates a new [`RectSpinner`] at the given tick.
431    #[must_use]
432    pub fn new(tick: u64) -> Self {
433        Self {
434            tick,
435            shape: RectShape::default(),
436            spin: Spin::default(),
437            ticks_per_step: 1,
438            outer_color: Color::Cyan,
439            inner_color: Color::DarkGray,
440            centre: Centre::default(),
441            block: None,
442            style: Style::default(),
443            alignment: Alignment::Left,
444        }
445    }
446
447    /// Sets the shape and size.
448    #[must_use]
449    pub const fn shape(mut self, shape: RectShape) -> Self {
450        self.shape = shape;
451        self
452    }
453
454    /// Sets the rotation direction.
455    #[must_use]
456    pub const fn spin(mut self, spin: Spin) -> Self {
457        self.spin = spin;
458        self
459    }
460
461    /// Sets the outer arc color.
462    #[must_use]
463    pub const fn outer_color(mut self, color: Color) -> Self {
464        self.outer_color = color;
465        self
466    }
467
468    /// Sets the inner centre color.
469    #[must_use]
470    pub const fn inner_color(mut self, color: Color) -> Self {
471        self.inner_color = color;
472        self
473    }
474
475    /// Sets the centre fill mode.
476    #[must_use]
477    pub const fn centre(mut self, centre: Centre) -> Self {
478        self.centre = centre;
479        self
480    }
481
482    /// Sets ticks per step (higher = slower).
483    #[must_use]
484    pub fn ticks_per_step(mut self, n: u64) -> Self {
485        self.ticks_per_step = n.max(1);
486        self
487    }
488
489    /// Wraps the spinner in a [`Block`].
490    #[must_use]
491    pub fn block(mut self, block: Block<'a>) -> Self {
492        self.block = Some(block);
493        self
494    }
495
496    /// Sets the base style.
497    #[must_use]
498    pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
499        self.style = style.into();
500        self
501    }
502
503    /// Sets horizontal alignment.
504    #[must_use]
505    pub const fn alignment(mut self, alignment: Alignment) -> Self {
506        self.alignment = alignment;
507        self
508    }
509
510    fn render_lines(&self) -> Vec<Line<'static>> {
511        let mut engine = match self.shape {
512            RectShape::Square(size) => SquareEngine::build(size, self.centre, self.spin),
513        };
514
515        let steps = self.tick / self.ticks_per_step;
516        for _ in 0..steps {
517            engine.walk();
518        }
519
520        let mut lines = engine.render_frame(self.outer_color, self.inner_color);
521
522        if matches!(self.spin, Spin::CounterClockwise) {
523            for line in &mut lines {
524                line.spans.reverse();
525            }
526        }
527
528        lines
529    }
530}
531
532impl_styled_for!(RectSpinner<'_>);
533
534impl_widget_via_ref!(RectSpinner<'_>);
535
536impl Widget for &RectSpinner<'_> {
537    fn render(self, area: Rect, buf: &mut Buffer) {
538        render_spinner_body!(self, area, buf, self.render_lines());
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn square_engine_builds() {
548        for size in 2..=6 {
549            for centre in [Centre::Filled, Centre::Empty] {
550                let e = SquareEngine::build(size, centre, Spin::Clockwise);
551                assert!(!e.head.is_empty());
552                assert!(!e.tail.is_empty());
553            }
554        }
555    }
556
557    #[test]
558    fn square_engine_walk_does_not_panic() {
559        for size in 2..=4 {
560            let mut e = SquareEngine::build(size, Centre::Filled, Spin::Clockwise);
561            let dm = calc_dimension(size);
562            for _ in 0..dm * 8 {
563                e.walk();
564            }
565        }
566    }
567
568    #[test]
569    fn filled_vs_empty_differ() {
570        let filled = SquareEngine::build(2, Centre::Filled, Spin::Clockwise);
571        let empty = SquareEngine::build(2, Centre::Empty, Spin::Clockwise);
572
573        let lf = filled.render_frame(Color::Cyan, Color::DarkGray);
574        let le = empty.render_frame(Color::Cyan, Color::DarkGray);
575
576        assert_ne!(lf, le);
577    }
578
579    #[test]
580    fn widget_renders() {
581        let area = Rect::new(0, 0, 20, 10);
582        let mut buf = Buffer::empty(area);
583        Widget::render(&RectSpinner::new(0), area, &mut buf);
584    }
585
586    #[test]
587    fn cw_and_ccw_differ() {
588        let area = Rect::new(0, 0, 20, 10);
589        let mut b1 = Buffer::empty(area);
590        let mut b2 = Buffer::empty(area);
591
592        Widget::render(&RectSpinner::new(0).spin(Spin::Clockwise), area, &mut b1);
593        Widget::render(
594            &RectSpinner::new(0).spin(Spin::CounterClockwise),
595            area,
596            &mut b2,
597        );
598
599        assert_ne!(b1, b2);
600    }
601
602    #[test]
603    fn different_ticks_produce_different_output() {
604        let area = Rect::new(0, 0, 20, 10);
605        let mut b0 = Buffer::empty(area);
606        let mut b5 = Buffer::empty(area);
607        Widget::render(&RectSpinner::new(0), area, &mut b0);
608        Widget::render(&RectSpinner::new(5), area, &mut b5);
609        assert_ne!(b0, b5);
610    }
611}