Add kiln-tui: a terminal player for kiln boards

New `kiln-tui` crate (ratatui 0.30 / crossterm) that plays a board from a
.toml map named on the command line, letting the player walk around with the
arrow keys or hjkl. It depends only on kiln-core — the engine needed zero
changes, confirming it is genuinely UI-agnostic.

- Text rendering: each Glyph.tile index is reinterpreted as a character via a
  CP437->Unicode table (cp437.rs); fg/bg map to ratatui Color::Rgb truecolor.
- render.rs BoardWidget: per-axis viewport that centers a small board and
  scrolls a large one to keep the player visible.
- term.rs TerminalCaps: detects truecolor and Kitty keyboard-protocol support,
  enabling the protocol when present (held-key repeat, modifier-only keys);
  shows a rainbow "truecolor" badge in the bottom border.
- Input loop acts on Press and Repeat (continuous held-key movement) and
  ignores Release.

Workspace: removed kiln-egui from members (files left untouched). Updated
CLAUDE.md and ARCHITECTURE.md to document the workspace split and kiln-tui.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 20:10:02 -05:00
parent 353461dbfd
commit de1870432f
9 changed files with 1252 additions and 3403 deletions
+96
View File
@@ -0,0 +1,96 @@
//! 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 color::Rgba8;
use kiln_core::game::{Board, Glyph};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::widgets::Widget;
/// Converts a core [`Rgba8`] color into a ratatui truecolor [`Color`].
///
/// The alpha channel is dropped — terminals have no per-cell alpha. Truecolor
/// requires terminal support; terminals limited to 256 colors approximate it.
fn rgba8_to_color(c: Rgba8) -> Color {
Color::Rgb(c.r, c.g, c.b)
}
/// 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.
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)
}
}
}
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;
// Resolve which glyph wins this cell: player > object > grid floor.
let glyph = if board.player.x == bx as i32 && board.player.y == by as i32 {
Glyph::player()
} else if let Some(obj) = board.object_at(bx, by) {
obj.glyph
} else {
board.get(bx, by).0
};
// 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));
}
}
}
}
}