Files
kiln/kiln-tui/CLAUDE.md
T
2026-06-23 01:08:01 -05:00

19 KiB
Raw Blame History

kiln-tui

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 → DisableMouseCaptureratatui::restore(). game.run_init() runs object init() hooks before the loop.
  • run is a ~30 FPS real-time loop: event::polls 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: Menuhandle_menu_input; otherwise (unless Ignore) it matches on ModePlayhandle_board_input/handle_scroll_input, Edithandle_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::replaces *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 CursorOverlays). 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.

kiln-tui/src/mode.rs — top-level application mode:

  • 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/Items) — a sidebar menu level; (Main[i] Items[t] ♦ Gem, which set_current(Archetype::Builtin(Builtin::Gem, "gem"))); 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 (MainWorld/Floor/Terrain/Machines; MachinesPushers), sets the drawing tool (Terrain/Pushersset_current(arch), plus Machines[s] CW / [c] CCW spinner, all using Archetype::Builtin(Builtin::…, "alias")), or launches a playtest (Main[p] Play boardplaytest()). 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, takes 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.

kiln-tui/src/input.rs — input mode + dispatch:

  • 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.menuMenu; else from Mode (EditGlyphDialog when the glyph picker is open, else Dialog when a dialog is open, else CodeEditor when a script editor is open, else Editor; PlayScroll 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, Escmenu_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 Rcs into ui.pending_transition when a move crosses a portal.

kiln-tui/src/animation.rs — the animation abstraction:

  • Animation trait — update(dt) -> bool (done?), draw(area, buf), layer(), input_mode_during() (default Ignore), input_mode_after(). AnimationLayer { Board, Overlay } (board-area replacement vs. full-screen overlay). AnimWidget<'a>(&'a dyn Animation) — adapts an animation to a ratatui Widget for frame.render_widget.

kiln-tui/src/transition.rs — board-change wipe:

  • TransitionAnimation — pre-renders the old and new boards into off-screen Buffers, 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).

kiln-tui/src/overlay.rs — shared overlay window frame:

  • 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).

kiln-tui/src/scroll_overlay.rs — scroll overlay widget:

  • 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.

kiln-tui/src/render.rs — board → ratatui buffer:

  • 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).

kiln-tui/src/speech.rs — speech bubble widget:

  • SpeechBubblesWidget<'a> — a ratatui::widgets::Widget that draws speech bubbles for all active SpeechBubbles 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.

kiln-tui/src/status.rs — play-mode status sidebar widget:

  • StatusSidebarWidget — a ratatui::widgets::Widget built with new(health, gems) that draws the player's stats in a bordered Block titled Status: a Health: label above a row of MAX_HEARTS (5) glyphs (first health bright red, rest dark red) and a Gems: ♦ N line. 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.

kiln-tui/src/log.rs — log panel widget:

  • 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.

kiln-tui/src/utils.rs — shared rendering helpers:

  • rgba8_to_color(Rgba8) -> Color — lossless RGB bridge (alpha dropped).
  • rects_overlap(a, b) -> bool — true if two Rects 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.