//! Rendering a kiln [`Board`] into a ratatui buffer as colored text. //! //! Each board cell becomes one terminal cell: the glyph's tile index is mapped //! to a character (see [`crate::cp437`]) and drawn with the glyph's foreground //! and background colors. Objects are drawn over their floor cell, and the //! player is drawn on top of everything. use crate::cp437::tile_to_char; use kiln_core::Board; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::widgets::Widget; use crate::utils::rgba8_to_color; /// A ratatui widget that draws a [`Board`] (with objects and the player) into a /// rectangular area, scrolled so the player stays visible on larger boards. pub struct BoardWidget<'a> { /// The board to render. Borrowed for the duration of the draw. pub board: &'a Board, } impl<'a> BoardWidget<'a> { /// Creates a widget that renders `board`. pub fn new(board: &'a Board) -> Self { Self { board } } /// Lays out one axis of the board within a viewport `view` cells long. /// /// Returns `(off, pad, count)` where `off` is the first board cell to draw, /// `pad` is the blank margin (in screen cells) before the board starts, and /// `count` is how many board cells are visible: /// /// - When the board fits (`board_len <= view`) it is **centered**: `off` is /// 0, `pad` splits the leftover space, and the whole board is drawn. /// - When the board is larger it **scrolls** to follow the player: `pad` is /// 0, `off` centers on the player clamped into `[0, board_len - view]`, and /// exactly `view` cells are shown — never any empty space past the edge. pub fn axis(board_len: usize, view: usize, player: i32) -> (usize, u16, usize) { if board_len <= view { let pad = (view - board_len) / 2; (0, pad as u16, board_len) } else { let half = (view / 2) as i32; let off = player .saturating_sub(half) .clamp(0, (board_len - view) as i32) as usize; (off, 0, view) } } } /// Maps a board cell `(bx, by)` to its screen position within `area`, using the /// same centering/scrolling layout as [`BoardWidget`]. Returns `None` when the /// cell is scrolled out of view. /// /// The inverse of [`screen_to_board`]; both reuse [`BoardWidget::axis`] so they /// stay consistent with how the board is actually drawn. (Unlike /// `speech::board_screen_pos`, which clamps off-screen cells to the edge, this /// reports off-screen cells as `None` — needed for exact cursor placement.) pub fn board_to_screen(area: Rect, board: &Board, bx: usize, by: usize) -> Option<(u16, u16)> { let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x); let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, board.player.y); // Cell must fall within the visible window on both axes. if bx < off_x || bx >= off_x + cols || by < off_y || by >= off_y + rows { return None; } let sx = area.x + pad_x + (bx - off_x) as u16; let sy = area.y + pad_y + (by - off_y) as u16; Some((sx, sy)) } /// Maps a screen position `(sx, sy)` to the board cell drawn there within `area`, /// the inverse of [`board_to_screen`]. Returns `None` when the point lies outside /// the drawn board (in the centering pad or past the visible cells). pub fn screen_to_board(area: Rect, board: &Board, sx: u16, sy: u16) -> Option<(usize, usize)> { let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x); let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, board.player.y); // Screen-space offset from the first drawn cell; reject anything before it. let col = (sx as i32) - (area.x as i32 + pad_x as i32); let row = (sy as i32) - (area.y as i32 + pad_y as i32); if col < 0 || row < 0 || col as usize >= cols || row as usize >= rows { return None; } Some((off_x + col as usize, off_y + row as usize)) } #[cfg(test)] mod tests { use super::BoardWidget; #[test] fn axis_centers_a_small_board() { // 10-cell board in a 20-cell view: no scroll, 5 cells of pad each side. let (off, pad, count) = BoardWidget::axis(10, 20, 3); assert_eq!((off, pad, count), (0, 5, 10)); } #[test] fn axis_scrolls_a_large_board_to_follow_the_player() { // 100-cell board in a 20-cell view, player at 50: centered, no pad. let (off, pad, count) = BoardWidget::axis(100, 20, 50); assert_eq!((off, pad, count), (40, 0, 20)); } #[test] fn axis_clamps_scroll_at_the_edges() { // Player near the left edge can't scroll past 0. assert_eq!(BoardWidget::axis(100, 20, 2), (0, 0, 20)); // Player near the right edge clamps to board_len - view. assert_eq!(BoardWidget::axis(100, 20, 99), (80, 0, 20)); } } impl Widget for BoardWidget<'_> { fn render(self, area: Rect, buf: &mut Buffer) { let board = self.board; // Resolve each axis independently: a small board is centered, a large // one scrolls to keep the player on screen. let (off_x, pad_x, cols) = Self::axis(board.width, area.width as usize, board.player.x); let (off_y, pad_y, rows) = Self::axis(board.height, area.height as usize, board.player.y); // Walk the visible board cells, placing each after the centering pad. for row in 0..rows { let by = off_y + row; let sy = area.y + pad_y + row as u16; for col in 0..cols { let bx = off_x + col; let sx = area.x + pad_x + col as u16; let glyph = board.glyph_at(bx, by); // Paint the resolved glyph into the terminal cell. if let Some(cell) = buf.cell_mut((sx, sy)) { cell.set_char(tile_to_char(glyph.tile)) .set_fg(rgba8_to_color(glyph.fg)) .set_bg(rgba8_to_color(glyph.bg)); } } } } }