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:
+44
-11
@@ -12,7 +12,9 @@ This document records the architectural decisions made so far and the reasoning
|
||||
|
||||
| Concern | Choice | Reason |
|
||||
|---------|--------|--------|
|
||||
| GUI/windowing | eframe 0.33 / egui 0.33 | Pure Rust, retained-mode, works on desktop and (eventually) WASM |
|
||||
| Engine core | `kiln-core` crate | UI-agnostic game types + map I/O; the one crate every front-end depends on |
|
||||
| Terminal front-end | `kiln-tui` — ratatui 0.30 / crossterm | Pure Rust TUI **player**; renders boards as colored text |
|
||||
| Desktop front-end | `kiln-egui` — eframe / egui 0.33 | Pure Rust retained-mode GUI player + editor. **Currently not a workspace member** (see Module structure) |
|
||||
| Scripting | Rhai 1.x | Pure Rust, sandboxed, WASM-compatible; designed for embedding |
|
||||
| Map format | TOML + serde | Human-readable, good Rust tooling, standard in the ecosystem |
|
||||
|
||||
@@ -22,22 +24,27 @@ This document records the architectural decisions made so far and the reasoning
|
||||
|
||||
## Module structure
|
||||
|
||||
Cargo **workspace**; root `members = ["kiln-core", "kiln-tui"]`. `kiln-egui` stays in the tree but was removed from the workspace and is not built at the root.
|
||||
|
||||
```
|
||||
src/
|
||||
main.rs — App, AppMode, frame loop (input → menu → panels → board → dialogs)
|
||||
kiln-core/src/ — the engine (UI-agnostic)
|
||||
game.rs — core data types and game logic
|
||||
map_file.rs — TOML deserialization, Board loader
|
||||
render.rs — drawing primitives; cell size comes from BitmapFont
|
||||
font.rs — BitmapFont, tile UV mapping, placeholder font
|
||||
font_dialog.rs — floating font-picker dialog (path, tile dims, preview, apply)
|
||||
editor.rs — EditorTab, EditorState, show_editor_panel
|
||||
glyph_picker.rs — floating Glyph picker dialog; decoupled (open, glyph, font) interface
|
||||
map_file.rs — TOML deserialization, Board load/save
|
||||
kiln-tui/src/ — terminal player (no editor)
|
||||
main.rs — CLI arg + play loop; TerminalCaps detection; Kitty flags
|
||||
render.rs — BoardWidget: draws the board into a ratatui buffer as text
|
||||
cp437.rs — CP437 → Unicode table; renders tile indices as characters
|
||||
term.rs — TerminalCaps detection + Kitty keyboard-protocol setup
|
||||
kiln-egui/src/ — eframe/egui player + editor (NOT a workspace member)
|
||||
main.rs, render.rs, font.rs, font_dialog.rs, editor.rs, glyph_picker.rs
|
||||
maps/
|
||||
start.toml — the starting map (loaded at launch)
|
||||
start.toml — example map; play via `cargo run -p kiln-tui -- maps/start.toml`
|
||||
assets/
|
||||
vga-font-8x16.png — default CP437 bitmap font (256×128 px, 32 cols × 8 rows, 8×16 tiles)
|
||||
vga-font-8x16.png — default CP437 bitmap font (256×128 px, 8×16 tiles); used by kiln-egui
|
||||
```
|
||||
|
||||
The per-module deep-dives below are labeled by file. `game.rs`/`map_file.rs` are **kiln-core**; the kiln-tui player has its own section; `font.rs`, `font_dialog.rs`, and the egui `main.rs`/`render.rs`/`editor.rs`/`glyph_picker.rs` are **kiln-egui** (retained as design notes for when the GUI is revived).
|
||||
|
||||
### `game.rs` — core types
|
||||
|
||||
The types here are the runtime representation of a game world. They are deliberately free of any file-loading or rendering concerns.
|
||||
@@ -87,6 +94,30 @@ Currently a thin wrapper around `Board` that provides game-logic methods (`try_m
|
||||
|
||||
---
|
||||
|
||||
### kiln-tui — terminal player
|
||||
|
||||
`kiln-tui` is a **play-only** front-end (no editor yet): it takes a map-file path on the command line, loads a `Board` via `kiln_core::map_file::load`, and lets the player walk around with the arrow keys (or `hjkl`). It depends only on `kiln-core`, `ratatui`/`crossterm`, and the `color` crate — none of the egui stack. This is the proof that `kiln-core` is genuinely UI-agnostic: the engine needed **zero changes** to add a second front-end.
|
||||
|
||||
**Text rendering instead of bitmap tiles.**
|
||||
A terminal can't blit a bitmap font, so kiln-tui reinterprets each `Glyph.tile` index as a *character*. The default kiln font is CP437, where the tile index equals the code point, so `cp437::tile_to_char` maps indices through a 256-entry CP437→Unicode table (graphic glyphs for 0x00–0x1F, box-drawing for 0xB0–0xDF, ASCII in between). The `[font]` map section is still parsed and stored on `Board`, but kiln-tui ignores the image path and uses the tile indices directly. `Glyph.fg`/`bg` (`Rgba8`) map to `ratatui::style::Color::Rgb` — full 24-bit truecolor, alpha dropped (256-color terminals approximate it).
|
||||
|
||||
**`render.rs` — `BoardWidget`.**
|
||||
A `ratatui::widgets::Widget` that draws the board into the frame buffer. The `axis(board_len, view, player) -> (offset, pad, count)` helper resolves each axis independently: a board smaller than the viewport is **centered** with screen padding; a larger one **scrolls** to keep the player visible, clamped so no empty margin shows past the board edge. Per cell, glyph priority is player > object > grid floor.
|
||||
|
||||
**`term.rs` — capability detection + Kitty protocol.**
|
||||
`TerminalCaps { keyboard_enhancement, truecolor }` is detected once at startup: `keyboard_enhancement` via crossterm's `supports_keyboard_enhancement()` (a real query/response with the terminal), `truecolor` from `$COLORTERM`. When keyboard enhancement is supported, kiln-tui pushes the Kitty keyboard-protocol flags (`DISAMBIGUATE_ESCAPE_CODES | REPORT_EVENT_TYPES | REPORT_ALTERNATE_KEYS | REPORT_ALL_KEYS_AS_ESCAPE_CODES`) after `ratatui::init()` and pops them before `restore()`. This unlocks richer input — modifier-only keypresses, key release/repeat events, unambiguous Ctrl combos — which the engine intends to hand to scripts so they can choose bindings the terminal actually supports.
|
||||
|
||||
**Input loop.**
|
||||
Because `REPORT_EVENT_TYPES` makes the terminal emit Release (and Repeat) events, the loop acts on `Press` **and** `Repeat` — so holding a key moves the player continuously at the terminal's own repeat cadence — and skips `Release`, which would otherwise fire a second, spurious move per keypress. In legacy terminals no Repeat events arrive, so held-key movement falls back to terminal auto-repeat; same behavior, no special-casing.
|
||||
|
||||
**Capability badge.**
|
||||
`TerminalCaps::summary_line()` renders a one-line badge in the bottom border. When truecolor is available it shows off by drawing the word "truecolor" with each letter a different rainbow color (`hue_to_rgb`, a full-saturation HSV→RGB helper); otherwise it shows a plain "256-color", followed by the input descriptor ("enhanced input" / "legacy input") in the default color.
|
||||
|
||||
**Why a capability struct, not a terminal name.**
|
||||
Scripts should branch on *what works* (truecolor, keyboard enhancement), not on a brittle terminal brand string that is often unset over SSH or inside tmux. `TerminalCaps` deliberately carries no program-identity field.
|
||||
|
||||
---
|
||||
|
||||
### `font.rs` — bitmap font
|
||||
|
||||
**`BitmapFont`** wraps an egui `TextureHandle` plus tile geometry (`tile_w`, `tile_h`, `img_w`, `img_h`). The texture is preprocessed at load time: the top-left pixel of the source PNG becomes the "background" sentinel — those pixels are stored as `TRANSPARENT`, all others as opaque `WHITE`. This lets egui's image tinting do all the colorization work at render time:
|
||||
@@ -289,6 +320,8 @@ A direct mapping from palette character → `(Glyph, Archetype)` means the map f
|
||||
|
||||
**Map save** — the editor can paint cells but has no way to write changes back to a `.toml` file. Saving will require serializing the `Board` back to `MapFile` format and writing it to disk.
|
||||
|
||||
**Terminal editor** — `kiln-tui` is play-only; it has no editing UI. The editor currently lives only in `kiln-egui`, which is out of the workspace. A future TUI editor (or reviving the egui one) is an open direction.
|
||||
|
||||
---
|
||||
|
||||
## Future considerations
|
||||
|
||||
@@ -23,28 +23,27 @@ When the user says **"finish the epic"**, do all of the following in order:
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
cargo build # compile
|
||||
cargo run # build and run (loads maps/start.toml)
|
||||
cargo test # run all tests
|
||||
cargo test <name> # run a single test by name (substring match)
|
||||
cargo clippy # lint
|
||||
cargo fmt # format
|
||||
cargo build # compile the workspace (kiln-core + kiln-tui)
|
||||
cargo run -p kiln-tui -- maps/start.toml # play a board in the terminal
|
||||
cargo test # run all tests
|
||||
cargo test <name> # run a single test by name (substring match)
|
||||
cargo clippy # lint
|
||||
cargo fmt # format
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The game is a single Rust binary using **eframe 0.33 / egui 0.33** for the GUI. eframe drives a retained-mode UI: the `App::update` method is called every frame and is responsible for both drawing and responding to input.
|
||||
`kiln` is a Cargo **workspace** that separates the engine from its front-ends so the same game data can be driven by different UIs:
|
||||
|
||||
`update` is structured in phases:
|
||||
- Input handling — arrow keys move the player (Play mode only)
|
||||
- `egui::TopBottomPanel::top` — menu bar (File → Exit, Play/Edit mode toggle)
|
||||
- `editor::show_editor_panel` — archetype palette panel (Edit mode only; declared before CentralPanel)
|
||||
- `egui::CentralPanel::default` — game viewport; rendered with `render::draw_board`; click-to-paint in Edit mode
|
||||
- `glyph_picker::show` — floating picker dialog (Edit mode only, when open)
|
||||
- **`kiln-core`** — the engine: all core game types (`Board`, `Glyph`, `Archetype`, `GameState`, …) plus `.toml` map-file load/save. No rendering or UI; every front-end depends on it.
|
||||
- **`kiln-tui`** — a terminal **player** (no editor yet) built on **ratatui 0.30 / crossterm**. Takes a map-file path on the command line and lets you walk the player around the board with the arrow keys.
|
||||
- **`kiln-egui`** — the original eframe/egui desktop app (player + editor). **Still in the tree but no longer a workspace member**: it was removed from the root `Cargo.toml` `members` and its files were left untouched, so it is not built at the root and is not guaranteed to compile against the current engine. Its notes below are retained for when it is revived.
|
||||
|
||||
### Modules
|
||||
Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`.
|
||||
|
||||
**`src/game.rs`** — all core game types:
|
||||
### kiln-core modules
|
||||
|
||||
**`kiln-core/src/game.rs`** — all core game types:
|
||||
- `Glyph` (`Copy`, `Eq`, `Hash`) — per-cell visual: `tile: u32` (tilesheet index), `fg/bg: Color32`. `Glyph::player()` is a `const fn`; all other glyphs come from the map file. Derives `Hash` so boards can deduplicate glyphs into a palette.
|
||||
- `FontSpec` — optional per-board bitmap font override: `path: String`, `tile_w: u32`, `tile_h: u32`. When `None`, the app default font is used.
|
||||
- `Behavior` — plain data struct of runtime behavioral properties: `passable: bool`, `opaque: bool`. Returned by `Archetype::behavior()`; new properties added here require no match arms elsewhere.
|
||||
@@ -56,7 +55,40 @@ The game is a single Rust binary using **eframe 0.33 / egui 0.33** for the GUI.
|
||||
- `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired
|
||||
- `GameState` — holds `board: Board`; `try_move(dx, dy)` checks passability before moving the player
|
||||
|
||||
**`src/font.rs`** — bitmap font loading and UV mapping:
|
||||
**`kiln-core/src/map_file.rs`** — map file loading:
|
||||
- `MapFile` and friends — serde `Deserialize` types for TOML map files
|
||||
- `TileIndex` — `#[serde(untagged)]` enum accepting either `Num(u32)` or `Chr(char)`; lets map files use `tile = " "` (char) or `tile = 32` (integer) interchangeably
|
||||
- `FontHeader` — deserializes the optional `[font]` section (`path`, `tile_w`, `tile_h`)
|
||||
- `impl From<MapFile> for Board` — converts a parsed file into a ready-to-use `Board`; unknown archetype names produce `ErrorBlock`
|
||||
- `pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>>` — reads and converts a `.toml` map file
|
||||
- `pub fn save(board: &Board, path: &Path) -> Result<(), Box<dyn std::error::Error>>` — serializes `Board` back to TOML and writes to disk
|
||||
|
||||
### kiln-tui modules
|
||||
|
||||
The terminal player. Renders the board as text: each `Glyph.tile` index is reinterpreted as a character (the default kiln font is CP437, where the tile index equals the code point) and drawn with the glyph's RGB colors. No bitmap font or UV mapping is involved.
|
||||
|
||||
**`kiln-tui/src/main.rs`** — entry point and play loop:
|
||||
- Parses a single positional arg (the map path); prints usage and exits non-zero if missing. Loads the board via `kiln_core::map_file::load` *before* touching the terminal so load errors print to a normal screen.
|
||||
- `ratatui::init()` → detect `TerminalCaps` → push Kitty flags when supported → `run` loop → pop Kitty flags → `ratatui::restore()`.
|
||||
- `run` blocks on `event::read()`; it acts on `Press` **and** `Repeat` key events (so holding a key moves continuously) and ignores `Release` (which the Kitty protocol also emits, and which would otherwise double-fire each move). Arrow keys or `hjkl` move the player via `GameState::try_move`; `q`/`Esc` quit.
|
||||
- `draw` wraps the board in a bordered `Block` titled with the board name and controls; `caps.summary_line()` is shown right-aligned in the bottom border.
|
||||
|
||||
**`kiln-tui/src/cp437.rs`** — CP437 → Unicode mapping:
|
||||
- `CP437: [char; 256]` — full code-page-437 table (graphic glyphs for 0x00–0x1F, box-drawing for 0xB0–0xDF, etc.).
|
||||
- `tile_to_char(tile: u32) -> char` — indexes the table for `tile < 256`; falls back to `char::from_u32` then a blank space. Unit-tested.
|
||||
|
||||
**`kiln-tui/src/render.rs`** — board → ratatui buffer:
|
||||
- `rgba8_to_color(Rgba8) -> Color::Rgb` — lossless RGB bridge (alpha dropped); truecolor terminals show exact colors, 256-color ones approximate.
|
||||
- `BoardWidget<'a>` — a `ratatui::widgets::Widget` that draws the board. Per axis, `axis(board_len, view, player) -> (offset, pad, count)`: a board smaller than the viewport is **centered** with screen padding, a larger one **scrolls** to keep the player on screen with no empty margins. Each cell resolves glyph priority player > object > grid floor, then writes char + fg/bg into the buffer.
|
||||
|
||||
**`kiln-tui/src/term.rs`** — terminal capability detection and Kitty setup:
|
||||
- `TerminalCaps { keyboard_enhancement: bool, truecolor: bool }` — detected once at startup. `keyboard_enhancement` is a real query/response (`supports_keyboard_enhancement()`); `truecolor` reads `$COLORTERM`. Intended to be handed to scripts so they can pick bindings the terminal actually supports.
|
||||
- `summary_line() -> Line` — styled one-line badge for the bottom border; when `truecolor`, the word "truecolor" is drawn with each letter a different rainbow color (`hue_to_rgb`, an HSV→RGB helper), else a plain "256-color".
|
||||
- `push_kitty_flags()` / `pop_kitty_flags()` — enable/disable the Kitty keyboard protocol (`DISAMBIGUATE_ESCAPE_CODES | REPORT_EVENT_TYPES | REPORT_ALTERNATE_KEYS | REPORT_ALL_KEYS_AS_ESCAPE_CODES`); the last flag makes modifier-only keypresses observable. Pushed only when supported, popped before restore. Enabling `REPORT_EVENT_TYPES` is why the input loop must filter out `Release` events.
|
||||
|
||||
### kiln-egui modules (no longer a workspace member)
|
||||
|
||||
**`kiln-egui/src/font.rs`** — bitmap font loading and UV mapping:
|
||||
- `BitmapFont` — wraps an egui `TextureHandle` (preprocessed to opaque-white / transparent) plus tile dimensions. The top-left pixel of the source PNG defines the "background" color; those pixels become `TRANSPARENT`, all others become `WHITE`. At render time, `painter.image(…, tile_uv, fg_color)` tints white pixels to `fg` and transparent pixels reveal the `bg` fill behind.
|
||||
- `BitmapFont::load(ctx, path, tile_w, tile_h)` — loads from disk
|
||||
- `BitmapFont::from_bytes(ctx, bytes, tile_w, tile_h, label)` — loads from embedded bytes
|
||||
@@ -65,20 +97,12 @@ The game is a single Rust binary using **eframe 0.33 / egui 0.33** for the GUI.
|
||||
- `BitmapFont::cell_size(zoom)` — pixel size (`Vec2`) of one rendered cell at the given integer zoom; centralizes the `tile_w * zoom` math used across rendering and hit-testing
|
||||
- `BitmapFont::tile_cols()`, `tile_count()`, `img_w()`, `img_h()` — geometry helpers used by the glyph picker and font dialog
|
||||
|
||||
**`src/font_dialog.rs`** — font picker dialog:
|
||||
**`kiln-egui/src/font_dialog.rs`** — font picker dialog:
|
||||
- `FontDialogState` — in-progress edit: `path: String`, `tile_w/tile_h: u32`, `preview: Option<BitmapFont>`
|
||||
- `FontDialogState::from_spec(spec)` — initializes from an existing `FontSpec` or defaults (8×16, empty path)
|
||||
- `show(ctx, open, state, font_spec)` — floating dialog with file browser (`rfd::FileDialog`), tile dimension `DragValue` controls, scrollable tilesheet preview with grid overlay, and Apply / Use default buttons. Uses a `should_close` flag to avoid the egui `.open()` borrow conflict.
|
||||
|
||||
**`src/map_file.rs`** — map file loading:
|
||||
- `MapFile` and friends — serde `Deserialize` types for TOML map files
|
||||
- `TileIndex` — `#[serde(untagged)]` enum accepting either `Num(u32)` or `Chr(char)`; lets map files use `tile = " "` (char) or `tile = 32` (integer) interchangeably
|
||||
- `FontHeader` — deserializes the optional `[font]` section (`path`, `tile_w`, `tile_h`)
|
||||
- `impl From<MapFile> for Board` — converts a parsed file into a ready-to-use `Board`; unknown archetype names produce `ErrorBlock`
|
||||
- `pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>>` — reads and converts a `.toml` map file
|
||||
- `pub fn save(board: &Board, path: &Path) -> Result<(), Box<dyn std::error::Error>>` — serializes `Board` back to TOML and writes to disk
|
||||
|
||||
**`src/main.rs`** — app entry point and frame loop:
|
||||
**`kiln-egui/src/main.rs`** — app entry point and frame loop:
|
||||
- `AppMode` enum (`Play` | `Edit`) — gates arrow-key input; toggles the side panel and viewport mode
|
||||
- `App` holds `GameState`, `AppMode`, `EditorState`, `default_font: BitmapFont`, and `board_font: Option<BitmapFont>`
|
||||
- `App::new(board, egui_ctx)` — loads `assets/vga-font-8x16.png` as the default font (falls back to `create_placeholder`); loads per-board font from `board.font` if present
|
||||
@@ -90,7 +114,7 @@ The game is a single Rust binary using **eframe 0.33 / egui 0.33** for the GUI.
|
||||
- **Play mode**: arrow keys move player; `render::board_origin` centers or player-tracks the viewport
|
||||
- **Edit mode**: `ScrollArea::both()` wraps the board; click-to-paint stamps `(editor.palette.glyph, editor.palette.selected)`; calls `editor::show_editor_panel` and `glyph_picker::show`
|
||||
|
||||
**`src/render.rs`** — cell rendering and drawing primitives:
|
||||
**`kiln-egui/src/render.rs`** — cell rendering and drawing primitives:
|
||||
- Window sizing constants (`DEFAULT_WINDOW_W = 840`, `DEFAULT_WINDOW_H = 524`, `MIN_WINDOW_W/H`); no fixed `CELL_W/H` — cell size comes from the active `BitmapFont`
|
||||
- `rgba8_to_color32(c)` / `color32_to_rgba8(c)` — inverse bridges between core `color::Rgba8` and egui `Color32`
|
||||
- `paint_glyph(painter, rect, glyph, font)` — `rect_filled` with `glyph.bg`, then `painter.image` tinted by `glyph.fg`
|
||||
@@ -101,7 +125,7 @@ The game is a single Rust binary using **eframe 0.33 / egui 0.33** for the GUI.
|
||||
- `board_origin(available, board_w, board_h, player, font, zoom)` — centers board or clamps to player with no empty space
|
||||
- `pos_to_cell(origin, pos, tile_w, tile_h) -> (i32, i32)` — pixel → cell coordinates; negatives signal out-of-bounds
|
||||
|
||||
**`src/editor.rs`** — editor state and side panel:
|
||||
**`kiln-egui/src/editor.rs`** — editor state and side panel:
|
||||
- `EditorTab` enum (`Palette` | `Objects` | `Board` | `Scripts` | `World`) — which tab is active
|
||||
- `EditorState` — `tab: EditorTab` plus four concern-grouped sub-structs so each tab takes only the borrow it needs:
|
||||
- `palette: PaletteState` — `selected: Archetype`, `glyph: Glyph`, `picker_open: bool` (selecting a new archetype resets `glyph` to its default)
|
||||
@@ -110,7 +134,7 @@ The game is a single Rust binary using **eframe 0.33 / egui 0.33** for the GUI.
|
||||
- `scripts: ScriptEdit` — `editing: Option<String>`, `content: String`, `new_name: String`
|
||||
- `show_editor_panel(ctx, editor, board, active_font)` — resizable right-side panel (default 200 px); renders the tab bar then dispatches to a per-tab function (`palette_tab`, `objects_tab`, `board_tab`, `scripts_tab`; World is empty), passing the relevant sub-struct(s). Palette shows the archetype list and glyph preview button; Board shows the font path, "Font…" button, and zoom slider; Objects lists the selected object's glyph preview, **Passable/Opaque checkboxes**, and script combobox; Scripts creates/edits named scripts
|
||||
|
||||
**`src/glyph_picker.rs`** — floating glyph picker dialog:
|
||||
**`kiln-egui/src/glyph_picker.rs`** — floating glyph picker dialog:
|
||||
- `show(ctx, open, glyph, board_cells, font)` — takes `open: &mut bool` and `glyph: &mut Glyph` directly; no dependency on `EditorState`
|
||||
- Three sections: board palette (unique glyphs deduped via `HashSet<Glyph>`, click to select), FG/BG color pickers, scrollable tile grid (all tiles in the active font at `tile_w × tile_h` per cell)
|
||||
|
||||
|
||||
Generated
+715
-3362
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,3 +1,3 @@
|
||||
[workspace]
|
||||
members = ["kiln-core", "kiln-egui"]
|
||||
members = ["kiln-core", "kiln-tui"]
|
||||
resolver = "2"
|
||||
|
||||
@@ -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