Files
kiln-ui/CLAUDE.md
T
randrews eabeaf1bf8 Extract kiln-ui: standalone game-agnostic ratatui widget toolkit
Widgets (CodeEditor, TextField, Dialog) plus clipboard/text internals,
extracted from the kiln workspace. No game-engine dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 23:42:48 -05:00

7.9 KiB

kiln-ui

Module reference for the kiln-ui crate — reusable, game-agnostic terminal UI widgets on ratatui. This is a standalone crate (its own git repo); it has no dependency on any game engine and is intended to be reused by any ratatui front-end (e.g. a text editor).

Code style

  • Add /// rustdoc comments to every pub type, field, and function.
  • Add inline // comments inside non-trivial function bodies to explain the why of each logical step.
  • Keep comments accurate: update them when the code they describe changes.

Overview

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 app/domain types. The front-end owns the event loop and routes input to the widget while it is focused. The syntect (highlighting) and arboard (clipboard) deps make this crate desktop/terminal-only (not WASM), unlike its ratatui core.

Consumers exchange ratatui types with these widgets (Dialog is a Widget, CursorOverlay uses Buffer/Rect/Position), so a consuming crate's ratatui must unify with this crate's (pinned 0.30.x).

src/lib.rs — crate root:

  • dim_area(area, buf) (pub) — dims every cell in area (background tint for a modal overlay). Shared scaffolding: used by [dialog] here and re-borrowable by a host's own overlay renderer so modal styles dim identically.
  • CursorOverlay (pub trait) + render_overlay(frame, &impl CursorOverlay) (pub) — a modal overlay paints into a &mut Buffer and returns Option<Position> (where the terminal caret goes) from render(area, buf), instead of placing the cursor itself. render_overlay is the one place that needs a Frame: it calls render over frame.area() then applies the returned position via set_cursor_position. The split exists because a ratatui Widget only sees a buffer (can't move the hardware cursor), and it makes render unit-testable headless. Implemented by Dialog, which also impl Widget for &Dialog (forwarding to CursorOverlay::render and discarding the caret) so it can be drawn with frame.render_widget where the cursor isn't needed.
  • Modules: pub mod dialog; pub mod code_editor; pub mod text_field; (widgets) plus mod clipboard; mod text; (pub(crate) shared internals — see below).

src/clipboard.rs / text.rspub(crate) shared internals for the text widgets:

  • clipboard::Clipboard — system clipboard via arboard when available, always backed by an internal buffer (so copy/paste work headless / on the web); set/get write-through and prefer-system-on-read. Used by both code_editor and text_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-width wide chars), within-line word boundaries, and the ctrl key rewrite, shared by both editors.

src/dialog.rs — modal dialog widgets:

  • Dialog<Ctx> — an open dialog generic over the host context its callback mutates. Built via Dialog::text(title, initial, on_done) (a single free-text field; on_done: FnOnce(Option<String>, &mut Ctx), None on cancel) or Dialog::list(title, items, allow_create, on_done) (a filter field over a selectable list; on_done: FnOnce(ListDialogResponse, &mut Ctx)). Holds a DialogBody (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 when allow_create), or cancel. DialogResult { Continue, Submit, Cancel } — what a key event did.
  • handle_event(&Event) -> DialogResultEscCancel; EnterSubmit only when OK is enabled (always for text; for a list, a valid entry is selected or allow_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 on Submit/Cancel, then call finish so the callback gets an un-borrowed &mut Ctx.
  • Dialog draws via its CursorOverlay impl (render(area, buf) -> Option<Position>; host renders it through render_overlay) — dims the screen, draws a centered bordered box. A text dialog centers its field box (h+v) with a distinct FIELD_BG background 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] Cancel footer (OK dimmed when disabled). render returns the active field's caret position (the host places the real terminal cursor). Unit-tested with a dummy Ctx.

src/text_field.rs — a single-line editable text field (a one-line sibling of code_editor, used by dialog's text/filter inputs):

  • TextFieldtext: String + a char-indexed cursor + a Clipboard + an optional max_length. new(initial) (unbounded) or sized(initial, max_length) (truncates the seed and caps typed input); value(), display_cursor() (caret's display column), set(text) (replace contents, truncated to max_length), and handle_key(KeyEvent) -> bool (true if consumed). Handles printable insert, Backspace/Delete, Left/Right (+ ctrl word-moves), Home/End, and ctrl/+v paste (newlines stripped); leaves Esc/Enter/Up/Down to the caller. Presentation is the caller's job (it reads value()/display_cursor()). No selection yet. Unit-tested.

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-indexed cursor/anchor (selection), desired_col (vertical-move column), scroll (top row + left display-col, cursor-following), undo/redo Snapshot stacks, a cached HighlightParts, and a Clipboard (system via arboard when 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 to ctrl up 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 (shift extends the selection), ctrl+Left/Right jump by word (text::{prev,next}_word, wrapping at line ends) and ctrl+Up/Down jump by paragraph (to the nearest blank/whitespace-only line, via is_blank_line), Home/End/PageUp/PageDown, ctrl+c/x/v (system clipboard), ctrl+a (select all), ctrl+z/y (undo/redo), EscExit. Bracketed Event::Paste inserts 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 via unicode-width) drives the cursor X, selection background, and horizontal scroll together. Highlighting: build_highlight_parts() parses the embedded resources/rhai.sublime-syntax into a one-syntax SyntaxSet + base16-ocean.dark theme (cached); draw runs syntect::easy::HighlightLines from line 0 to the viewport bottom each frame (small scripts), mapping syntect colors to Color::Rgb. Long lines clip with the cursor-following horizontal scroll (no soft-wrap). Mouse is out of scope for now. Note: highlighting is currently hardcoded to Rhai; making the syntax/theme pluggable is a known follow-up for non-Rhai hosts.
  • Unit-tested (pure logic, no TTY).