ratatui_widgets/
mascot.rs

1//! A Ratatui mascot widget
2//!
3//! The mascot takes 32x16 cells and is rendered using half block characters.
4use itertools::Itertools;
5use ratatui_core::buffer::Buffer;
6use ratatui_core::layout::Rect;
7use ratatui_core::style::Color;
8use ratatui_core::widgets::Widget; // tuples();
9
10const RATATUI_MASCOT: &str = indoc::indoc! {"
11                   hhh
12                 hhhhhh
13                hhhhhhh
14               hhhhhhhh
15              hhhhhhhhh
16             hhhhhhhhhh
17            hhhhhhhhhhhh
18            hhhhhhhhhhhhh
19            hhhhhhhhhhhhh     ██████
20             hhhhhhhhhhh    ████████
21                  hhhhh ███████████
22                   hhh ██ee████████
23                    h █████████████
24                ████ █████████████
25               █████████████████
26               ████████████████
27               ████████████████
28                ███ ██████████
29              ▒▒    █████████
30             ▒░░▒   █████████
31            ▒░░░░▒ ██████████
32           ▒░░▓░░░▒ █████████
33          ▒░░▓▓░░░░▒ ████████
34         ▒░░░░░░░░░░▒ ██████████
35        ▒░░░░░░░░░░░░▒ ██████████
36       ▒░░░░░░░▓▓░░░░░▒ █████████
37      ▒░░░░░░░░░▓▓░░░░░▒ ████  ███
38     ▒░░░░░░░░░░░░░░░░░░▒ ██   ███
39    ▒░░░░░░░░░░░░░░░░░░░░▒ █   ███
40    ▒░░░░░░░░░░░░░░░░░░░░░▒   ███
41     ▒░░░░░░░░░░░░░░░░░░░░░▒ ███
42      ▒░░░░░░░░░░░░░░░░░░░░░▒ █"
43};
44
45const EMPTY: char = ' ';
46const RAT: char = '█';
47const HAT: char = 'h';
48const EYE: char = 'e';
49const TERM: char = '░';
50const TERM_BORDER: char = '▒';
51const TERM_CURSOR: char = '▓';
52
53/// State for the mascot's eye
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum MascotEyeColor {
56    /// The default eye color
57    #[default]
58    Default,
59
60    /// The red eye color
61    Red,
62}
63
64/// A widget that renders the Ratatui mascot
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct RatatuiMascot {
67    eye_state: MascotEyeColor,
68    /// The color of the rat
69    rat_color: Color,
70    /// The color of the rat's eye
71    rat_eye_color: Color,
72    /// The color of the rat's eye when blinking
73    rat_eye_blink: Color,
74    /// The color of the rat's hat
75    hat_color: Color,
76    /// The color of the terminal
77    term_color: Color,
78    /// The color of the terminal border
79    term_border_color: Color,
80    /// The color of the terminal cursor
81    term_cursor_color: Color,
82}
83
84impl Default for RatatuiMascot {
85    fn default() -> Self {
86        Self {
87            rat_color: Color::Indexed(252),         // light_gray #d0d0d0
88            hat_color: Color::Indexed(231),         // white #ffffff
89            rat_eye_color: Color::Indexed(236),     // dark_charcoal #303030
90            rat_eye_blink: Color::Indexed(196),     // red #ff0000
91            term_color: Color::Indexed(232),        // vampire_black #080808
92            term_border_color: Color::Indexed(237), // gray  #808080
93            term_cursor_color: Color::Indexed(248), // dark_gray #a8a8a8
94            eye_state: MascotEyeColor::Default,
95        }
96    }
97}
98
99impl RatatuiMascot {
100    /// Create a new Ratatui mascot widget
101    pub fn new() -> Self {
102        Self {
103            ..Default::default()
104        }
105    }
106
107    /// Set the eye state (open / blinking)
108    #[must_use]
109    pub const fn set_eye(self, rat_eye: MascotEyeColor) -> Self {
110        Self {
111            eye_state: rat_eye,
112            ..self
113        }
114    }
115
116    const fn color_for(&self, c: char) -> Option<Color> {
117        match c {
118            RAT => Some(self.rat_color),
119            HAT => Some(self.hat_color),
120            EYE => Some(match self.eye_state {
121                MascotEyeColor::Default => self.rat_eye_color,
122                MascotEyeColor::Red => self.rat_eye_blink,
123            }),
124            TERM => Some(self.term_color),
125            TERM_CURSOR => Some(self.term_cursor_color),
126            TERM_BORDER => Some(self.term_border_color),
127            _ => None,
128        }
129    }
130}
131
132impl Widget for RatatuiMascot {
133    /// Use half block characters to render a logo based on the `RATATUI_LOGO` const.
134    ///
135    /// The logo colors are hardcorded in the widget.
136    /// The eye color depends on whether it's open / blinking
137    fn render(self, area: Rect, buf: &mut Buffer) {
138        let area = area.intersection(buf.area);
139        if area.is_empty() {
140            return;
141        }
142
143        for (y, (line1, line2)) in RATATUI_MASCOT.lines().tuples().enumerate() {
144            for (x, (ch1, ch2)) in line1.chars().zip(line2.chars()).enumerate() {
145                let x = area.left() + x as u16;
146                let y = area.top() + y as u16;
147
148                // Check if coordinates are within the buffer area
149                if x >= area.right() || y >= area.bottom() {
150                    continue;
151                }
152
153                let cell = &mut buf[(x, y)];
154                // given two cells which make up the top and bottom of the character,
155                // Foreground color should be the non-space, non-terminal
156                let (fg, bg) = match (ch1, ch2) {
157                    (EMPTY, EMPTY) => (None, None),
158                    (c, EMPTY) | (EMPTY, c) => (self.color_for(c), None),
159                    (TERM, TERM_BORDER) => (self.color_for(TERM_BORDER), self.color_for(TERM)),
160                    (TERM, c) | (c, TERM) => (self.color_for(c), self.color_for(TERM)),
161                    (c1, c2) => (self.color_for(c1), self.color_for(c2)),
162                };
163                // symbol should make the empty space or terminal bg as the empty part of the block
164                let symbol = match (ch1, ch2) {
165                    (EMPTY, EMPTY) => None,
166                    (TERM, TERM) => Some(EMPTY),
167                    (_, EMPTY | TERM) => Some('▀'),
168                    (EMPTY | TERM, _) => Some('▄'),
169                    (c, d) if c == d => Some('█'),
170                    (_, _) => Some('▀'),
171                };
172                if let Some(fg) = fg {
173                    cell.fg = fg;
174                }
175                if let Some(bg) = bg {
176                    cell.bg = bg;
177                }
178                if let Some(symb) = symbol {
179                    cell.set_char(symb);
180                }
181            }
182        }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use alloc::string::String;
189
190    use super::*;
191
192    #[test]
193    fn new_mascot() {
194        let mascot = RatatuiMascot::new();
195        assert_eq!(mascot.eye_state, MascotEyeColor::Default);
196    }
197
198    #[test]
199    fn set_eye_color() {
200        let mut buf = Buffer::empty(Rect::new(0, 0, 32, 16));
201        let mascot = RatatuiMascot::new().set_eye(MascotEyeColor::Red);
202        mascot.render(buf.area, &mut buf);
203        assert_eq!(mascot.eye_state, MascotEyeColor::Red);
204        assert_eq!(buf[(21, 5)].bg, Color::Indexed(196));
205    }
206
207    #[test]
208    fn render_mascot() {
209        let mascot = RatatuiMascot::new();
210        let mut buf = Buffer::empty(Rect::new(0, 0, 32, 16));
211        mascot.render(buf.area, &mut buf);
212        assert_eq!(buf.area.as_size(), (32, 16).into());
213        assert_eq!(buf[(21, 5)].bg, Color::Indexed(236));
214        assert_eq!(
215            buf.content
216                .iter()
217                .map(ratatui_core::buffer::Cell::symbol)
218                .collect::<String>(),
219            Buffer::with_lines([
220                "             ▄▄███              ",
221                "           ▄███████             ",
222                "         ▄█████████             ",
223                "        ████████████            ",
224                "        ▀███████████▀   ▄▄██████",
225                "              ▀███▀▄█▀▀████████ ",
226                "            ▄▄▄▄▀▄████████████  ",
227                "           ████████████████     ",
228                "           ▀███▀██████████      ",
229                "         ▄▀▀▄   █████████       ",
230                "       ▄▀ ▄  ▀▄▀█████████       ",
231                "     ▄▀  ▀▀    ▀▄▀███████       ",
232                "   ▄▀      ▄▄    ▀▄▀█████████   ",
233                " ▄▀         ▀▀     ▀▄▀██▀  ███  ",
234                "█                    ▀▄▀  ▄██   ",
235                " ▀▄                    ▀▄▀█     ",
236            ])
237            .content
238            .iter()
239            .map(ratatui_core::buffer::Cell::symbol)
240            .collect::<String>()
241        );
242    }
243
244    #[test]
245    fn render_in_minimal_buffer() {
246        let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1));
247        let mascot = RatatuiMascot::new();
248        // This should not panic, even if the buffer is too small to render the mascot.
249        mascot.render(buffer.area, &mut buffer);
250        assert_eq!(buffer, Buffer::with_lines([" "]));
251    }
252
253    #[test]
254    fn render_in_zero_size_buffer() {
255        let mut buffer = Buffer::empty(Rect::ZERO);
256        let mascot = RatatuiMascot::new();
257        // This should not panic, even if the buffer has zero size.
258        mascot.render(buffer.area, &mut buffer);
259    }
260}