Skip to main content

tui_splitflap/
widget.rs

1//! Stateless `FlapBoard` widget and cell rendering.
2
3use ratatui::buffer::Buffer;
4use ratatui::layout::Rect;
5use ratatui::style::Style;
6use ratatui::widgets::{Block, StatefulWidget, Widget};
7
8use crate::cell::FlipPhase;
9use crate::state::FlapBoardState;
10use crate::theme::FlapTheme;
11
12#[cfg(feature = "pantry")]
13#[path = "widget.ingredient.rs"]
14pub mod ingredient;
15
16const CELL_HEIGHT: u16 = 3;
17
18/// Terminal columns per tile.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum CellWidth {
21    #[default]
22    Normal,
23    Compact,
24}
25
26impl CellWidth {
27    pub fn columns(self) -> u16 {
28        match self {
29            CellWidth::Normal => 3,
30            CellWidth::Compact => 1,
31        }
32    }
33}
34
35/// Stateless widget that renders a split-flap board from `FlapBoardState`.
36#[derive(Debug, Clone, Default)]
37pub struct FlapBoard<'a> {
38    cell_width: CellWidth,
39    theme: FlapTheme,
40    block: Option<Block<'a>>,
41}
42
43impl<'a> FlapBoard<'a> {
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    pub fn cell_width(mut self, width: CellWidth) -> Self {
49        self.cell_width = width;
50        self
51    }
52
53    pub fn theme(mut self, theme: FlapTheme) -> Self {
54        self.theme = theme;
55        self
56    }
57
58    pub fn block(mut self, block: Block<'a>) -> Self {
59        self.block = Some(block);
60        self
61    }
62}
63
64impl StatefulWidget for FlapBoard<'_> {
65    type State = FlapBoardState;
66
67    fn render(self, area: Rect, buf: &mut Buffer, state: &mut FlapBoardState) {
68        let area = area.intersection(*buf.area());
69        fill_background(buf, area, self.theme.board_style());
70
71        let inner = render_block(self.block, area, buf);
72
73        if inner.width == 0 || inner.height == 0 {
74            return;
75        }
76
77        let cw = self.cell_width.columns();
78        let visible_cols = (inner.width / cw) as usize;
79        let visible_rows = (inner.height / CELL_HEIGHT) as usize;
80
81        if visible_cols == 0 || visible_rows == 0 {
82            return;
83        }
84
85        let render_cols = visible_cols.min(state.cols());
86        let render_rows = visible_rows.min(state.rows());
87
88        let board_width = render_cols as u16 * cw;
89        let board_height = render_rows as u16 * CELL_HEIGHT;
90        let x_offset = inner.x + (inner.width.saturating_sub(board_width)) / 2;
91        let y_offset = inner.y + (inner.height.saturating_sub(board_height)) / 2;
92
93        for row in 0..render_rows {
94            for col in 0..render_cols {
95                let Some(cell) = state.cell(row, col) else {
96                    continue;
97                };
98
99                let x = x_offset + col as u16 * cw;
100                let y = y_offset + row as u16 * CELL_HEIGHT;
101
102                render_cell(
103                    buf,
104                    Rect::new(x, y, cw, CELL_HEIGHT),
105                    cell,
106                    &self.cell_width,
107                    &self.theme,
108                );
109            }
110        }
111    }
112}
113
114fn fill_background(buf: &mut Buffer, area: Rect, style: Style) {
115    for y in area.top()..area.bottom() {
116        for x in area.left()..area.right() {
117            buf[(x, y)].set_style(style);
118        }
119    }
120}
121
122fn render_block(block: Option<Block<'_>>, area: Rect, buf: &mut Buffer) -> Rect {
123    match block {
124        Some(b) => {
125            let inner = b.inner(area);
126            b.render(area, buf);
127            inner
128        }
129        None => area,
130    }
131}
132
133fn render_cell(
134    buf: &mut Buffer,
135    area: Rect,
136    cell: &crate::cell::FlapCell,
137    cell_width: &CellWidth,
138    theme: &FlapTheme,
139) {
140    for y in area.top()..area.bottom() {
141        for x in area.left()..area.right() {
142            buf[(x, y)].set_style(theme.tile_style());
143        }
144    }
145
146    if *cell_width == CellWidth::Normal {
147        render_cell_border(buf, area, theme);
148    }
149
150    match cell.phase() {
151        FlipPhase::Settled | FlipPhase::Sequential | FlipPhase::Pending => {
152            render_char_cell(buf, area, cell.display(), cell_width, theme);
153        }
154
155        FlipPhase::Mechanical {
156            frame,
157            total_frames,
158        } => {
159            render_mechanical_cell(buf, area, cell, cell_width, theme, frame, total_frames);
160        }
161    }
162}
163
164fn render_cell_border(buf: &mut Buffer, area: Rect, theme: &FlapTheme) {
165    let style = theme.border_style();
166    let x0 = area.x;
167    let x2 = area.x + 2;
168    let y0 = area.y;
169    let y2 = area.y + 2;
170
171    buf[(x0, y0)].set_char('┌').set_style(style);
172    buf[(x0 + 1, y0)].set_char('─').set_style(style);
173    buf[(x2, y0)].set_char('┐').set_style(style);
174
175    buf[(x0, y0 + 1)].set_char('│').set_style(style);
176    buf[(x2, y0 + 1)].set_char('│').set_style(style);
177
178    buf[(x0, y2)].set_char('└').set_style(style);
179    buf[(x0 + 1, y2)].set_char('─').set_style(style);
180    buf[(x2, y2)].set_char('┘').set_style(style);
181}
182
183fn render_char_cell(
184    buf: &mut Buffer,
185    area: Rect,
186    ch: char,
187    cell_width: &CellWidth,
188    theme: &FlapTheme,
189) {
190    let char_x = char_column(area, cell_width);
191
192    buf[(char_x, area.y + 1)]
193        .set_char(ch)
194        .set_style(theme.char_style());
195}
196
197fn render_mechanical_cell(
198    buf: &mut Buffer,
199    area: Rect,
200    cell: &crate::cell::FlapCell,
201    cell_width: &CellWidth,
202    theme: &FlapTheme,
203    frame: u8,
204    total_frames: u8,
205) {
206    let stage = split_stage(frame, total_frames);
207    let char_x = char_column(area, cell_width);
208    let center_y = area.y + 1;
209
210    let (split_char, top_char, bottom_char) = match stage {
211        SplitStage::Closing => ('▄', Some(cell.settled()), None),
212        SplitStage::Occluded => ('█', None, None),
213        SplitStage::Opening => ('▀', None, Some(cell.target())),
214    };
215
216    buf[(char_x, center_y)]
217        .set_char(split_char)
218        .set_style(theme.split_style());
219
220    if let Some(ch) = top_char {
221        buf[(char_x, area.y)]
222            .set_char(ch)
223            .set_style(theme.char_style());
224    }
225
226    if let Some(ch) = bottom_char {
227        buf[(char_x, area.y + 2)]
228            .set_char(ch)
229            .set_style(theme.char_style());
230    }
231}
232
233fn char_column(area: Rect, cell_width: &CellWidth) -> u16 {
234    match cell_width {
235        CellWidth::Normal => area.x + 1,
236        CellWidth::Compact => area.x,
237    }
238}
239
240#[derive(Debug, Clone, Copy)]
241enum SplitStage {
242    Closing,
243    Occluded,
244    Opening,
245}
246
247fn split_stage(frame: u8, total_frames: u8) -> SplitStage {
248    if total_frames <= 1 {
249        return SplitStage::Occluded;
250    }
251
252    let third = (total_frames / 3).max(1);
253
254    if frame < third {
255        SplitStage::Closing
256    } else if frame >= total_frames - third {
257        SplitStage::Opening
258    } else {
259        SplitStage::Occluded
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::cell::FlipStyle;
267    use crate::state::FlapMessage;
268
269    fn render_to_buffer(
270        widget: FlapBoard,
271        state: &mut FlapBoardState,
272        width: u16,
273        height: u16,
274    ) -> Buffer {
275        let area = Rect::new(0, 0, width, height);
276        let mut buf = Buffer::empty(area);
277        widget.render(area, &mut buf, state);
278        buf
279    }
280
281    #[test]
282    fn settled_board_renders_characters() {
283        let mut state = FlapBoardState::new(1, 5).with_flip_speed_ms(80);
284        state.set_message(&FlapMessage::single("HELLO"));
285        let _ = state.tick(10000);
286
287        let buf = render_to_buffer(FlapBoard::new(), &mut state, 80, 24);
288
289        // No block → inner = full area (80×24). render_cols=5, board_width=15
290        // x_offset = (80-15)/2 = 32, y_offset = (24-3)/2 = 10
291        // center row = 10 + 1 = 11, char at center of 3-wide cell = x+1
292        let x_off: u16 = 32;
293        let y_center: u16 = 11;
294
295        let chars: Vec<char> = (0..5)
296            .map(|i| {
297                let x = x_off + 1 + i * 3;
298                buf[(x, y_center)].symbol().chars().next().unwrap_or('?')
299            })
300            .collect();
301
302        assert_eq!(chars, vec!['H', 'E', 'L', 'L', 'O']);
303    }
304
305    #[test]
306    fn cell_borders_rendered() {
307        let mut state = FlapBoardState::new(1, 1).with_flip_speed_ms(80);
308        state.set_message(&FlapMessage::single("A"));
309        let _ = state.tick(10000);
310
311        let buf = render_to_buffer(FlapBoard::new(), &mut state, 10, 5);
312
313        // board: 3×3, centered in 10×5 → x_offset=3, y_offset=1
314        assert_eq!(buf[(3, 1)].symbol(), "┌");
315        assert_eq!(buf[(4, 1)].symbol(), "─");
316        assert_eq!(buf[(5, 1)].symbol(), "┐");
317        assert_eq!(buf[(3, 2)].symbol(), "│");
318        assert_eq!(buf[(4, 2)].symbol(), "A");
319        assert_eq!(buf[(5, 2)].symbol(), "│");
320        assert_eq!(buf[(3, 3)].symbol(), "└");
321        assert_eq!(buf[(4, 3)].symbol(), "─");
322        assert_eq!(buf[(5, 3)].symbol(), "┘");
323    }
324
325    #[test]
326    fn block_provides_container() {
327        let mut state = FlapBoardState::new(1, 1);
328
329        let widget = FlapBoard::new().block(Block::bordered().title(" Test "));
330        let buf = render_to_buffer(widget, &mut state, 10, 5);
331
332        // Block border at edges, inner area for cells
333        assert_eq!(buf[(0, 0)].symbol(), "┌");
334        assert_eq!(buf[(9, 4)].symbol(), "┘");
335    }
336
337    #[test]
338    fn layout_computes_visible_cells() {
339        let mut state = FlapBoardState::new(7, 26);
340        let buf = render_to_buffer(FlapBoard::new(), &mut state, 80, 24);
341        assert_eq!(buf.area().width, 80);
342    }
343
344    #[test]
345    fn too_small_area_renders_nothing() {
346        let mut state = FlapBoardState::new(1, 5);
347        let buf = render_to_buffer(FlapBoard::new(), &mut state, 2, 2);
348        assert_eq!(buf.area().width, 2);
349    }
350
351    #[test]
352    fn compact_width_renders_without_borders() {
353        let mut state = FlapBoardState::new(1, 5).with_flip_speed_ms(80);
354        state.set_message(&FlapMessage::single("HELLO"));
355        let _ = state.tick(10000);
356
357        let widget = FlapBoard::new().cell_width(CellWidth::Compact);
358        let buf = render_to_buffer(widget, &mut state, 20, 5);
359
360        // No block → inner = 20×5. board: 5×3
361        // x_offset = (20-5)/2 = 7, y_offset = (5-3)/2 = 1
362        // center_y = 1 + 1 = 2
363        let chars: Vec<char> = (0..5)
364            .map(|i| {
365                let x = 7 + i;
366                buf[(x as u16, 2)].symbol().chars().next().unwrap_or('?')
367            })
368            .collect();
369
370        assert_eq!(chars, vec!['H', 'E', 'L', 'L', 'O']);
371    }
372
373    #[test]
374    fn mechanical_animation_renders_block_char() {
375        let mut state = FlapBoardState::new(1, 1)
376            .with_flip_speed_ms(80)
377            .with_flip_style(FlipStyle::Mechanical { frames: 3 });
378
379        state.set_message(&FlapMessage::single("Z"));
380
381        // No block → inner = 10×5. board: 3×3
382        // x_offset = (10-3)/2 = 3, y_offset = (5-3)/2 = 1
383        // center_y = 2, char_x = 3+1 = 4
384        let buf = render_to_buffer(FlapBoard::new(), &mut state, 10, 5);
385
386        let center_char = buf[(4, 2)].symbol().chars().next().unwrap_or('?');
387        assert_eq!(center_char, '▄');
388    }
389
390    #[test]
391    fn board_centers_in_available_space() {
392        let mut state = FlapBoardState::new(1, 2);
393        // 2 cells × 3 wide = 6 board width, 3 board height
394        // No block → inner = 20×5. x_offset = (20-6)/2 = 7, y_offset = (5-3)/2 = 1
395        let buf = render_to_buffer(FlapBoard::new(), &mut state, 20, 5);
396
397        let theme = FlapTheme::default();
398
399        assert_eq!(buf[(7, 2)].bg, theme.tile_bg);
400        assert_eq!(buf[(12, 2)].bg, theme.tile_bg);
401
402        assert_eq!(buf[(6, 2)].bg, theme.bg);
403        assert_eq!(buf[(13, 2)].bg, theme.bg);
404    }
405}