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/// Horizontal spacing between displayed snake segments.
7const SEGMENT_SPACING: u16 = 4;
8/// Total number of snake segments (emojis) to display.
9const TOTAL_SEGMENTS: usize = 5;
10
11/// Manages the welcome-screen snake moving across the top row.
12pub struct EdgeSnake {
13    /// Current horizontal position of the head.
14    pub x: u16,
15    /// Kept at the top row; exposed for compatibility with the menu renderer.
16    pub y: u16,
17    /// Last update timestamp for frame rate control.
18    last_update: Instant,
19    /// Target duration between animation frames (speed).
20    frame_duration: Duration,
21}
22
23impl Default for EdgeSnake {
24    fn default() -> Self {
25        Self::new()
26    }
27}
28
29impl EdgeSnake {
30    /// Creates a new `EdgeSnake` instance.
31    #[must_use]
32    pub fn new() -> Self {
33        Self {
34            x: 0,
35            y: 0,
36            last_update: Instant::now(),
37            frame_duration: Duration::from_millis(SPEED_MOVING_SNAKE_SLEEP_TIME_MS),
38        }
39    }
40
41    /// Moves the snake one cell to the right on the top row, wrapping at the opposite edge.
42    pub fn update(&mut self, area: &Rect) {
43        if self.last_update.elapsed() < self.frame_duration {
44            return;
45        }
46        self.last_update = Instant::now();
47
48        self.x = Self::next_x(self.x, area.width);
49        self.y = 0;
50    }
51
52    pub fn render(&self, frame: &mut ratatui::Frame, area: &Rect) {
53        for (x, y) in self.get_positions(area.width) {
54            frame.render_widget(Paragraph::new("🐍"), Rect::new(x, y, 2, 1));
55        }
56    }
57
58    /// Returns the next head position, wrapping from the right edge to the left edge.
59    fn next_x(x: u16, width: u16) -> u16 {
60        let max_x = width.saturating_sub(2);
61        if width <= 2 || x >= max_x { 0 } else { x + 1 }
62    }
63
64    /// Returns the top-row coordinates for the head and its body segments.
65    /// Body segments wrap horizontally as well, so the animation remains continuous at the edge.
66    #[must_use]
67    pub fn get_positions(&self, width: u16) -> Vec<(u16, u16)> {
68        if width <= 2 {
69            return Vec::new();
70        }
71
72        let available_positions = width - 1;
73        let head_x = self.x.min(width - 2);
74        (0..TOTAL_SEGMENTS)
75            .map(|segment| {
76                let offset = (u16::try_from(segment).expect("segment count exceeds u16")
77                    * SEGMENT_SPACING)
78                    % available_positions;
79                (
80                    (head_x + available_positions - offset) % available_positions,
81                    0,
82                )
83            })
84            .collect()
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::EdgeSnake;
91
92    #[test]
93    fn head_wraps_from_the_right_edge_to_the_left_edge() {
94        assert_eq!(EdgeSnake::next_x(7, 9), 0);
95        assert_eq!(EdgeSnake::next_x(6, 9), 7);
96    }
97
98    #[test]
99    fn body_stays_on_the_top_row_and_wraps_horizontally() {
100        let mut snake = EdgeSnake::new();
101        snake.x = 1;
102
103        assert_eq!(
104            snake.get_positions(10),
105            vec![(1, 0), (6, 0), (2, 0), (7, 0), (3, 0)]
106        );
107    }
108}