18 KiB
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::loadbefore touching the terminal so load errors print to a normal screen, thenGameState::from_world(world), wrapped inMode::Play. The world path is kept onUi::world_pathso the editor / play-reload can reload it. ratatui::init()→EnableMouseCapture→ detectTerminalCaps→ push Kitty flags when supported →runloop → pop Kitty flags →DisableMouseCapture→ratatui::restore().game.run_init()runs objectinit()hooks before the loop.runis 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 elapseddtviaUi::tick(dt, mode). Acts onPress/Repeatand ignoresRelease(the Kitty protocol emits these; acting on them double-fires moves). Input is routed bycurrent_input_mode:Menu→handle_menu_input; otherwise (unlessIgnore) it matches onMode—Play→handle_board_input/handle_scroll_input,Edit→handle_editor_input/handle_dialog_input. After ticking it callsapply_pending_modethenapply_playtest_transition, and exits onceshould_quitis set and no close animation is running.apply_pending_mode(mode, ui)— applies aPendingModea menu action requested: reloads the world fromworld_pathand swaps*modetoMode::Edit(EditorState::new(world))(enter editor) or a freshMode::Play(play world, re-runninginit()); 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 theGameStatethe editor parked inEditorState::pending_playtest(built from aWorld::deep_clone, so isolated) andstd::mem::replaces*modewithMode::Play(game), stashing the oldMode::EditintoUi::suspended_editor. Exit: whenUi::exit_playtestis set (the playtest pressedEsc), drops the playtest game and restores the suspended editor verbatim. ReusesMode::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 theEscbranch inhandle_board_input).draw(frame, mode, ui)— renders the active mode (draw_playoreditor::draw_editor), then the overlay layer in precedence animation > menu > (Play: scroll / Edit: dialog): anOverlay-layer animation viaAnimWidget, elseMenuWidget, else the play scroll overlay (ScrollOverlayWidget) or — viakiln_ui::render_overlay— the editor's glyph picker (when open) or dialog (bothCursorOverlays).draw_playwraps the board in a borderedBlock(board-name title; latest log previewed in the bottom border when the panel is closed).draw_board_areainitializes 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 theshould_quitpattern. A playtest also usesMode::Playbut is launched from the editor's sidebar (not aPendingMode): the parked editor lives inUi::suspended_editorsoEscrestores it (seeapply_playtest_transition).
kiln-tui/src/editor.rs — world editor mode:
EditorState— a non-ticking editing session over a freshly reloadedWorld: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 blinkingcursor, the drawing tool (current_archetype+current_glyph— the thing stamped onspace, defaulting to a wall — anddraw_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-sidelog, andpending_playtest: Option<GameState>(a playtest game parked for the run loop to swap intoMode::Play).MenuLevel(Main/World/Floor/Terrain/Machines/Pushers) — a sidebar menu level;title()(breadcrumb segment) andentries() -> Vec<MenuEntry>(the level's lines). AMenuEntryis a key-activatedItem([k] Label+ a boxedFnOnce(&mut EditorState)action), aCurrentValue(a boxedFn(&EditorState) -> Stringshown indented beneath an item, e.g. the world name), or aBlank/Separatorspacer. An item's action opens a dialog (World/scripts/boards), pushes a child level (Main→World/Floor/Terrain/Machines;Machines→Pushers), sets the drawing tool (Terrain/Pushers→set_current(arch), plusMachines→[s]CW /[c]CCW spinner), 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 overlayeditor_menu_items()),menu_key(c)(dispatch a letter → the matchedentries()item's action viaMenuLevel::perform_key),playtest()(deep-clones the world, sets its start to the edited board, expands script-backed archetypes in every board viaBoard::expand_builtin_archetypesso editor-stamped spinners/pushers run, builds aGameState+ runsinit(), and parks it inpending_playtestfor the run loop), and theopen_{name,entry,scripts,boards}_dialogbuilders (name → text dialog writingworld.name; entry → list dialog writingworld.start; boards → select-only; scripts → opens the script incode_editor).open_script_editor(name)builds aCodeEditorfromworld.scripts[name];close_script_editor()saves its text back intoworld.scripts(in-memory, like the other dialogs). - Drawing:
set_current(arch)setscurrent_archetypeand resetscurrent_glyphto its default;open_glyph_picker()seeds aGlyphDialogfromcurrent_glyphand writes the chosen glyph back (bound tog);place_current()stamps the current archetype+glyph at the cursor viaBoard::place_archetype;toggle_draw_mode()flipsdraw_mode.place_currentis bound tospaceandtoggle_draw_modetotabinhandle_editor_input;move_cursor/set_cursor_at_screenalso callplace_currentwhendraw_modeis 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, onSubmit/Cancel,takes it out and callsfinish(.., ed).handle_glyph_dialog_input(event, ed)— the same pattern for the openglyph_dialog.handle_code_editor_input(event, ed)— forwards to the open code editor and, onExit(Esc), callsclose_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 itsEsc-to-exit signal). Whileactive_animationisSomeall input is suppressed.tick(dt, mode)advances the active animation (and, on completion, clears scroll/menu state when returning toBoard), then ticks the active mode — the game only inPlay+Boardinput mode (so an open scroll/menu pauses it), the editor's cursor blink inEdit; a scroll that appears from a tick starts an open animation.open_menu(items)/begin_close_menu()— start the menu open/close animations (snapshottingMenuState).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 openui.menu→Menu; else fromMode(Edit→GlyphDialogwhen the glyph picker is open, elseDialogwhen a dialog is open, elseCodeEditorwhen a script editor is open, elseEditor;Play→Scrollwhen a scroll is active elseBoard).handle_board_input(arrows move the player viatry_move_with_transition,ltoggles log,Escopens thepause_menu_items()overlay — or, during a playtest, setsui.exit_playtestto return to the editor, PageUp/Down + mouse scroll/resize the log),handle_editor_input(arrows move the cursor,ltoggles log,gopens 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 viaresolve_choice),handle_menu_input(clone the matched item's actionRcto drop theui.menuborrow, then call it).try_move_with_transitioncaptures both boardRcs intoui.pending_transitionwhen a move crosses a portal.
kiln-tui/src/animation.rs — the animation abstraction:
Animationtrait —update(dt) -> bool(done?),draw(area, buf),layer(),input_mode_during()(defaultIgnore),input_mode_after().AnimationLayer { Board, Overlay }(board-area replacement vs. full-screen overlay).AnimWidget<'a>(&'a dyn Animation)— adapts an animation to a ratatuiWidgetforframe.render_widget.
kiln-tui/src/transition.rs — board-change wipe:
TransitionAnimation— pre-renders the old and new boards into off-screenBuffers, then reveals cells in an LFSR-driven pseudo-random order overTRANSITION_DURATION(0.25 s), blending old/new per cell indraw. ABoard-layer animation that ends inInputMode::Board.lfsr_step/lfsr_sequencebuild the maximal-period permutation (16-bit Galois LFSR, poly0xB400).
kiln-tui/src/overlay.rs — shared overlay window frame:
OverlayStyle { bg, border_fg, width_div, height_fraction }andrender_overlay_frame(style, progress, area, buf) -> Option<(content_rect, scrollbar_rect)>— dims the background (viakiln_ui::dim_area), sizes/centers an animated-height window (kept even), draws the bordered block, and returns the content + scrollbar rects. Shared bymenu.rsandscroll_overlay.rs. (The dialog widgets in kiln-ui draw their own smaller box and only reusedim_area.)
kiln-tui/src/menu.rs — in-game menu overlay:
MenuItem { key: MenuKey, label, action: Rc<dyn Fn(&mut Ui)> }(ActionRc) andMenuKey { Char(char), Esc }— menu actions only touchUi(signal close/quit/pending_mode), so the same shape works for play and editor menus.MenuState { items, offset, max_scroll },MenuWidget(fully-openStatefulWidget), andMenuOpenAnimation/MenuCloseAnimation(share theAnimationpipeline +render_overlay_frame, blue style).render_menu_at(..)draws one[key] labelline 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>—StatefulWidgetfor the fully-open overlay (½ width × ¾ height, amber style);ScrollOpenAnimation/ScrollCloseAnimationimplementAnimationfor 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>— aratatui::widgets::Widgetthat 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 callsBoard::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 inversescreen_to_board(area, board, sx, sy) -> Option<(usize, usize)>(pub) — convert between board cells and terminal coordinates using the sameaxismath, returningNoneoff-board. Used by the editor's blinking cursor + right-click hit-testing (and mirrored byspeech::board_screen_pos).
kiln-tui/src/speech.rs — speech bubble widget:
SpeechBubblesWidget<'a>— aratatui::widgets::Widgetthat draws speech bubbles for all activeSpeechBubbles 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()+Paragraphfor 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 toplacedon success.board_screen_pos(area, board, bx, by) -> Option<(u16, u16)>— converts a board cell coordinate to terminal screen coordinates, returningNoneif off-screen. Used by bubble placement.
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>—StatefulWidgetthat renders all log lines newest-first in a bordered block with word-wrap.logline_to_line(line) -> Line— converts a coreLogLineinto a styled ratatuiLine; eachLogSpan'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 twoRects 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_enhancementis a real query/response (supports_keyboard_enhancement());truecolorreads$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; whentruecolor, 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. EnablingREPORT_EVENT_TYPESis why the input loop must filter outReleaseevents.