Skip to main content

tui_spinner/
circle_spinner.rs

1//! Circle braille-arc spinner.
2//!
3//! A comet-like arc rotates around a circular braille-dot ring.
4//!
5//! ## How it works
6//!
7//! 1. The circle perimeter is computed with the midpoint circle algorithm.
8//!    Dot coordinates are stored as `(row, col)` in a 1:1 dot pitch — one
9//!    braille dot column is one unit wide, one braille dot row is one unit
10//!    tall.  Because each braille character packs 2 dot-cols horizontally
11//!    and 4 dot-rows vertically, and terminal cells are ~2× taller than
12//!    wide, these two factors cancel exactly: 1 dot-col pixel width =
13//!    `cell_w/2`, 1 dot-row pixel height = `cell_h/4` = `cell_w/2`.  So a 1:1
14//!    dot pitch produces a visually round circle.
15//!
16//! 2. The perimeter dots are sorted clockwise (12-o'clock first).
17//!
18//! 3. A boolean dot-grid is allocated to the bounding box of the circle.
19//!    No interior fill — only the perimeter ring is ever drawn.
20//!
21//! 4. Head and tail indices step through the perimeter list each `walk()`
22//!    call: head sets its dot `true`, tail clears its dot `false`.  This
23//!    produces the travelling comet arc identical to [`crate::RectSpinner`].
24//!
25//! 5. The grid is packed into braille bytes and rendered as [`Line`]s.
26//!    Arc dots use `arc_color`; the dim remainder of the ring uses
27//!    `dim_color`.
28
29use std::collections::HashSet;
30
31use crate::rect_spinner::Spin;
32
33use ratatui::buffer::Buffer;
34use ratatui::layout::{Alignment, Rect};
35use ratatui::style::{Color, Style};
36use ratatui::text::{Line, Span};
37use ratatui::widgets::{Block, Widget};
38
39// ── Braille constants ─────────────────────────────────────────────────────────
40
41const BRAILLE_BASE: u32 = 0x2800;
42
43/// Bit index within a braille byte, indexed by `(dot_row % 4, dot_col % 2)`.
44const BRAILLE_MAP: [[u8; 2]; 4] = [
45    [0, 3], // row 0: left→bit 0,  right→bit 3
46    [1, 4], // row 1: left→bit 1,  right→bit 4
47    [2, 5], // row 2: left→bit 2,  right→bit 5
48    [6, 7], // row 3: left→bit 6,  right→bit 7
49];
50
51// ── Dot ──────────────────────────────────────────────────────────────────────
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54struct Dot {
55    row: isize,
56    col: isize,
57}
58
59impl Dot {
60    const fn new(row: isize, col: isize) -> Self {
61        Self { row, col }
62    }
63}
64
65// ── Perimeter ─────────────────────────────────────────────────────────────────
66
67/// Compute all dot positions on a circle perimeter using the midpoint circle
68/// algorithm at 1:1 dot pitch, then sort them clockwise from 12 o'clock.
69#[allow(clippy::cast_possible_wrap)]
70fn circle_perimeter(r: usize) -> Vec<Dot> {
71    if r == 0 {
72        return vec![Dot::new(0, 0)];
73    }
74
75    let ri = r as isize;
76    let mut points: HashSet<(isize, isize)> = HashSet::new();
77
78    let mut x: isize = 0;
79    let mut y: isize = ri;
80    let mut d: isize = 1 - ri;
81
82    while x <= y {
83        // All 8 octant reflections — no column scaling, pure 1:1 pitch.
84        for &(px, py) in &[
85            (x, -y),
86            (y, -x),
87            (y, x),
88            (x, y),
89            (-x, y),
90            (-y, x),
91            (-y, -x),
92            (-x, -y),
93        ] {
94            points.insert((py, px)); // (row, col) — no ×2
95        }
96        if d < 0 {
97            d += 2 * x + 3;
98        } else {
99            d += 2 * (x - y) + 5;
100            y -= 1;
101        }
102        x += 1;
103    }
104
105    sort_clockwise(points.into_iter().collect())
106}
107
108/// Sort `(row, col)` dot coords into clockwise order starting from 12 o'clock.
109#[allow(clippy::cast_precision_loss)]
110fn sort_clockwise(dots: Vec<(isize, isize)>) -> Vec<Dot> {
111    if dots.is_empty() {
112        return vec![];
113    }
114
115    let n = dots.len() as f64;
116    let cr = dots.iter().map(|&(r, _)| r as f64).sum::<f64>() / n;
117    let cc = dots.iter().map(|&(_, c)| c as f64).sum::<f64>() / n;
118
119    let mut with_angle: Vec<(f64, isize, isize)> = dots
120        .into_iter()
121        .map(|(r, c)| {
122            let dr = -(r as f64 - cr);
123            let dc = c as f64 - cc;
124            let raw = dc.atan2(dr); // 0 = top, increases clockwise
125            let angle = if raw < 0.0 {
126                raw + 2.0 * std::f64::consts::PI
127            } else {
128                raw
129            };
130            (angle, r, c)
131        })
132        .collect();
133
134    with_angle.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
135
136    with_angle
137        .into_iter()
138        .map(|(_, r, c)| Dot::new(r, c))
139        .collect()
140}
141
142// ── Arc engine ────────────────────────────────────────────────────────────────
143
144/// All state for one animation frame of a circle spinner.
145#[derive(Debug, Clone)]
146struct CircleEngine {
147    /// Boolean dot-grid; dimensions `dot_rows × dot_cols`.
148    cells: Vec<Vec<bool>>,
149    dot_rows: usize,
150    dot_cols: usize,
151    /// Offset to map perimeter-space coords → grid indices.
152    row_offset: isize,
153    col_offset: isize,
154    /// Sorted clockwise perimeter dot list.
155    perimeter: Vec<Dot>,
156    /// Index of the arc front (most recently lit dot).
157    head: usize,
158    /// Index of the arc back (next dot to be erased).
159    tail: usize,
160    /// Number of perimeter dots in the lit arc.
161    arc_len: usize,
162}
163
164impl CircleEngine {
165    #[allow(clippy::cast_sign_loss)]
166    fn build(radius: usize, arc_len_override: usize, spin: Spin) -> Self {
167        let perimeter = circle_perimeter(radius);
168        let n = perimeter.len();
169
170        let min_row = perimeter.iter().map(|d| d.row).min().unwrap_or(0);
171        let max_row = perimeter.iter().map(|d| d.row).max().unwrap_or(0);
172        let min_col = perimeter.iter().map(|d| d.col).min().unwrap_or(0);
173        let max_col = perimeter.iter().map(|d| d.col).max().unwrap_or(0);
174
175        let row_offset = -min_row;
176        let col_offset = -min_col;
177        let dot_rows = (max_row - min_row + 1) as usize;
178        let dot_cols = (max_col - min_col + 1) as usize;
179
180        let cells = vec![vec![false; dot_cols]; dot_rows];
181
182        let arc_len = if arc_len_override > 0 {
183            arc_len_override.min(n)
184        } else {
185            (n / 4).max(1)
186        };
187
188        // Head starts at index 0 (top of circle, clockwise) or index n-1
189        // (top of circle, counter-clockwise); tail trails arc_len behind.
190        let head = 0usize;
191        let tail = if matches!(spin, Spin::CounterClockwise) {
192            arc_len % n
193        } else {
194            (n - arc_len) % n
195        };
196
197        let mut engine = Self {
198            cells,
199            dot_rows,
200            dot_cols,
201            row_offset,
202            col_offset,
203            perimeter,
204            head,
205            tail,
206            arc_len,
207        };
208
209        // Light the initial arc.
210        for i in 0..arc_len {
211            let dot = engine.perimeter[i];
212            engine.set_dot(dot, true);
213        }
214
215        engine
216    }
217
218    #[inline]
219    #[allow(clippy::cast_sign_loss)]
220    fn set_dot(&mut self, dot: Dot, value: bool) {
221        let r = (dot.row + self.row_offset) as usize;
222        let c = (dot.col + self.col_offset) as usize;
223        if r < self.dot_rows && c < self.dot_cols {
224            self.cells[r][c] = value;
225        }
226    }
227
228    /// Advance one step: head lights next dot, tail erases its dot.
229    fn walk(&mut self, spin: Spin) {
230        let n = self.perimeter.len();
231
232        if matches!(spin, Spin::CounterClockwise) {
233            self.head = (self.head + n - 1) % n;
234            let new_head = self.perimeter[self.head];
235            self.set_dot(new_head, true);
236
237            let old_tail = self.perimeter[self.tail];
238            self.set_dot(old_tail, false);
239            self.tail = (self.tail + n - 1) % n;
240        } else {
241            self.head = (self.head + 1) % n;
242            let new_head = self.perimeter[self.head];
243            self.set_dot(new_head, true);
244
245            let old_tail = self.perimeter[self.tail];
246            self.set_dot(old_tail, false);
247            self.tail = (self.tail + 1) % n;
248        }
249    }
250
251    /// Render the grid into braille [`Line`]s.
252    ///
253    /// Arc dots → `arc_color`, dim ring dots → `dim_color`.
254    #[allow(clippy::cast_sign_loss)]
255    fn render_lines(&self, arc_color: Color, dim_color: Color) -> Vec<Line<'static>> {
256        let char_rows = self.dot_rows.div_ceil(4);
257        let char_cols = self.dot_cols.div_ceil(2);
258
259        // Build the set of currently-lit arc dot positions for fast lookup.
260        let arc_set: HashSet<(isize, isize)> = (0..self.arc_len)
261            .map(|i| {
262                let idx = (self.tail + i) % self.perimeter.len();
263                let d = self.perimeter[idx];
264                (d.row, d.col)
265            })
266            .collect();
267
268        // Two braille byte-grids: lit arc and dim ring.
269        let mut bright: Vec<Vec<u8>> = vec![vec![0u8; char_cols]; char_rows];
270        let mut dim: Vec<Vec<u8>> = vec![vec![0u8; char_cols]; char_rows];
271
272        for dot in &self.perimeter {
273            let r = (dot.row + self.row_offset) as usize;
274            let c = (dot.col + self.col_offset) as usize;
275            if r >= self.dot_rows || c >= self.dot_cols {
276                continue;
277            }
278            let ci = r / 4;
279            let cj = c / 2;
280            if ci >= char_rows || cj >= char_cols {
281                continue;
282            }
283            let bit = BRAILLE_MAP[r % 4][c % 2];
284            if arc_set.contains(&(dot.row, dot.col)) {
285                bright[ci][cj] |= 1 << bit;
286            } else {
287                dim[ci][cj] |= 1 << bit;
288            }
289        }
290
291        // Compose: bright > dim > blank.
292        let mut lines = Vec::with_capacity(char_rows);
293        for ri in 0..char_rows {
294            let mut spans = Vec::with_capacity(char_cols);
295            for ci in 0..char_cols {
296                let b = bright[ri][ci];
297                let d = dim[ri][ci];
298                let (byte, color) = if b != 0 {
299                    (b, arc_color)
300                } else if d != 0 {
301                    (d, dim_color)
302                } else {
303                    (0u8, dim_color)
304                };
305                let ch = if byte == 0 {
306                    '\u{2800}'
307                } else {
308                    char::from_u32(BRAILLE_BASE + u32::from(byte)).unwrap_or('\u{2800}')
309                };
310                spans.push(Span::styled(ch.to_string(), Style::default().fg(color)));
311            }
312            lines.push(Line::from(spans));
313        }
314        lines
315    }
316}
317
318// ── Public widget ─────────────────────────────────────────────────────────────
319
320/// A spinner whose arc rotates clockwise around a circular braille-dot ring.
321///
322/// Uses a 1:1 dot pitch which, after braille packing (2 dot-cols per char-col,
323/// 4 dot-rows per char-row) and the ~2× terminal cell aspect ratio, produces a
324/// visually round circle.
325///
326/// There is no centre fill — only the rotating arc on the ring is drawn.
327///
328/// # Layout
329///
330/// The rendered size in terminal characters is approximately:
331///   - columns: `⌈(2r + 1) / 2⌉`
332///   - rows:    `⌈(2r + 1) / 4⌉`
333///
334/// Use [`CircleSpinner::char_size`] to query the exact dimensions.
335///
336/// # Examples
337///
338/// ```no_run
339/// use ratatui::style::Color;
340/// use ratatui::Frame;
341/// use ratatui::layout::Rect;
342/// use tui_spinner::CircleSpinner;
343///
344/// fn draw(frame: &mut Frame, area: Rect, tick: u64) {
345///     frame.render_widget(
346///         CircleSpinner::new(tick)
347///             .radius(6)
348///             .arc_color(Color::Cyan)
349///             .dim_color(Color::DarkGray),
350///         area,
351///     );
352/// }
353/// ```
354#[derive(Debug, Clone)]
355pub struct CircleSpinner<'a> {
356    tick: u64,
357    radius: usize,
358    /// Explicit arc length in perimeter dots (0 = auto ¼ of perimeter).
359    arc_len: usize,
360    ticks_per_step: u64,
361    spin: Spin,
362    /// Colour of the rotating bright arc.
363    arc_color: Color,
364    /// Colour of the dim remainder of the ring.
365    dim_color: Color,
366    block: Option<Block<'a>>,
367    style: Style,
368    alignment: Alignment,
369}
370
371impl<'a> CircleSpinner<'a> {
372    /// Creates a new [`CircleSpinner`] with defaults: radius 4, clockwise spin,
373    /// white arc, dark-gray dim ring, 1 tick per step, auto arc length.
374    ///
375    /// # Examples
376    ///
377    /// ```
378    /// use tui_spinner::CircleSpinner;
379    ///
380    /// let spinner = CircleSpinner::new(42);
381    /// ```
382    #[must_use]
383    pub fn new(tick: u64) -> Self {
384        Self {
385            tick,
386            radius: 4,
387            arc_len: 0,
388            ticks_per_step: 1,
389            spin: Spin::Clockwise,
390            arc_color: Color::White,
391            dim_color: Color::DarkGray,
392            block: None,
393            style: Style::default(),
394            alignment: Alignment::Left,
395        }
396    }
397
398    /// Sets the circle radius in braille dots (default: 4, minimum: 1).
399    ///
400    /// # Examples
401    ///
402    /// ```
403    /// use tui_spinner::CircleSpinner;
404    ///
405    /// let big = CircleSpinner::new(0).radius(8);
406    /// ```
407    #[must_use]
408    pub fn radius(mut self, r: usize) -> Self {
409        self.radius = r.max(1);
410        self
411    }
412
413    /// Sets the number of perimeter dots in the bright arc
414    /// (0 = auto ¼ of the total perimeter length).
415    #[must_use]
416    pub fn arc_len(mut self, len: usize) -> Self {
417        self.arc_len = len;
418        self
419    }
420
421    /// Sets the spin direction (default: [`Spin::Clockwise`]).
422    ///
423    /// # Examples
424    ///
425    /// ```
426    /// use tui_spinner::{CircleSpinner, Spin};
427    ///
428    /// let ccw = CircleSpinner::new(0).spin(Spin::CounterClockwise);
429    /// ```
430    #[must_use]
431    pub const fn spin(mut self, spin: Spin) -> Self {
432        self.spin = spin;
433        self
434    }
435
436    /// Sets how many ticks each arc step is held (default: 1, higher = slower).
437    #[must_use]
438    pub fn ticks_per_step(mut self, n: u64) -> Self {
439        self.ticks_per_step = n.max(1);
440        self
441    }
442
443    /// Sets the colour of the rotating bright arc (default: [`Color::White`]).
444    ///
445    /// # Examples
446    ///
447    /// ```
448    /// use ratatui::style::Color;
449    /// use tui_spinner::CircleSpinner;
450    ///
451    /// let spinner = CircleSpinner::new(0).arc_color(Color::Cyan);
452    /// ```
453    #[must_use]
454    pub const fn arc_color(mut self, color: Color) -> Self {
455        self.arc_color = color;
456        self
457    }
458
459    /// Sets the colour of the dim background ring (default: [`Color::DarkGray`]).
460    ///
461    /// # Examples
462    ///
463    /// ```
464    /// use ratatui::style::Color;
465    /// use tui_spinner::CircleSpinner;
466    ///
467    /// let spinner = CircleSpinner::new(0).dim_color(Color::DarkGray);
468    /// ```
469    #[must_use]
470    pub const fn dim_color(mut self, color: Color) -> Self {
471        self.dim_color = color;
472        self
473    }
474
475    /// Wraps the spinner in a [`Block`].
476    #[must_use]
477    pub fn block(mut self, block: Block<'a>) -> Self {
478        self.block = Some(block);
479        self
480    }
481
482    /// Sets the base style for the widget area.
483    #[must_use]
484    pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
485        self.style = style.into();
486        self
487    }
488
489    /// Sets the horizontal alignment of the rendered output (default: left).
490    #[must_use]
491    pub const fn alignment(mut self, alignment: Alignment) -> Self {
492        self.alignment = alignment;
493        self
494    }
495
496    /// Returns the exact rendered size in terminal characters `(cols, rows)`.
497    ///
498    /// # Examples
499    ///
500    /// ```
501    /// use tui_spinner::CircleSpinner;
502    ///
503    /// let (cols, rows) = CircleSpinner::new(0).radius(4).char_size();
504    /// // dot_cols = 2*4+1 = 9, char_cols = ceil(9/2) = 5
505    /// // dot_rows = 2*4+1 = 9, char_rows = ceil(9/4) = 3
506    /// assert_eq!(cols, 5);
507    /// assert_eq!(rows, 3);
508    /// ```
509    #[must_use]
510    #[allow(clippy::cast_possible_truncation)]
511    pub fn char_size(&self) -> (u16, u16) {
512        let dot_dim = self.radius * 2 + 1;
513        let char_cols = dot_dim.div_ceil(2) as u16;
514        let char_rows = dot_dim.div_ceil(4) as u16;
515        (char_cols, char_rows)
516    }
517
518    fn build_lines(&self) -> Vec<Line<'static>> {
519        let mut engine = CircleEngine::build(self.radius, self.arc_len, self.spin);
520
521        #[allow(clippy::cast_possible_truncation)]
522        let steps = (self.tick / self.ticks_per_step) as usize;
523        for _ in 0..steps {
524            engine.walk(self.spin);
525        }
526
527        engine.render_lines(self.arc_color, self.dim_color)
528    }
529}
530
531impl_styled_for!(CircleSpinner<'_>);
532
533impl_widget_via_ref!(CircleSpinner<'_>);
534
535impl Widget for &CircleSpinner<'_> {
536    fn render(self, area: Rect, buf: &mut Buffer) {
537        render_spinner_body!(self, area, buf, self.build_lines());
538    }
539}
540
541// ── Tests ─────────────────────────────────────────────────────────────────────
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use crate::rect_spinner::Spin;
547    use ratatui::buffer::Buffer;
548    use ratatui::layout::Rect;
549    use ratatui::widgets::Widget;
550
551    // ── Perimeter geometry ────────────────────────────────────────────────────
552
553    #[test]
554    fn circle_perimeter_has_points() {
555        for r in 1..=8usize {
556            let p = circle_perimeter(r);
557            assert!(!p.is_empty(), "radius {r} should produce perimeter points");
558        }
559    }
560
561    #[test]
562    fn circle_perimeter_zero_returns_single_dot() {
563        let p = circle_perimeter(0);
564        assert_eq!(p.len(), 1);
565        assert_eq!(p[0], Dot::new(0, 0));
566    }
567
568    #[test]
569    fn circle_perimeter_larger_radius_more_points() {
570        assert!(circle_perimeter(6).len() > circle_perimeter(2).len());
571    }
572
573    #[test]
574    fn circle_perimeter_is_square_bounding_box() {
575        // With 1:1 pitch the bounding box should be (2r+1) × (2r+1).
576        for r in 1..=6usize {
577            let p = circle_perimeter(r);
578            let min_row = p.iter().map(|d| d.row).min().unwrap();
579            let max_row = p.iter().map(|d| d.row).max().unwrap();
580            let min_col = p.iter().map(|d| d.col).min().unwrap();
581            let max_col = p.iter().map(|d| d.col).max().unwrap();
582            let height = (max_row - min_row + 1) as usize;
583            let width = (max_col - min_col + 1) as usize;
584            // Should be approximately square (within 1 dot either way).
585            let diff = height.abs_diff(width);
586            assert!(
587                diff <= 2,
588                "r={r}: bounding box {width}×{height} is not square"
589            );
590        }
591    }
592
593    #[test]
594    fn circle_perimeter_sorted_clockwise() {
595        let p = circle_perimeter(4);
596        let cr = p.iter().map(|d| d.row as f64).sum::<f64>() / p.len() as f64;
597        let cc = p.iter().map(|d| d.col as f64).sum::<f64>() / p.len() as f64;
598        let angles: Vec<f64> = p
599            .iter()
600            .map(|d| {
601                let dr = -(d.row as f64 - cr);
602                let dc = d.col as f64 - cc;
603                let raw = dc.atan2(dr);
604                if raw < 0.0 {
605                    raw + 2.0 * std::f64::consts::PI
606                } else {
607                    raw
608                }
609            })
610            .collect();
611        for w in angles.windows(2) {
612            assert!(
613                w[1] >= w[0] - 1e-9,
614                "angles not monotone: {} > {}",
615                w[0],
616                w[1]
617            );
618        }
619    }
620
621    #[test]
622    fn sort_clockwise_four_cardinals() {
623        let pts = vec![(-1isize, 0isize), (0, 1), (1, 0), (0, -1)];
624        let sorted = sort_clockwise(pts);
625        assert_eq!(sorted[0], Dot::new(-1, 0), "first should be top");
626        assert_eq!(sorted[1], Dot::new(0, 1), "second should be right");
627        assert_eq!(sorted[2], Dot::new(1, 0), "third should be bottom");
628        assert_eq!(sorted[3], Dot::new(0, -1), "fourth should be left");
629    }
630
631    // ── Engine ────────────────────────────────────────────────────────────────
632
633    #[test]
634    fn engine_builds_for_various_radii() {
635        for r in [1, 2, 3, 4, 5, 8] {
636            for spin in [Spin::Clockwise, Spin::CounterClockwise] {
637                let _ = CircleEngine::build(r, 0, spin);
638            }
639        }
640    }
641
642    #[test]
643    fn engine_walk_does_not_panic() {
644        for spin in [Spin::Clockwise, Spin::CounterClockwise] {
645            let mut e = CircleEngine::build(4, 0, spin);
646            for _ in 0..e.perimeter.len() * 2 {
647                e.walk(spin);
648            }
649        }
650    }
651
652    #[test]
653    fn engine_advances_after_walk() {
654        let e0 = CircleEngine::build(4, 0, Spin::Clockwise);
655        let mut e1 = CircleEngine::build(4, 0, Spin::Clockwise);
656        e1.walk(Spin::Clockwise);
657        let l0 = e0.render_lines(Color::Cyan, Color::DarkGray);
658        let l1 = e1.render_lines(Color::Cyan, Color::DarkGray);
659        assert_ne!(l0, l1, "frame should change after one walk step");
660    }
661
662    #[test]
663    fn engine_wraps_after_full_revolution() {
664        for spin in [Spin::Clockwise, Spin::CounterClockwise] {
665            let mut e = CircleEngine::build(4, 0, spin);
666            let n = e.perimeter.len();
667            let l0 = e.render_lines(Color::Cyan, Color::DarkGray);
668            for _ in 0..n {
669                e.walk(spin);
670            }
671            let ln = e.render_lines(Color::Cyan, Color::DarkGray);
672            assert_eq!(
673                l0, ln,
674                "should return to identical frame after full revolution ({spin:?})"
675            );
676        }
677    }
678
679    // ── Widget ────────────────────────────────────────────────────────────────
680
681    #[test]
682    fn widget_renders_without_panic() {
683        for r in [1, 2, 4, 6, 8, 12] {
684            for spin in [Spin::Clockwise, Spin::CounterClockwise] {
685                let spinner = CircleSpinner::new(10).radius(r).spin(spin);
686                let area = Rect::new(0, 0, 40, 20);
687                let mut buf = Buffer::empty(area);
688                Widget::render(&spinner, area, &mut buf);
689            }
690        }
691    }
692
693    #[test]
694    fn widget_zero_area_no_panic() {
695        let spinner = CircleSpinner::new(0);
696        let area = Rect::new(0, 0, 0, 0);
697        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
698        Widget::render(&spinner, area, &mut buf);
699    }
700
701    #[test]
702    fn different_ticks_produce_different_output() {
703        let area = Rect::new(0, 0, 10, 5);
704        let mut b0 = Buffer::empty(area);
705        let mut b5 = Buffer::empty(area);
706        Widget::render(
707            &CircleSpinner::new(0).radius(4).spin(Spin::Clockwise),
708            area,
709            &mut b0,
710        );
711        Widget::render(
712            &CircleSpinner::new(5).radius(4).spin(Spin::Clockwise),
713            area,
714            &mut b5,
715        );
716        assert_ne!(b0, b5, "tick 0 and tick 5 should render differently");
717    }
718
719    #[test]
720    fn ticks_per_step_slows_animation() {
721        // With ticks_per_step=3, tick=1 stays at step 0 — same frame as tick=0.
722        let area = Rect::new(0, 0, 10, 5);
723        let mut b0 = Buffer::empty(area);
724        let mut b1 = Buffer::empty(area);
725        Widget::render(
726            &CircleSpinner::new(0).radius(4).ticks_per_step(3),
727            area,
728            &mut b0,
729        );
730        Widget::render(
731            &CircleSpinner::new(1).radius(4).ticks_per_step(3),
732            area,
733            &mut b1,
734        );
735        assert_eq!(b0, b1, "slow spinner at tick 1 should equal tick 0");
736    }
737
738    #[test]
739    fn cw_and_ccw_differ() {
740        let area = Rect::new(0, 0, 10, 5);
741        let mut bcw = Buffer::empty(area);
742        let mut bccw = Buffer::empty(area);
743        Widget::render(
744            &CircleSpinner::new(5).radius(4).spin(Spin::Clockwise),
745            area,
746            &mut bcw,
747        );
748        Widget::render(
749            &CircleSpinner::new(5).radius(4).spin(Spin::CounterClockwise),
750            area,
751            &mut bccw,
752        );
753        assert_ne!(bcw, bccw, "CW and CCW should produce different frames");
754    }
755
756    #[test]
757    fn char_size_is_correct() {
758        // radius 4: dot_dim = 2*4+1 = 9
759        // char_cols = ceil(9/2) = 5, char_rows = ceil(9/4) = 3
760        let (cols, rows) = CircleSpinner::new(0).radius(4).char_size();
761        assert_eq!(cols, 5, "char cols for radius 4");
762        assert_eq!(rows, 3, "char rows for radius 4");
763    }
764
765    #[test]
766    fn char_size_radius_8() {
767        // radius 8: dot_dim = 2*8+1 = 17
768        // char_cols = ceil(17/2) = 9, char_rows = ceil(17/4) = 5
769        let (cols, rows) = CircleSpinner::new(0).radius(8).char_size();
770        assert_eq!(cols, 9, "char cols for radius 8");
771        assert_eq!(rows, 5, "char rows for radius 8");
772    }
773
774    #[test]
775    fn builder_chain_fields() {
776        let s = CircleSpinner::new(10)
777            .radius(6)
778            .arc_len(5)
779            .ticks_per_step(3)
780            .spin(Spin::CounterClockwise)
781            .arc_color(Color::Cyan)
782            .dim_color(Color::DarkGray);
783        assert_eq!(s.radius, 6);
784        assert_eq!(s.arc_len, 5);
785        assert_eq!(s.ticks_per_step, 3);
786        assert_eq!(s.spin, Spin::CounterClockwise);
787        assert_eq!(s.arc_color, Color::Cyan);
788        assert_eq!(s.dim_color, Color::DarkGray);
789    }
790
791    #[test]
792    fn ring_has_visible_content() {
793        let spinner = CircleSpinner::new(0).radius(4).arc_color(Color::Cyan);
794        let area = Rect::new(0, 0, 30, 10);
795        let mut buf = Buffer::empty(area);
796        Widget::render(&spinner, area, &mut buf);
797        let has_content = buf
798            .content()
799            .iter()
800            .any(|c| c.symbol() != " " && c.symbol() != "\u{2800}");
801        assert!(
802            has_content,
803            "spinner should render some visible braille dots"
804        );
805    }
806}