1use 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
39const BRAILLE_BASE: u32 = 0x2800;
42
43const BRAILLE_MAP: [[u8; 2]; 4] = [
45 [0, 3], [1, 4], [2, 5], [6, 7], ];
50
51#[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#[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 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)); }
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#[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); 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#[derive(Debug, Clone)]
146struct CircleEngine {
147 cells: Vec<Vec<bool>>,
149 dot_rows: usize,
150 dot_cols: usize,
151 row_offset: isize,
153 col_offset: isize,
154 perimeter: Vec<Dot>,
156 head: usize,
158 tail: usize,
160 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 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 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 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 #[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 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 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 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#[derive(Debug, Clone)]
355pub struct CircleSpinner<'a> {
356 tick: u64,
357 radius: usize,
358 arc_len: usize,
360 ticks_per_step: u64,
361 spin: Spin,
362 arc_color: Color,
364 dim_color: Color,
366 block: Option<Block<'a>>,
367 style: Style,
368 alignment: Alignment,
369}
370
371impl<'a> CircleSpinner<'a> {
372 #[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 #[must_use]
408 pub fn radius(mut self, r: usize) -> Self {
409 self.radius = r.max(1);
410 self
411 }
412
413 #[must_use]
416 pub fn arc_len(mut self, len: usize) -> Self {
417 self.arc_len = len;
418 self
419 }
420
421 #[must_use]
431 pub const fn spin(mut self, spin: Spin) -> Self {
432 self.spin = spin;
433 self
434 }
435
436 #[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 #[must_use]
454 pub const fn arc_color(mut self, color: Color) -> Self {
455 self.arc_color = color;
456 self
457 }
458
459 #[must_use]
470 pub const fn dim_color(mut self, color: Color) -> Self {
471 self.dim_color = color;
472 self
473 }
474
475 #[must_use]
477 pub fn block(mut self, block: Block<'a>) -> Self {
478 self.block = Some(block);
479 self
480 }
481
482 #[must_use]
484 pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
485 self.style = style.into();
486 self
487 }
488
489 #[must_use]
491 pub const fn alignment(mut self, alignment: Alignment) -> Self {
492 self.alignment = alignment;
493 self
494 }
495
496 #[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#[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 #[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 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 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 #[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 #[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 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 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 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}