1use 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
13const BRAILLE_MAP: [[u8; 2]; 4] = [
15 [0, 3], [1, 4], [2, 5], [6, 7], ];
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum Spin {
26 #[default]
28 Clockwise,
29 CounterClockwise,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum Centre {
36 #[default]
38 Filled,
39 Empty,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum RectShape {
46 Square(usize),
48}
49
50impl Default for RectShape {
51 fn default() -> Self {
52 Self::Square(2)
53 }
54}
55
56#[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 #[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 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
107fn calc_dimension(size: usize) -> usize {
111 8 + 5 * size.saturating_sub(2)
112}
113
114fn vertical_offset(size: usize) -> isize {
116 if size == 2 {
117 2
118 } else {
119 0
120 }
121}
122
123fn 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
143fn 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
169fn 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
193fn 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
234fn 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
255fn 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
265struct 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 #[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 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 let (centre_cells, c_start, c_end) = make_centre(sz, dm_i);
296
297 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 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 for i in 0..size {
321 grid.fill(tail[i], head[i]);
322 }
323
324 let has_centre = matches!(centre, Centre::Filled);
326 if has_centre {
327 for c in ¢re_cells {
328 grid.set(c.row, c.col, true);
329 }
330 }
331
332 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 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 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 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 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 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#[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 #[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 #[must_use]
449 pub const fn shape(mut self, shape: RectShape) -> Self {
450 self.shape = shape;
451 self
452 }
453
454 #[must_use]
456 pub const fn spin(mut self, spin: Spin) -> Self {
457 self.spin = spin;
458 self
459 }
460
461 #[must_use]
463 pub const fn outer_color(mut self, color: Color) -> Self {
464 self.outer_color = color;
465 self
466 }
467
468 #[must_use]
470 pub const fn inner_color(mut self, color: Color) -> Self {
471 self.inner_color = color;
472 self
473 }
474
475 #[must_use]
477 pub const fn centre(mut self, centre: Centre) -> Self {
478 self.centre = centre;
479 self
480 }
481
482 #[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 #[must_use]
491 pub fn block(mut self, block: Block<'a>) -> Self {
492 self.block = Some(block);
493 self
494 }
495
496 #[must_use]
498 pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
499 self.style = style.into();
500 self
501 }
502
503 #[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}