2026-06-03 20:10:02 -05:00
|
|
|
//! 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
|
2026-06-20 15:27:09 -05:00
|
|
|
//! to a character (see [`kiln_core::cp437`]) and drawn with the glyph's foreground
|
2026-06-03 20:10:02 -05:00
|
|
|
//! and background colors. Objects are drawn over their floor cell, and the
|
|
|
|
|
//! player is drawn on top of everything.
|
|
|
|
|
|
2026-06-21 01:32:47 -05:00
|
|
|
use crate::utils::rgba8_to_color;
|
2026-06-07 00:19:53 -05:00
|
|
|
use kiln_core::Board;
|
2026-06-21 01:32:47 -05:00
|
|
|
use kiln_core::cp437::tile_to_char;
|
2026-06-03 20:10:02 -05:00
|
|
|
use ratatui::buffer::Buffer;
|
2026-06-11 21:31:37 -05:00
|
|
|
use ratatui::layout::Rect;
|
|
|
|
|
use ratatui::widgets::Widget;
|
2026-06-03 22:46:54 -05:00
|
|
|
|
2026-06-03 20:10:02 -05:00
|
|
|
/// A ratatui widget that draws a [`Board`] (with objects and the player) into a
|
2026-06-23 21:59:40 -05:00
|
|
|
/// rectangular area, scrolled so a chosen focus cell stays visible on larger boards.
|
2026-06-03 20:10:02 -05:00
|
|
|
pub struct BoardWidget<'a> {
|
|
|
|
|
/// The board to render. Borrowed for the duration of the draw.
|
|
|
|
|
pub board: &'a Board,
|
2026-06-23 21:59:40 -05:00
|
|
|
/// The board cell (x, y) the viewport scrolls to keep visible. Defaults to
|
|
|
|
|
/// the player position so play mode requires no extra setup.
|
|
|
|
|
focus: (i32, i32),
|
2026-06-03 20:10:02 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<'a> BoardWidget<'a> {
|
2026-06-23 21:59:40 -05:00
|
|
|
/// Creates a widget that renders `board`, scrolling to follow the player.
|
2026-06-03 20:10:02 -05:00
|
|
|
pub fn new(board: &'a Board) -> Self {
|
2026-06-28 00:12:52 -05:00
|
|
|
let focus = (board.player.x as i32, board.player.y as i32);
|
2026-06-23 21:59:40 -05:00
|
|
|
Self { board, focus }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Overrides the scroll focus to `(x, y)` — use in the editor to follow the
|
|
|
|
|
/// cursor instead of the player.
|
|
|
|
|
pub fn with_focus(mut self, x: i32, y: i32) -> Self {
|
|
|
|
|
self.focus = (x, y);
|
|
|
|
|
self
|
2026-06-03 20:10:02 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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.
|
2026-06-08 22:15:44 -05:00
|
|
|
pub fn axis(board_len: usize, view: usize, player: i32) -> (usize, u16, usize) {
|
2026-06-03 20:10:02 -05:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 22:02:15 -05:00
|
|
|
/// Maps a board cell `(bx, by)` to its screen position within `area`, using the
|
2026-06-23 21:59:40 -05:00
|
|
|
/// same centering/scrolling layout as [`BoardWidget`] with the given `focus`.
|
|
|
|
|
/// Returns `None` when the cell is scrolled out of view.
|
|
|
|
|
///
|
|
|
|
|
/// Pass `(board.player.x, board.player.y)` as `focus` in play mode, or the
|
|
|
|
|
/// editor cursor coordinates in editor mode, to match the active viewport.
|
2026-06-15 22:02:15 -05:00
|
|
|
///
|
|
|
|
|
/// 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.)
|
2026-06-23 21:59:40 -05:00
|
|
|
pub fn board_to_screen(
|
|
|
|
|
area: Rect,
|
|
|
|
|
board: &Board,
|
|
|
|
|
bx: usize,
|
|
|
|
|
by: usize,
|
|
|
|
|
focus: (i32, i32),
|
|
|
|
|
) -> Option<(u16, u16)> {
|
|
|
|
|
let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, focus.0);
|
|
|
|
|
let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, focus.1);
|
2026-06-15 22:02:15 -05:00
|
|
|
// 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`,
|
2026-06-23 21:59:40 -05:00
|
|
|
/// the inverse of [`board_to_screen`]. `focus` must match the focus used when
|
|
|
|
|
/// the board was rendered so the coordinate mapping is consistent.
|
|
|
|
|
/// 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,
|
|
|
|
|
focus: (i32, i32),
|
|
|
|
|
) -> Option<(usize, usize)> {
|
|
|
|
|
let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, focus.0);
|
|
|
|
|
let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, focus.1);
|
2026-06-15 22:02:15 -05:00
|
|
|
// 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));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 20:10:02 -05:00
|
|
|
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
|
2026-06-23 21:59:40 -05:00
|
|
|
// one scrolls to keep the focus cell on screen.
|
|
|
|
|
let (off_x, pad_x, cols) = Self::axis(board.width, area.width as usize, self.focus.0);
|
|
|
|
|
let (off_y, pad_y, rows) = Self::axis(board.height, area.height as usize, self.focus.1);
|
2026-06-03 20:10:02 -05:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
2026-06-07 00:19:53 -05:00
|
|
|
let glyph = board.glyph_at(bx, by);
|
2026-06-03 20:10:02 -05:00
|
|
|
|
|
|
|
|
// 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));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|