Files
kiln/kiln-ui/CLAUDE.md
T

38 lines
9.8 KiB
Markdown
Raw Normal View History

2026-06-21 11:54:19 -05:00
# 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 in `area` (background tint for a modal overlay). Shared scaffolding: used by [`dialog`] here and re-borrowed by kiln-tui's `overlay::render_overlay_frame` so both 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` and `GlyphDialog`, which *also* `impl Widget for &Dialog`/`&GlyphDialog` (forwarding to `CursorOverlay::render` and discarding the caret) so they 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; pub mod glyph_dialog;` (widgets) plus `mod 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 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.
**`kiln-ui/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) -> DialogResult``Esc``Cancel`; `Enter``Submit` 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`.
**`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 as `Dialog`. Built via `GlyphDialog::new(title, initial: Glyph, on_done)`; `on_done: FnOnce(Option<Glyph>, &mut Ctx)` fires `Some(glyph)` on OK, `None` on cancel. Holds the selected `tile: u32`, two `ColorPicker`s (`fg`/`bg`), and a `Focus` (`Grid`/`FgStrip`/`FgField`/`BgStrip`/`BgField`).
- `ColorPicker` (private) — one color selector: a `selected` slot index over a strip of the 16 `kiln_core::colors::NAMED_COLORS` swatches plus a final **custom** slot (`CUSTOM == NAMED_COLORS.len()`), and a 6-hex `TextField` that is the **source of truth** (`color()` parses it). Picking a named swatch fills the field via `TextField::set`; the custom slot leaves it user-editable. Seeds from an `Rgba8` by matching a named color (else the custom slot).
- `handle_event(&Event) -> DialogResult` (reuses `dialog::DialogResult`) — `Esc``Cancel`; `Enter``Submit` only when both colors resolve; `Tab`/`Shift-Tab`/`BackTab` cycle focus (skipping a color's `*Field` stop 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 (a `Submit` with both colors valid yields `Some(Glyph{..})`).
- Drawing is its `CursorOverlay` impl (`render(area, buf) -> Option<Position>`; host renders it through `render_overlay`) — dims + centered bordered box; renders the 256-char grid via `kiln_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 `#RRGGBB` box, 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 (or `None`). Unit-tested with a dummy `Ctx`.
**`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-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.
**`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-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), `Esc``Exit`. 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.
- Unit-tested (pure logic, no TTY).