Module reference for the `kiln-tui` crate — a terminal **player + world
editor** built on **ratatui 0.30 / crossterm**, depending on both `kiln-core`
and `kiln-ui`. See the repository-root `CLAUDE.md` for project-wide guidance,
code style, the world file format, and key design decisions.
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. The app is one of two top-level [`Mode`]s — `Play` (live, ticking game) or `Edit` (a freshly reloaded, non-ticking world) — and the active mode plus a `Ui` value are threaded through the loop.
**Drawing rule: prefer ratatui primitives.** Before writing manual cell-by-cell buffer code, check whether a `Block`, `Paragraph`, `Layout`, `Line::centered()`, or other ratatui widget/method achieves the same result. If a small design change (e.g. fixed vs. content-driven sizing) would unlock a ratatui-native approach, raise it as a question rather than writing manual code. The documented exceptions are: the background dimming pass (`kiln_ui::dim_area`, used by `render_overlay_frame` and the dialog widgets — no ratatui primitive for "tint the whole buffer"); the tail characters in `SpeechBubblesWidget` (`speech.rs`): the tail-join `┬` on the bottom border and the `│` column drawn below the box down to the source object; and the blinking editor cursor + LFSR wipe blend.
**`kiln-tui/src/main.rs`** — entry point, real-time loop, and frame dispatch:
- Parses a single positional arg (the world file path); prints usage and exits non-zero if missing. Loads the world via `kiln_core::world::load`*before* touching the terminal so load errors print to a normal screen, then `GameState::from_world(world)`, wrapped in `Mode::Play`. The world path is kept on `Ui::world_path` so the editor / play-reload can reload it.
-`ratatui::init()` → `EnableMouseCapture` → detect `TerminalCaps` → push Kitty flags when supported → `run` loop → pop Kitty flags → `DisableMouseCapture` → `ratatui::restore()`. `game.run_init()` runs object `init()` hooks before the loop.
-`run` is a ~30 FPS real-time loop: `event::poll`s only until the next frame deadline (so input stays responsive) and otherwise wakes to advance by the real elapsed `dt` via `Ui::tick(dt, mode)`. Acts on `Press`/`Repeat` and ignores `Release` (the Kitty protocol emits these; acting on them double-fires moves). Input is routed by `current_input_mode`: `Menu`→`handle_menu_input`; otherwise (unless `Ignore`) it matches on `Mode` — `Play` → `handle_board_input`/`handle_scroll_input`, `Edit` → `handle_editor_input`/`handle_dialog_input`. After ticking it calls `apply_pending_mode` then `apply_playtest_transition`, and exits once `should_quit` is set and no close animation is running.
-`apply_pending_mode(mode, ui)` — applies a `PendingMode` a menu action requested: reloads the world from `world_path` and swaps `*mode` to `Mode::Edit(EditorState::new(world))` (enter editor) or a fresh `Mode::Play` (play world, re-running `init()`); a reload failure is logged to the play game and leaves the mode unchanged.
-`apply_playtest_transition(mode, ui)` — enters/leaves a **playtest** (the editor's `[p] Play board`). Enter: takes the `GameState` the editor parked in `EditorState::pending_playtest` (built from a `World::deep_clone`, so isolated) and `std::mem::replace`s `*mode` with `Mode::Play(game)`, stashing the old `Mode::Edit` into `Ui::suspended_editor`. Exit: when `Ui::exit_playtest` is set (the playtest pressed `Esc`), drops the playtest game and restores the suspended editor verbatim. Reuses `Mode::Play`, so playtest draw/tick/input are the normal play paths; `suspended_editor.is_some()` is the "is playtest" flag (drives the `(playtest)` title cue and the `Esc` branch in `handle_board_input`).
-`draw(frame, mode, ui)` — renders the active mode (`draw_play` or `editor::draw_editor`), then the overlay layer in precedence **animation > menu > (Play: scroll / Edit: dialog)**: an `Overlay`-layer animation via `AnimWidget`, else `MenuWidget`, else the play scroll overlay (`ScrollOverlayWidget`) or — via `kiln_ui::render_overlay` — the editor's glyph picker (when open) or dialog (both `CursorOverlay`s). `draw_play` first carves a fixed-width (`STATUS_SIDEBAR_WIDTH = 14`) status sidebar (`StatusSidebarWidget`) off the left edge when `ui.show_status` (default on, toggled by `i`), then wraps the board in a bordered `Block` (board-name title; latest log previewed in the bottom border when the panel is closed) over the remaining `content_area`. The sidebar is play-only (the editor never calls `draw_play`); it also shows during a playtest. `draw_board_area` initializes a pending portal transition (now that the inner area is known) and renders either the board-layer wipe animation or the live board + speech bubbles.
-`Mode { Play(GameState), Edit(EditorState) }` — exactly one is live at a time (play and edit are mutually exclusive: entering the editor drops the running game; returning to play rebuilds it fresh from the world file). `PendingMode { EnterEditor, PlayWorld }` — a deferred Play↔Edit switch requested by a menu closure (which only has `&mut Ui`) and applied by the run loop, mirroring the `should_quit` pattern. A **playtest** also uses `Mode::Play` but is launched from the editor's sidebar (not a `PendingMode`): the parked editor lives in `Ui::suspended_editor` so `Esc` restores it (see `apply_playtest_transition`).
**`kiln-tui/src/editor.rs`** — world editor mode:
-`EditorState` — a non-ticking editing session over a freshly reloaded `World`: `world`, `board_name` (board being edited), `menu: Vec<MenuLevel>` (sidebar menu stack, for breadcrumb + escape-to-parent), `dialog: Option<Dialog<EditorState>>`, `code_editor: Option<CodeEditor>` (the open script editor; replaces the board view), `glyph_dialog: Option<GlyphDialog<EditorState>>` (the open glyph picker overlay), a blinking `cursor`, the **drawing tool** (`current_archetype` + `current_glyph` — the thing stamped on `space`, defaulting to a wall — and `draw_mode`, the toggle that re-stamps on every cursor move), `sidebar_width` (mouse-resizable), blink state, `last_board_area` (written at draw, read for right-click hit-testing), editor-side `log`, and `pending_playtest: Option<GameState>` (a playtest game parked for the run loop to swap into `Mode::Play`).
-`MenuLevel` (`Main`/`World`/`Floor`/`Terrain`/`Machines`/`Pushers`/`Transporters`/`Items`) — a sidebar menu level; (`Main` → `[i] Items` → `[t] ♦ Gem` / `[h] ♥ Heart`, which call `set_current(Archetype::Builtin(Builtin::Gem, "gem"))` / `set_current(Archetype::Builtin(Builtin::Heart, "heart"))` respectively); `title()` (breadcrumb segment) and `entries() -> Vec<MenuEntry>` (the level's lines). A `MenuEntry` is a key-activated `Item` (`[k] Label` + a boxed `FnOnce(&mut EditorState)` action), a `CurrentValue` (a boxed `Fn(&EditorState) -> String` shown indented beneath an item, e.g. the world name), or a `Blank`/`Separator` spacer. An item's action **opens a dialog** (`World`/scripts/boards), **pushes a child level** (`Main` → `World`/`Floor`/`Terrain`/`Machines`; `Machines` → `Pushers`/`Transporters`), **sets the drawing tool** (`Terrain`/`Pushers`/`Transporters` → `set_current(arch)`, plus `Machines` → `[s]` CW / `[c]` CCW spinner, all using `Archetype::Builtin(Builtin::…, "alias")`), or **launches a playtest** (`Main` → `[p] Play board` → `playtest()`). The menu is a static letter-keyed stack — picking from a list is always a dialog, so arrow keys never conflict with the board cursor.
- Methods: `breadcrumb()`, `current_menu()`, `menu_escape(ui)` (pop a level, or at the root open the overlay `editor_menu_items()`), `menu_key(c)` (dispatch a letter → the matched `entries()` item's action via `MenuLevel::perform_key`), `playtest()` (deep-clones the world, sets its start to the edited board, **expands script-backed archetypes** in every board via `Board::expand_builtin_archetypes` so editor-stamped spinners/pushers run, builds a `GameState` + runs `init()`, and parks it in `pending_playtest` for the run loop), and the `open_{name,entry,scripts,boards}_dialog` builders (name → text dialog writing `world.name`; entry → list dialog writing `world.start`; boards → select-only; scripts → **opens the script in `code_editor`**). `open_script_editor(name)` builds a `CodeEditor` from `world.scripts[name]`; `close_script_editor()` saves its text back into `world.scripts` (in-memory, like the other dialogs).
- Drawing: `set_current(arch)` sets `current_archetype` and resets `current_glyph` to its default; `open_glyph_picker()` seeds a `GlyphDialog` from `current_glyph` and writes the chosen glyph back (bound to **`g`**); `place_current()` stamps the current archetype+glyph at the cursor via `Board::place_archetype`; `toggle_draw_mode()` flips `draw_mode`. `place_current` is bound to **`space`** and `toggle_draw_mode` to **`tab`** in `handle_editor_input`; `move_cursor`/`set_cursor_at_screen` also call `place_current` when `draw_mode` is on and the cursor enters a new cell. The sidebar's bottom **drawing-controls footer** (`draw_footer_lines`) shows the archetype name, `[g] Glyph` + a live colored preview, `[spc] Place`, and `[tab] Draw mode (on/off)`.
-`editor_menu_items()` — the overlay editor menu (`[p]` play, `[q]` quit, `[esc]` resume), opened at the root of the menu tree. `handle_dialog_input(event, ed)` — forwards to the open dialog and, on `Submit`/`Cancel`, `take`s it out and calls `finish(.., ed)`. `handle_glyph_dialog_input(event, ed)` — the same pattern for the open `glyph_dialog`. `handle_code_editor_input(event, ed)` — forwards to the open code editor and, on `Exit` (Esc), calls `close_script_editor()`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — the **code editor (when open) else** the board (with blinking cursor + coord readout) in the left area, a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel.
**`kiln-tui/src/ui.rs`** — front-end presentation state (not on `GameState`):
-`Ui { log: LogState, overlay: ScrollOverlayState, pending_transition, active_animation: Option<Box<dyn Animation>>, menu: Option<MenuState>, should_quit, world_path, pending_mode, suspended_editor: Option<EditorState>, exit_playtest: bool }` (the last two drive the editor playtest: the parked editor and its `Esc`-to-exit signal). While `active_animation` is `Some` all input is suppressed. `tick(dt, mode)` advances the active animation (and, on completion, clears scroll/menu state when returning to `Board`), then ticks the active mode — the game only in `Play` + `Board` input mode (so an open scroll/menu pauses it), the editor's cursor blink in `Edit`; a scroll that appears from a tick starts an open animation.
-`open_menu(items)` / `begin_close_menu()` — start the menu open/close animations (snapshotting `MenuState`). `begin_close(choice, game)` — set the player's scroll choice and start the scroll close animation. `start_transition(old, new, area)` — build the board-wipe once the inner area is known. `scroll_lines(delta)` / `scroll_menu(delta)` — clamped overlay/menu scrolling.
-`InputMode { Board, Scroll, Menu, Editor, Dialog, CodeEditor, GlyphDialog, Ignore, Frozen }`. `current_input_mode(mode, ui)` — derives the mode: an active animation declares its own (`input_mode_during`); a pending transition → `Ignore`; an open `ui.menu` → `Menu`; else from `Mode` (`Edit` → `GlyphDialog` when the glyph picker is open, else `Dialog` when a dialog is open, else `CodeEditor` when a script editor is open, else `Editor`; `Play` → `Scroll` when a scroll is active else `Board`).
-`handle_board_input` (arrows move the player via `try_move_with_transition`, `l` toggles log, `i` toggles the status sidebar (`ui.show_status`), `Esc` opens the `pause_menu_items()` overlay — or, during a playtest, sets `ui.exit_playtest` to return to the editor, PageUp/Down + mouse scroll/resize the log), `handle_editor_input` (arrows move the cursor, `l` toggles log, `g` opens the glyph picker, `Esc` → `menu_escape`, other letters → `menu_key`, right-click moves the cursor, drag resizes the sidebar), `handle_scroll_input` (scroll nav + choice letters via `resolve_choice`), `handle_menu_input` (clone the matched item's action `Rc` to drop the `ui.menu` borrow, then call it). `try_move_with_transition` captures both board `Rc`s into `ui.pending_transition` when a move crosses a portal.
-`TransitionAnimation` — pre-renders the old and new boards into off-screen `Buffer`s, then reveals cells in an LFSR-driven pseudo-random order over `TRANSITION_DURATION` (0.25 s), blending old/new per cell in `draw`. A `Board`-layer animation that ends in `InputMode::Board`. `lfsr_step`/`lfsr_sequence` build the maximal-period permutation (16-bit Galois LFSR, poly `0xB400`).
-`OverlayStyle { bg, border_fg, width_div, height_fraction }` and `render_overlay_frame(style, progress, area, buf) -> Option<(content_rect, scrollbar_rect)>` — dims the background (via `kiln_ui::dim_area`), sizes/centers an animated-height window (kept even), draws the bordered block, and returns the content + scrollbar rects. Shared by `menu.rs` and `scroll_overlay.rs`. (The dialog widgets in kiln-ui draw their own smaller box and only reuse `dim_area`.)
**`kiln-tui/src/menu.rs`** — in-game menu overlay:
-`MenuItem { key: MenuKey, label, action: Rc<dyn Fn(&mut Ui)> }` (`ActionRc`) and `MenuKey { Char(char), Esc }` — menu actions only touch `Ui` (signal close/quit/`pending_mode`), so the same shape works for play and editor menus. `MenuState { items, offset, max_scroll }`, `MenuWidget` (fully-open `StatefulWidget`), and `MenuOpenAnimation`/`MenuCloseAnimation` (share the `Animation` pipeline + `render_overlay_frame`, blue style). `render_menu_at(..)` draws one `[key] label` line per item. `pause_menu_items()` — the play-mode pause menu (`[e]` edit, `[q]` quit, `[esc]` close).
-`ScrollOverlayState { offset, max_scroll }` — input/output state threaded through render. `ScrollOverlayWidget<'a>` — `StatefulWidget` for the fully-open overlay (½ width × ¾ height, amber style); `ScrollOpenAnimation`/`ScrollCloseAnimation` implement `Animation` for the open/close transitions. `render_scroll_at(..)` wraps text, centers whitespace-leading lines, prefixes choices with `[a]`/`[b]`, and draws a scrollbar when content overflows.
-`BoardWidget<'a>` — a `ratatui::widgets::Widget` that draws the board. Per axis, `axis(board_len, view, player) -> (offset, pad, count)` (pub): 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 calls `Board::glyph_at`, which already folds in the player glyph at the player's position — no separate player-overlay pass needed.
-`board_to_screen(area, board, bx, by) -> Option<(u16, u16)>` and its inverse `screen_to_board(area, board, sx, sy) -> Option<(usize, usize)>` (pub) — convert between board cells and terminal coordinates using the same `axis` math, returning `None` off-board. Used by the editor's blinking cursor + right-click hit-testing (and mirrored by `speech::board_screen_pos`).
-`SpeechBubblesWidget<'a>` — a `ratatui::widgets::Widget` that draws speech bubbles for all active `SpeechBubble`s over the board. Two-pass: first collects all placements (sorted bottom-first so upward-shift decisions don't interfere with higher bubbles), then renders in **reverse** order (top-most first) so lower bubbles' boxes naturally cover extended tails without an explicit occlusion check.
- Each bubble: `Block::bordered()` + `Paragraph` for the box; the tail-join `┬` on the bottom border and a column of `│` chars from below the box down to the source object are written manually. Bubbles that would overlap are shifted upward one row at a time (up to 20 attempts) until clear.
-`place_bubble(area, obj_sx, obj_sy, lines, placed) -> Option<Rect>` — sizes and places one bubble, appending its rect to `placed` on success.
-`board_screen_pos(area, board, bx, by) -> Option<(u16, u16)>` — converts a board cell coordinate to terminal screen coordinates, returning `None` if off-screen. Used by bubble placement.
-`StatusSidebarWidget(pub Player)` — a `ratatui::widgets::Widget` wrapping a `kiln_core::player::Player` snapshot that draws the player's stats in a bordered `Block` titled `Status`: a `Health:` label above a row of `player.max_health``♥` glyphs (first `player.health` bright red, rest dark red), a `Gems: ♦ N` line, and a `Keys:` row of 8 colored key glyphs (colored when held, near-black when absent, via `player.keys.colors()`). The gem indicator is rendered from `Archetype::Builtin(Builtin::Gem, "gem").default_glyph()` (char via `cp437::tile_to_char`, color via `rgba8_to_color`) so it matches gems on the board — not a hardcoded glyph/color. Purely presentational — reads the values handed in, never mutates game state. Drawn by `draw_play` when `ui.show_status` is set; `Tab` toggles it.
-`LogState { open, height, scroll }` — panel visibility, height in rows, and scroll offset. `toggle()`, `scroll_by(delta, log_len)`, `resize(target, total)`.
-`LogWidget<'a>` — `StatefulWidget` that renders all log lines newest-first in a bordered block with word-wrap.
-`logline_to_line(line) -> Line` — converts a core `LogLine` into a styled ratatui `Line`; each `LogSpan`'s optional fg/bg is applied only when present.
-`log_preview_line(logs) -> Line` — builds the `[l]og: <latest>` preview span for the board's bottom border.
-`rects_overlap(a, b) -> bool` — true if two `Rect`s share at least one cell; used by bubble placement.
**`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.