1use 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#[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#[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 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 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 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 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 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 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}