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
+133
View File
@@ -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)
}