9.8 KiB
kiln-ui
Module reference for the kiln-ui crate — reusable terminal UI widgets on
ratatui, mostly game-agnostic. See the repository-root CLAUDE.md for
project-wide guidance, code style, and key design decisions.
A widget owns its presentation + input handling and is generic over a host context Ctx it mutates only through callbacks, so a front-end decides what a choice does without the widget knowing about game/world types. The front-end owns the event loop and routes input to the widget while it is focused. The lone exception is glyph_dialog, which depends on kiln-core for Glyph/Rgba8 and cp437::tile_to_char (the accepted, documented first kiln-core dependency — it would matter only if kiln-ui were ever split into a standalone crate). The syntect (highlighting) and arboard (clipboard) deps make this crate desktop/terminal-only (not WASM), unlike its ratatui core.
kiln-ui/src/lib.rs — crate root:
dim_area(area, buf)(pub) — dims every cell inarea(background tint for a modal overlay). Shared scaffolding: used by [dialog] here and re-borrowed by kiln-tui'soverlay::render_overlay_frameso both modal styles dim identically.CursorOverlay(pubtrait) +render_overlay(frame, &impl CursorOverlay)(pub) — a modal overlay paints into a&mut Bufferand returnsOption<Position>(where the terminal caret goes) fromrender(area, buf), instead of placing the cursor itself.render_overlayis the one place that needs aFrame: it callsrenderoverframe.area()then applies the returned position viaset_cursor_position. The split exists because a ratatuiWidgetonly sees a buffer (can't move the hardware cursor), and it makesrenderunit-testable headless. Implemented byDialogandGlyphDialog, which alsoimpl Widget for &Dialog/&GlyphDialog(forwarding toCursorOverlay::renderand discarding the caret) so they can be drawn withframe.render_widgetwhere the cursor isn't needed.- Modules:
pub mod dialog; pub mod code_editor; pub mod text_field; pub mod glyph_dialog;(widgets) plusmod clipboard; mod text;(pub(crate)shared internals — see below).
kiln-ui/src/clipboard.rs / text.rs — pub(crate) shared internals for the text widgets:
clipboard::Clipboard— system clipboard viaarboardwhen available, always backed by an internal buffer (so copy/paste work headless / on the web);set/getwrite-through and prefer-system-on-read. Used by bothcode_editorandtext_field.text::{byte_of, char_cells, display_width, next_word, prev_word, super_to_ctrl, TAB_WIDTH}— char↔byte index, display-width (tab stops +unicode-widthwide chars), within-line word boundaries, and the⌘→ctrlkey rewrite, shared by both editors.
kiln-ui/src/dialog.rs — modal dialog widgets:
Dialog<Ctx>— an open dialog generic over the host context its callback mutates. Built viaDialog::text(title, initial, on_done)(a single free-text field;on_done: FnOnce(Option<String>, &mut Ctx),Noneon cancel) orDialog::list(title, items, allow_create, on_done)(a filter field over a selectable list;on_done: FnOnce(ListDialogResponse, &mut Ctx)). Holds aDialogBody(Text/List) plus the boxed callback. Each field is a [TextField].ListDialogResponse { Select(String), Create(String), Cancel }— list outcome: an existing entry, a newly-typed value (only whenallow_create), or cancel.DialogResult { Continue, Submit, Cancel }— what a key event did.handle_event(&Event) -> DialogResult—Esc→Cancel;Enter→Submitonly when OK is enabled (always for text; for a list, a valid entry is selected orallow_create+ non-empty); Up/Down move a list's selection; other keys edit the field (and re-clamp the list selection to the new prefix filter).finish(result, &mut Ctx)consumes the dialog and fires its callback. The host pattern:take()the dialog out of its slot onSubmit/Cancel, then callfinishso the callback gets an un-borrowed&mut Ctx.Dialogdraws via itsCursorOverlayimpl (render(area, buf) -> Option<Position>; host renders it throughrender_overlay) — dims the screen, draws a centered bordered box. A text dialog centers its field box (h+v) with a distinctFIELD_BGbackground so it reads as an input box; a list dialog puts the filter field atop the scrolling, selection-highlighted list. Both show an[enter] OK [esc] Cancelfooter (OK dimmed when disabled).renderreturns the active field's caret position (the host places the real terminal cursor). Unit-tested with a dummyCtx.
kiln-ui/src/glyph_dialog.rs — the glyph picker (the one kiln-core-dependent widget):
GlyphDialog<Ctx>— a modal picker for a [Glyph] (CP437 tile index + fg/bg colors), same API shape asDialog. Built viaGlyphDialog::new(title, initial: Glyph, on_done);on_done: FnOnce(Option<Glyph>, &mut Ctx)firesSome(glyph)on OK,Noneon cancel. Holds the selectedtile: u32, twoColorPickers (fg/bg), and aFocus(Grid/FgStrip/FgField/BgStrip/BgField).ColorPicker(private) — one color selector: aselectedslot index over a strip of the 16kiln_core::colors::NAMED_COLORSswatches plus a final custom slot (CUSTOM == NAMED_COLORS.len()), and a 6-hexTextFieldthat is the source of truth (color()parses it). Picking a named swatch fills the field viaTextField::set; the custom slot leaves it user-editable. Seeds from anRgba8by matching a named color (else the custom slot).handle_event(&Event) -> DialogResult(reusesdialog::DialogResult) —Esc→Cancel;Enter→Submitonly when both colors resolve;Tab/Shift-Tab/BackTabcycle focus (skipping a color's*Fieldstop unless it's on the custom slot). On the grid, arrows move the 32×8 selection (clamped); on a strip, Left/Right pick a swatch and Down enters the custom field; in a custom field, keys edit it and Up returns to the strip.finish(result, &mut Ctx)fires the callback (aSubmitwith both colors valid yieldsSome(Glyph{..})).- Drawing is its
CursorOverlayimpl (render(area, buf) -> Option<Position>; host renders it throughrender_overlay) — dims + centered bordered box; renders the 256-char grid viakiln_core::cp437::tile_to_char(chosen fg/bg when both colors resolve, else gray-on-black; selected cell is a bright cursor when the grid is focused, else reversed), then for each color a swatch strip (one solid cell per named color + the custom slot; selected slot marked●, custom slot otherwise+) above a hex row (an editable#RRGGBBbox, red when invalid, when on the custom slot; else the dimmed name + hex of the selected named color), and the footer (which previews the chosen glyph). Returns the focused custom field's caret position (orNone). Unit-tested with a dummyCtx.
kiln-ui/src/text_field.rs — a single-line editable text field (a one-line sibling of code_editor, used by dialog's text/filter inputs):
TextField—text: String+ a char-indexedcursor+ aClipboard+ an optionalmax_length.new(initial)(unbounded) orsized(initial, max_length)(truncates the seed and caps typed input);value(),display_cursor()(caret's display column),set(text)(replace contents, truncated tomax_length), andhandle_key(KeyEvent) -> bool(trueif consumed). Handles printable insert, Backspace/Delete, Left/Right (+ ctrl word-moves), Home/End, andctrl/⌘+vpaste (newlines stripped); leaves Esc/Enter/Up/Down to the caller. Presentation is the caller's job (it readsvalue()/display_cursor()). No selection yet. Unit-tested.
kiln-ui/src/code_editor.rs — a hand-written modeless, keyboard-only code editor (hand-written; edtui was rejected for panicking on bare Shift and lacking line-wrap):
CodeEditor— edits one script's text:lines: Vec<String>, a char-indexedcursor/anchor(selection),desired_col(vertical-move column),scroll(top row + left display-col, cursor-following), undo/redoSnapshotstacks, a cachedHighlightParts, and aClipboard(system viaarboardwhen available, always backed by an internal buffer so copy/paste work headless / on the web). Public API:new(name, text),name(),text()(lines joined with\n),handle_event(&Event) -> CodeEditorOutcome { Continue, Exit },draw(&mut self, frame, area).- Input (
handle_event):⌘is mapped toctrlup front; then printable keys insert, Enter/Backspace/Delete edit (Backspace/Delete at a line edge join lines, Left/Right wrap across line ends), arrows move (shiftextends the selection),ctrl+Left/Right jump by word (text::{prev,next}_word, wrapping at line ends) andctrl+Up/Down jump by paragraph (to the nearest blank/whitespace-only line, viais_blank_line), Home/End/PageUp/PageDown,ctrl+c/x/v(system clipboard),ctrl+a(select all),ctrl+z/y(undo/redo),Esc→Exit. BracketedEvent::Pasteinserts text. Unknown keycodes are ignored (the whole point — no panic on Shift/F-keys). Undo coalesces a run of typing (or deletes) into one step; movement/structural edits break the run. - Rendering (
draw): a titled border, a line-number gutter, and per-line styled text. One width model (char_cells: tabs → next 4-stop, wide chars viaunicode-width) drives the cursor X, selection background, and horizontal scroll together. Highlighting:build_highlight_parts()parses the embeddedresources/rhai.sublime-syntaxinto a one-syntaxSyntaxSet+base16-ocean.darktheme (cached);drawrunssyntect::easy::HighlightLinesfrom line 0 to the viewport bottom each frame (small scripts), mapping syntect colors toColor::Rgb. Long lines clip with the cursor-following horizontal scroll (no soft-wrap). Mouse is out of scope for now. - Unit-tested (pure logic, no TTY).