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:
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "kiln-tui"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
kiln-core = { path = "../kiln-core" }
|
||||
color = "0.3.3"
|
||||
ratatui = "0.30"
|
||||
@@ -0,0 +1,89 @@
|
||||
//! CP437 → Unicode mapping for rendering glyph tile indices as terminal text.
|
||||
//!
|
||||
//! kiln stores each cell's visual as a `tile: u32` index into a bitmap font.
|
||||
//! The default kiln font is IBM CP437, where the tile index equals the CP437
|
||||
//! code point. A terminal can't draw the bitmap font, so kiln-tui reinterprets
|
||||
//! the tile index as a character via this table and prints that character with
|
||||
//! the cell's foreground/background colors.
|
||||
|
||||
/// CP437 code point → Unicode scalar value.
|
||||
///
|
||||
/// Index this array by a byte value (0–255) to get the displayable character.
|
||||
/// The low control range (0x00–0x1F) maps to CP437's graphic glyphs (hearts,
|
||||
/// arrows, musical notes, etc.) rather than ASCII control codes, matching how
|
||||
/// these byte values render in a DOS/ZZT-style font. Code 0x00 maps to a blank
|
||||
/// space since a literal NUL is not displayable.
|
||||
const CP437: [char; 256] = [
|
||||
// 0x00–0x0F
|
||||
' ', '☺', '☻', '♥', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼',
|
||||
// 0x10–0x1F
|
||||
'►', '◄', '↕', '‼', '¶', '§', '▬', '↨', '↑', '↓', '→', '←', '∟', '↔', '▲', '▼',
|
||||
// 0x20–0x2F (ASCII space onward)
|
||||
' ', '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '-', '.', '/',
|
||||
// 0x30–0x3F
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';', '<', '=', '>', '?',
|
||||
// 0x40–0x4F
|
||||
'@', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O',
|
||||
// 0x50–0x5F
|
||||
'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '[', '\\', ']', '^', '_',
|
||||
// 0x60–0x6F
|
||||
'`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
|
||||
// 0x70–0x7F
|
||||
'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '{', '|', '}', '~', '⌂',
|
||||
// 0x80–0x8F
|
||||
'Ç', 'ü', 'é', 'â', 'ä', 'à', 'å', 'ç', 'ê', 'ë', 'è', 'ï', 'î', 'ì', 'Ä', 'Å',
|
||||
// 0x90–0x9F
|
||||
'É', 'æ', 'Æ', 'ô', 'ö', 'ò', 'û', 'ù', 'ÿ', 'Ö', 'Ü', '¢', '£', '¥', '₧', 'ƒ',
|
||||
// 0xA0–0xAF
|
||||
'á', 'í', 'ó', 'ú', 'ñ', 'Ñ', 'ª', 'º', '¿', '⌐', '¬', '½', '¼', '¡', '«', '»',
|
||||
// 0xB0–0xBF (shading + box drawing)
|
||||
'░', '▒', '▓', '│', '┤', '╡', '╢', '╖', '╕', '╣', '║', '╗', '╝', '╜', '╛', '┐',
|
||||
// 0xC0–0xCF
|
||||
'└', '┴', '┬', '├', '─', '┼', '╞', '╟', '╚', '╔', '╩', '╦', '╠', '═', '╬', '╧',
|
||||
// 0xD0–0xDF
|
||||
'╨', '╤', '╥', '╙', '╘', '╒', '╓', '╫', '╪', '┘', '┌', '█', '▄', '▌', '▐', '▀',
|
||||
// 0xE0–0xEF
|
||||
'α', 'ß', 'Γ', 'π', 'Σ', 'σ', 'µ', 'τ', 'Φ', 'Θ', 'Ω', 'δ', '∞', 'φ', 'ε', '∩',
|
||||
// 0xF0–0xFF
|
||||
'≡', '±', '≥', '≤', '⌠', '⌡', '÷', '≈', '°', '∙', '·', '√', 'ⁿ', '²', '■', '\u{00A0}',
|
||||
];
|
||||
|
||||
/// Converts a glyph tile index into a displayable character.
|
||||
///
|
||||
/// Tile indices in `0..256` use the [`CP437`] table. Larger indices (which a
|
||||
/// non-default font could in principle reference) fall back to interpreting the
|
||||
/// index as a raw Unicode scalar, then to a blank space if that is not a valid
|
||||
/// or printable character.
|
||||
pub fn tile_to_char(tile: u32) -> char {
|
||||
if tile < 256 {
|
||||
CP437[tile as usize]
|
||||
} else {
|
||||
char::from_u32(tile).unwrap_or(' ')
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ascii_passthrough() {
|
||||
// The printable ASCII range must map to itself.
|
||||
assert_eq!(tile_to_char('@' as u32), '@'); // player tile 64
|
||||
assert_eq!(tile_to_char('#' as u32), '#'); // wall tile 35
|
||||
assert_eq!(tile_to_char(' ' as u32), ' '); // empty tile 32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_drawing_and_blocks() {
|
||||
assert_eq!(tile_to_char(0xB0), '░');
|
||||
assert_eq!(tile_to_char(0xDB), '█');
|
||||
assert_eq!(tile_to_char(0xC4), '─');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_falls_back_to_space() {
|
||||
// A surrogate code point is not a valid char → blank.
|
||||
assert_eq!(tile_to_char(0xD800), ' ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! `kiln-tui` — a terminal "player" for kiln boards.
|
||||
//!
|
||||
//! Pass a `.toml` map file as the single command-line argument; kiln-tui loads
|
||||
//! that board and lets you walk the player around it with the arrow keys
|
||||
//! (or HJKL). There is no editor — this is a play-only front-end.
|
||||
//!
|
||||
//! Rendering is text-based: kiln's bitmap-font tile indices are reinterpreted
|
||||
//! as characters (see [`cp437`]) and drawn with each cell's RGB colors.
|
||||
|
||||
mod cp437;
|
||||
mod render;
|
||||
mod term;
|
||||
|
||||
use kiln_core::game::GameState;
|
||||
use kiln_core::map_file;
|
||||
use ratatui::Frame;
|
||||
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||
use ratatui::widgets::Block;
|
||||
use render::BoardWidget;
|
||||
use std::process::ExitCode;
|
||||
use term::TerminalCaps;
|
||||
|
||||
/// Entry point: parse the map path, load the board, then run the play loop with
|
||||
/// the terminal in raw/alternate-screen mode (restored on every exit path).
|
||||
fn main() -> ExitCode {
|
||||
// The single positional argument is the map file to play.
|
||||
let Some(path) = std::env::args().nth(1) else {
|
||||
eprintln!("usage: kiln-tui <map.toml>");
|
||||
return ExitCode::from(2);
|
||||
};
|
||||
|
||||
// Load before touching the terminal so load errors print to a normal screen.
|
||||
let board = match map_file::load(&path) {
|
||||
Ok(board) => board,
|
||||
Err(e) => {
|
||||
eprintln!("error loading {path}: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
let mut game = GameState::new(board);
|
||||
|
||||
// `init` enters the alternate screen and raw mode; `restore` always undoes it.
|
||||
let mut terminal = ratatui::init();
|
||||
|
||||
// Detect terminal capabilities (now that we're in raw mode) and enable the
|
||||
// Kitty keyboard protocol when it's available, so scripts can rely on the
|
||||
// richer bindings it provides. Disabled again before restoring the terminal.
|
||||
let caps = TerminalCaps::detect();
|
||||
if caps.keyboard_enhancement {
|
||||
let _ = term::push_kitty_flags();
|
||||
}
|
||||
|
||||
let result = run(&mut terminal, &mut game, &caps);
|
||||
|
||||
if caps.keyboard_enhancement {
|
||||
let _ = term::pop_kitty_flags();
|
||||
}
|
||||
ratatui::restore();
|
||||
|
||||
if let Err(e) = result {
|
||||
eprintln!("error: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// Draw-then-handle-input loop. Returns once the user asks to quit.
|
||||
fn run(
|
||||
terminal: &mut ratatui::DefaultTerminal,
|
||||
game: &mut GameState,
|
||||
caps: &TerminalCaps,
|
||||
) -> std::io::Result<()> {
|
||||
loop {
|
||||
// Redraw the board every iteration; ratatui diffs against the previous
|
||||
// frame so only changed cells are actually written to the terminal.
|
||||
terminal.draw(|frame| draw(frame, game, caps))?;
|
||||
|
||||
// Block until the next input event.
|
||||
if let Event::Key(key) = event::read()? {
|
||||
// Act on Press and Repeat so holding a key moves the player
|
||||
// continuously (the Kitty protocol emits Repeat events while a key is
|
||||
// held). Ignore Release, which that protocol also emits and which
|
||||
// would otherwise fire a second, spurious move per keypress.
|
||||
if key.kind == KeyEventKind::Release {
|
||||
continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
||||
KeyCode::Up | KeyCode::Char('k') => game.try_move(0, -1),
|
||||
KeyCode::Down | KeyCode::Char('j') => game.try_move(0, 1),
|
||||
KeyCode::Left | KeyCode::Char('h') => game.try_move(-1, 0),
|
||||
KeyCode::Right | KeyCode::Char('l') => game.try_move(1, 0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a single frame: a bordered block titled with the board name and
|
||||
/// controls, containing the board itself.
|
||||
fn draw(frame: &mut Frame, game: &GameState, caps: &TerminalCaps) {
|
||||
let board = &game.board;
|
||||
let block = Block::bordered()
|
||||
.title(format!(" {} — arrows move · q quit ", board.name))
|
||||
// Surface detected terminal capabilities in the bottom-right border.
|
||||
.title_bottom(caps.summary_line().right_aligned());
|
||||
// Draw the board inside the border, not over it.
|
||||
let inner = block.inner(frame.area());
|
||||
frame.render_widget(block, frame.area());
|
||||
frame.render_widget(BoardWidget::new(board), inner);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Terminal capability detection and Kitty keyboard-protocol setup.
|
||||
//!
|
||||
//! kiln-tui detects what the host terminal can do once at startup and, when the
|
||||
//! terminal supports the Kitty keyboard protocol, enables it so richer input
|
||||
//! (modifier-only keys, key release/repeat, unambiguous Ctrl combos) becomes
|
||||
//! available. The detected [`TerminalCaps`] are intended to be handed to scripts
|
||||
//! so they can pick key bindings that actually work on this terminal.
|
||||
|
||||
use ratatui::crossterm::event::{
|
||||
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
||||
};
|
||||
use ratatui::crossterm::execute;
|
||||
use ratatui::crossterm::terminal::supports_keyboard_enhancement;
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use std::io;
|
||||
|
||||
/// The input capabilities of the host terminal, detected once at startup.
|
||||
///
|
||||
/// Scripts should branch on these capabilities rather than on the terminal's
|
||||
/// program name: capabilities stay correct on terminals nobody has special-cased
|
||||
/// (and through tmux/SSH), whereas brand names are brittle and often unset.
|
||||
pub struct TerminalCaps {
|
||||
/// Whether the Kitty keyboard protocol is supported (and has been enabled).
|
||||
///
|
||||
/// When true, bindings that legacy terminals cannot express are available:
|
||||
/// a bare modifier keypress (e.g. Ctrl by itself), key release/repeat events,
|
||||
/// `Ctrl+I` distinct from `Tab`, and reliable `Ctrl`+digit/symbol combos.
|
||||
pub keyboard_enhancement: bool,
|
||||
/// Whether the terminal advertises 24-bit truecolor (via `$COLORTERM`).
|
||||
/// When false, RGB glyph colors are approximated to the 256-color palette.
|
||||
pub truecolor: bool,
|
||||
}
|
||||
|
||||
impl TerminalCaps {
|
||||
/// Detects the host terminal's capabilities.
|
||||
///
|
||||
/// `keyboard_enhancement` is a real query/response round-trip with the
|
||||
/// terminal (it may block briefly, and relies on a timeout over slow links),
|
||||
/// so call this once and cache the result. `truecolor` reads an environment
|
||||
/// variable, which is heuristic.
|
||||
pub fn detect() -> Self {
|
||||
Self {
|
||||
keyboard_enhancement: matches!(supports_keyboard_enhancement(), Ok(true)),
|
||||
truecolor: std::env::var("COLORTERM")
|
||||
.map(|v| v == "truecolor" || v == "24bit")
|
||||
.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-line styled summary for the UI, e.g. `"truecolor · enhanced input"`.
|
||||
///
|
||||
/// When truecolor is available the word "truecolor" is rendered with each
|
||||
/// letter a different rainbow color — a fitting flex, since only a terminal
|
||||
/// that can show per-cell RGB can display it. Otherwise the color word is the
|
||||
/// plain `"256-color"`. The input descriptor is always drawn in the default
|
||||
/// color.
|
||||
pub fn summary_line(&self) -> Line<'static> {
|
||||
let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];
|
||||
|
||||
if self.truecolor {
|
||||
// Rotate the hue across the letters so each gets a distinct spectrum color.
|
||||
let word = "truecolor";
|
||||
let n = word.chars().count();
|
||||
for (i, ch) in word.chars().enumerate() {
|
||||
let (r, g, b) = hue_to_rgb(i as f32 / n as f32 * 360.0);
|
||||
spans.push(Span::styled(
|
||||
ch.to_string(),
|
||||
Style::default().fg(Color::Rgb(r, g, b)),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
spans.push(Span::raw("256-color"));
|
||||
}
|
||||
|
||||
let input = if self.keyboard_enhancement {
|
||||
"enhanced input"
|
||||
} else {
|
||||
"legacy input"
|
||||
};
|
||||
spans.push(Span::raw(format!(" · {input} ")));
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a hue in degrees to an RGB triple at full saturation and value.
|
||||
///
|
||||
/// Standard 6-segment HSV→RGB conversion (S = V = 1); used to spread a rainbow
|
||||
/// across the letters of the "truecolor" badge.
|
||||
fn hue_to_rgb(hue: f32) -> (u8, u8, u8) {
|
||||
let h = (hue % 360.0) / 60.0;
|
||||
// `x` ramps the secondary channel up/down within each 60° segment.
|
||||
let x = 1.0 - (h % 2.0 - 1.0).abs();
|
||||
let (r, g, b) = match h as u32 {
|
||||
0 => (1.0, x, 0.0),
|
||||
1 => (x, 1.0, 0.0),
|
||||
2 => (0.0, 1.0, x),
|
||||
3 => (0.0, x, 1.0),
|
||||
4 => (x, 0.0, 1.0),
|
||||
_ => (1.0, 0.0, x),
|
||||
};
|
||||
((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
|
||||
}
|
||||
|
||||
/// The Kitty keyboard-protocol features kiln-tui requests when supported:
|
||||
/// unambiguous escape codes, press/release/repeat event types, alternate-key
|
||||
/// reporting, and reporting every key as an escape code (which is what makes
|
||||
/// modifier-only keypresses observable).
|
||||
fn enhancement_flags() -> KeyboardEnhancementFlags {
|
||||
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
|
||||
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
|
||||
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
|
||||
| KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
|
||||
}
|
||||
|
||||
/// Enables the Kitty keyboard protocol on stdout.
|
||||
///
|
||||
/// Only call this after the terminal is in raw mode (e.g. after `ratatui::init`)
|
||||
/// and only when [`TerminalCaps::keyboard_enhancement`] is true. Pair every call
|
||||
/// with [`pop_kitty_flags`] before restoring the terminal.
|
||||
pub fn push_kitty_flags() -> io::Result<()> {
|
||||
execute!(
|
||||
io::stdout(),
|
||||
PushKeyboardEnhancementFlags(enhancement_flags())
|
||||
)
|
||||
}
|
||||
|
||||
/// Disables the Kitty keyboard protocol previously enabled by [`push_kitty_flags`].
|
||||
/// Call this before `ratatui::restore` so the terminal is left as we found it.
|
||||
pub fn pop_kitty_flags() -> io::Result<()> {
|
||||
execute!(io::stdout(), PopKeyboardEnhancementFlags)
|
||||
}
|
||||
Reference in New Issue
Block a user