1use 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
38pub use crate::rect_spinner::Centre;
40
41const BRAILLE_BASE: u32 = 0x2800;
44
45const BRAILLE_MAP: [[u8; 2]; 4] = [
47 [0, 3], [1, 4], [2, 5], [6, 7], ];
52
53#[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
101fn 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
115fn 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
132fn 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
180fn 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
240fn 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
249struct 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 ¢re_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#[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 arc_color: Color,
407 dim_color: Color,
409 block: Option<Block<'a>>,
410 style: Style,
411 alignment: Alignment,
412}
413
414impl<'a> SquareSpinner<'a> {
415 #[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 #[must_use]
453 pub fn size(mut self, size: usize) -> Self {
454 self.size = size.clamp(2, 8);
455 self
456 }
457
458 #[must_use]
468 pub const fn spin(mut self, spin: Spin) -> Self {
469 self.spin = spin;
470 self
471 }
472
473 #[must_use]
483 pub const fn centre(mut self, centre: Centre) -> Self {
484 self.centre = centre;
485 self
486 }
487
488 #[must_use]
499 pub const fn arc_color(mut self, color: Color) -> Self {
500 self.arc_color = color;
501 self
502 }
503
504 #[must_use]
517 pub const fn dim_color(mut self, color: Color) -> Self {
518 self.dim_color = color;
519 self
520 }
521
522 #[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 #[must_use]
548 pub fn block(mut self, block: Block<'a>) -> Self {
549 self.block = Some(block);
550 self
551 }
552
553 #[must_use]
555 pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
556 self.style = style.into();
557 self
558 }
559
560 #[must_use]
562 pub const fn alignment(mut self, alignment: Alignment) -> Self {
563 self.alignment = alignment;
564 self
565 }
566
567 #[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}