Skip to main content

rsnaker/graphics/menus/
edge_snake.rs

1use ratatui::layout::Rect;
2use ratatui::widgets::Paragraph;
3use std::time::{Duration, Instant};
4
5pub const SPEED_MOVING_SNAKE_SLEEP_TIME_MS: u64 = 50;
6/// Spacing between snake segments.
7/// Horizontal spacing is double this value to account for a TUI cell aspect ratio.
8const SEGMENT_GAP: i32 = 1;
9/// Total number of snake segments (emojis) to display.
10const TOTAL_SEGMENTS: usize = 5;
11
12/// Manages a snake animation that moves along the terminal boundaries.
13/// The snake follows the edges of the given area in a clockwise direction.
14pub struct EdgeSnake {
15    /// Current horizontal position of the head.
16    pub x: u16,
17    /// Current vertical position of the head.
18    pub y: u16,
19    /// Last update timestamp for frame rate control.
20    last_update: Instant,
21    /// Target duration between animation frames (speed).
22    frame_duration: Duration,
23}
24
25impl Default for EdgeSnake {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl EdgeSnake {
32    /// Creates a new `EdgeSnake` instance.
33    #[must_use]
34    pub fn new() -> Self {
35        Self {
36            x: 0,
37            y: 0,
38            last_update: Instant::now(),
39            frame_duration: Duration::from_millis(SPEED_MOVING_SNAKE_SLEEP_TIME_MS),
40        }
41    }
42
43    /// Updates the snake's position along the edges.
44    ///
45    /// The movement is constrained by a fixed frame rate.
46    pub fn update(&mut self, area: &Rect) {
47        if self.last_update.elapsed() < self.frame_duration {
48            return;
49        }
50        self.last_update = Instant::now();
51
52        let (max_x, max_y) = Self::get_limits(area.width, area.height);
53        if max_x == 0 && max_y == 0 {
54            return;
55        }
56        (self.x, self.y) = Self::step_along_edge(self.x, self.y, max_x, max_y, 1);
57    }
58    pub fn render(&self, frame: &mut ratatui::Frame, area: &Rect) {
59        for (x, y) in self.get_positions(area.width, area.height) {
60            frame.render_widget(Paragraph::new("🐍"), Rect::new(x, y, 2, 1));
61        }
62    }
63
64    /// Computes terminal-specific boundaries for the snake.
65    ///
66    /// Returns (`max_x`, `max_y`).
67    fn get_limits(width: u16, height: u16) -> (u16, u16) {
68        // Emojis are 2 cells wide, so we stop at width - 2.
69        let max_x = width.saturating_sub(2);
70        let max_y = height.saturating_sub(1);
71
72        if width <= 2 || height <= 1 {
73            return (0, 0);
74        }
75
76        (max_x, max_y)
77    }
78    /// Having a little fun with mathematics, as simple if condition can do the tricks also
79    /// Unified helper that steps a given number of cells along the perimeter.
80    /// A `delta` of `1` moves clockwise; `-1` moves counter-clockwise.
81    #[must_use]
82    pub fn step_along_edge(x: u16, y: u16, max_x: u16, max_y: u16, delta: i32) -> (u16, u16) {
83        // sanity checks
84        // Ensure the current position is within limits (handles resize)
85        let x = x.min(max_x);
86        let mut y = y.min(max_y);
87
88        // If not on an edge (e.g., after resize), snap to the nearest edge;
89        // To keep it simple, we snap to the top edge if it's internal.
90        if x > 0 && x < max_x && y > 0 && y < max_y {
91            y = 0;
92        }
93        //rectangle perimeter from maths
94        let perimeter = 2 * (max_x + max_y);
95        if perimeter == 0 {
96            return (x, y);
97        }
98
99        // 1. Map the 2D coordinate to a 1D index (clockwise starting at 0,0)
100        let index = if y == 0 {
101            x
102        } else if x == max_x {
103            max_x + y
104        } else if y == max_y {
105            max_x + max_y + (max_x - x)
106        } else if x == 0 {
107            2 * max_x + max_y + (max_y - y)
108        } else {
109            return (x, y); // Safe fallback if the position is inside the grid
110        };
111
112        // 2. Step forward or backward along the 1D ring using Euclidean modulo to handle negatives safely
113        let new_index = u16::try_from((i32::from(index) + delta).rem_euclid(i32::from(perimeter)))
114            .expect("Maths error");
115
116        // 3. Map the new 1D index back into 2D coordinates
117        if new_index <= max_x {
118            (new_index, 0)
119        } else if new_index <= max_x + max_y {
120            (max_x, new_index - max_x)
121        } else if new_index <= 2 * max_x + max_y {
122            (max_x - (new_index - max_x - max_y), max_y)
123        } else {
124            (0, max_y - (new_index - 2 * max_x - max_y))
125        }
126    }
127
128    /// Checks if the segment at (x, y) is on a vertical edge.
129    fn is_vertical(x: u16, y: u16, max_x: u16, max_y: u16) -> bool {
130        // Right edge (excluding the top-right corner) or Left edge (excluding the bottom-left corner)
131        (x == max_x && y > 0) || (x == 0 && y > 0 && y < max_y)
132    }
133
134    /// Returns the coordinates for all snake segments, starting from the head and going `TOTAL_SEGMENTS` time back
135    /// Calculate each position as if size was 1, and compute an offset to do that the good amount of time (different between width and height)
136    #[must_use]
137    pub fn get_positions(&self, width: u16, height: u16) -> Vec<(u16, u16)> {
138        let (max_x, max_y) = Self::get_limits(width, height);
139        if max_x == 0 && max_y == 0 {
140            return vec![(0, 0); TOTAL_SEGMENTS];
141        }
142
143        let mut positions = Vec::with_capacity(TOTAL_SEGMENTS);
144        let (mut curr_x, mut curr_y) = (self.x, self.y);
145
146        // Snap to limits for the current area
147        curr_x = curr_x.min(max_x);
148        curr_y = curr_y.min(max_y);
149
150        for i in 0..TOTAL_SEGMENTS {
151            positions.push((curr_x, curr_y));
152
153            if i < TOTAL_SEGMENTS - 1 {
154                // Determine how much to step back for the next segment.
155                let offset: i32 = if Self::is_vertical(curr_x, curr_y, max_x, max_y) {
156                    1 + SEGMENT_GAP
157                } else {
158                    2 + (SEGMENT_GAP * 2)
159                };
160                // Get backward offset nth position
161                (curr_x, curr_y) = Self::step_along_edge(curr_x, curr_y, max_x, max_y, -offset);
162            }
163        }
164
165        positions
166    }
167}