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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user