editor ui

This commit is contained in:
2026-06-17 18:25:09 -05:00
parent 6464e47747
commit fb8f6df501
11 changed files with 982 additions and 135 deletions
+61 -27
View File
@@ -27,8 +27,8 @@ When the user says **"finish the epic"**, do all of the following in order:
## Commands
```bash
cargo build # compile the workspace (kiln-core + kiln-tui)
cargo run -p kiln-tui -- maps/start.toml # play a board in the terminal
cargo build # compile the workspace (kiln-core + kiln-ui + kiln-tui)
cargo run -p kiln-tui -- maps/start.toml # play/edit a world in the terminal
cargo test # run all tests
cargo test <name> # run a single test by name (substring match)
cargo clippy # lint
@@ -40,9 +40,10 @@ cargo fmt # format
`kiln` is a Cargo **workspace** that separates the engine from its front-ends so the same game data can be driven by different UIs:
- **`kiln-core`** — the engine: all core game types (`Board`, `Glyph`, `Archetype`, `GameState`, …) plus `.toml` map-file load/save. No rendering or UI; every front-end depends on it.
- **`kiln-tui`** — a terminal **player** (no editor yet) built on **ratatui 0.30 / crossterm**. Takes a map-file path on the command line and lets you walk the player around the board with the arrow keys.
- **`kiln-ui`** — reusable, **game-agnostic** terminal UI widgets on **ratatui** (no `kiln-core` dep). Each widget owns its presentation + input handling and is generic over a host context `Ctx` it mutates only through callbacks. Currently holds the dialog/text-field system (`dialog::Dialog<Ctx>`) and the shared `dim_area` overlay helper; future home of the script text-editor wrapper.
- **`kiln-tui`** — a terminal **player + world editor** built on **ratatui 0.30 / crossterm**, depending on both `kiln-core` and `kiln-ui`. Takes a world-file path on the command line; plays a board (arrow keys) and edits it (`Esc` → menu → `e`).
Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`.
Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui", "kiln-ui"]`, with `ratatui`/`tui-input` pinned in `[workspace.dependencies]` so kiln-ui and kiln-tui share one version (their public APIs exchange ratatui types).
### kiln-core modules
@@ -128,18 +129,64 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
- `World` — the runtime representation of a `.toml` world file: `name: String`, `start: String` (key of the starting board), `scripts: HashMap<String, String>` (named Rhai source, shared across all boards), `boards: HashMap<String, Rc<RefCell<Board>>>` (all boards, always present). Every board is wrapped in `Rc<RefCell<Board>>` so multiple shared refs can coexist — `GameState` clones the active board's `Rc` for its `ScriptHost`, and all boards remain in `world.boards` at all times (no board is ever "removed" when active).
- `pub fn load(path: &str) -> Result<World, Box<dyn std::error::Error>>` — reads a world `.toml` file, converts each `[boards.NAME.*]` subtable via `TryFrom<MapFile> for Board`, wraps each in `Rc::new(RefCell::new(...))`, and validates that `world.start` matches a board key.
### kiln-ui modules
Reusable, **game-agnostic** terminal UI widgets on **ratatui** — no `kiln-core` dependency. 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.
**`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.
- `pub mod dialog;` — see below. (Future home of a full-screen script text-editor wrapper.)
**`kiln-ui/src/dialog.rs`** — modal dialog widgets, backed by [`tui-input`]:
- `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. The text field is a `tui_input::Input` driven through `InputRequest` (we don't use tui-input's own event backend, avoiding crossterm-version coupling).
- `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::draw(&self, &mut Frame)` — 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). Takes `&mut Frame` (not a `Widget`) because it needs to place a real terminal cursor. Unit-tested (filtering/clamping, OK-enabled rule, Select/Create/Cancel/text outcomes) with a dummy `Ctx`.
### kiln-tui modules
The terminal player. 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 terminal **player + world editor**, depending on both `kiln-core` and `kiln-ui`. 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 in `ScrollOverlayWidget` (no ratatui primitive for "tint the whole buffer"), and 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.
**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 and play loop:
- 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 calls `GameState::from_world(world)` to start on the world's designated start board.
- `ratatui::init()` → detect `TerminalCaps` → push Kitty flags when supported → `run` loop → pop Kitty flags → `ratatui::restore()`.
- `run` is a ~30 FPS real-time loop: it `event::poll`s only until the next frame deadline (so input stays responsive) and otherwise wakes to advance the game by the real elapsed `dt` via `GameState::tick`. It acts on `Press` **and** `Repeat` key events (so holding a key moves continuously) and ignores `Release` (which the Kitty protocol also emits, and which would otherwise double-fire each move). Arrow keys move the player via `GameState::try_move`; `l` toggles the log panel; `q`/`Esc` quit. Mouse capture is enabled so the log panel scrolls (wheel) and resizes (drag the divider). `game.run_init()` runs object `init()` hooks after the board is loaded and before the loop. When `game.active_scroll` is `Some`, all input is redirected to scroll navigation (Up/Down to scroll, letter keys for choices, Esc to dismiss if no choices); `game.tick()` is skipped until the overlay is closed.
- `draw` wraps the board in a bordered `Block` titled with the board name and controls. When the log panel is closed, the latest log line is previewed in the bottom-left border after a `[l]og:` label; when open, a bottom panel lists the log newest-first (`render::logline_to_line` converts each `LogLine`). The scroll overlay is drawn last (above everything) by `render::draw_scroll_overlay`.
- `Ui { log: LogState, overlay: ScrollOverlayState }` — view-only panel state kept in the front-end (not on `GameState`). `Ui::tick(dt, game)` advances animations, dispatches finished close events, and ticks the game when no overlay is blocking. `begin_close(choice)` starts the close animation.
**`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 → `DisableMouseCapture``ratatui::restore()`. `game.run_init()` runs object `init()` hooks before the loop.
- `run` is a ~30 FPS real-time loop: `event::poll`s 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`: `Menu``handle_menu_input`; otherwise (unless `Ignore`) it matches on `Mode``Play``handle_board_input`/`handle_scroll_input`, `Edit``handle_editor_input`/`handle_dialog_input`. After ticking it calls `apply_pending_mode` 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.
- `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 the editor dialog (`kiln_ui::dialog::draw`). `draw_play` wraps the board in a bordered `Block` (board-name title; latest log previewed in the bottom border when the panel is closed). `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.
**`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>>`, a blinking `cursor`, `sidebar_width` (mouse-resizable), blink state, `last_board_area` (written at draw, read for right-click hit-testing), and editor-side `log`.
- `MenuLevel { World }` — a sidebar menu level; `title()` (breadcrumb segment) and `choices()` (static `(key, label)` list — `[n] Name`, `[e] Entry board`, `[s] Scripts`, `[b] Boards`). The menu is a stack so nested levels can be added later; today only `World` exists and every choice opens a dialog (no sub-menu). Per the design, menus are static letter-keyed lists — 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 → `open_*_dialog`), `menu_value(key)` (the "current value" shown beneath a choice in the sidebar — world name for `n`, start board for `e`), and the `open_{name,entry,scripts,boards}_dialog` builders (name → text dialog writing `world.name`; entry → list dialog writing `world.start`; scripts/boards → select-only list dialogs for now).
- `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`, `take`s it out and calls `finish(.., ed)`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — board (with blinking cursor + coord readout), 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 }`. 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, 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.menu``Menu`; else from `Mode` (`Edit``Dialog` when a dialog is open else `Editor`; `Play``Scroll` when a scroll is active else `Board`).
- `handle_board_input` (arrows move the player via `try_move_with_transition`, `l` toggles log, `Esc` opens the `pause_menu_items()` overlay, PageUp/Down + mouse scroll/resize the log), `handle_editor_input` (arrows move the cursor, `l` toggles log, `Esc``menu_escape`, 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 `Rc`s 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 `Buffer`s, 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/cp437.rs`** — CP437 → Unicode mapping:
- `CP437: [char; 256]` — full code-page-437 table (graphic glyphs for 0x000x1F, box-drawing for 0xB00xDF, etc.).
@@ -147,6 +194,7 @@ The terminal player. Renders the board as text: each `Glyph.tile` index is reint
**`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 `SpeechBubble`s 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.
@@ -154,20 +202,6 @@ The terminal player. Renders the board as text: each `Glyph.tile` index is reint
- `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/scroll_overlay.rs`** — scroll overlay widget:
- `ScrollAnimState { Opening(f32), Open, Closing(f32), Done }` — open/close animation progress (0.01.0). `Done` signals the caller to dispatch the pending choice and reset state.
- `ScrollOverlayState { offset, max_scroll, anim, pending_choice }` — threaded through render as `StatefulWidget` state. `advance_anim(dt)` ticks the animation; `close_finished()` / `take_choice()` let the caller dispatch when `Done`. `max_scroll` is an output written during render.
- `ScrollOverlayWidget<'a>``StatefulWidget` that dims the background, renders a bordered window at ½ width × ¾ height with animated height (forced even), wraps text, centers whitespace-leading lines, prefixes choices with `[a]`/`[b]`, and renders a scrollbar when content overflows.
**`kiln-tui/src/ui.rs`** — top-level front-end state:
- `Ui { log: LogState, overlay: ScrollOverlayState }` — aggregates all presentation state. `tick(dt, game)` advances animations, dispatches finished close events, ticks the game when no overlay blocks, and starts the open animation when a new scroll appears. `scroll_lines(delta)` adjusts `overlay.offset`. `begin_close(choice)` starts the closing animation (uses current opening progress if still animating in).
**`kiln-tui/src/input.rs`** — input dispatch:
- `InputMode { Board(bool), Scroll }` — which input mode is active; `Board(log_open)` for normal play, `Scroll` when the overlay is blocking.
- `current_input_mode(game, ui) -> InputMode` — derives mode from game + animation state.
- `handle_board_input(event, log_open, terminal_h, game, ui) -> bool` — processes arrow keys (move player), `l` (toggle log), `q`/`Esc` (quit), PageUp/Down (scroll log), mouse drag (resize log). Returns `true` if the player quit.
- `handle_scroll_input(event, game, ui)` — processes Up/Down (scroll), letter keys (select choice via `resolve_choice`), Esc (dismiss if no choices), mouse wheel.
**`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.
@@ -295,5 +329,5 @@ Today's baked-in assumptions that will need to change (don't make `Board.player`
- **Scripting growth** — more event hooks beyond `init`/`tick`/`bump` (e.g. `on_touch` when the player steps onto an object), a larger action/read vocabulary (more actions could carry a `time_cost`), and persisting script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call).
- **Runtime spawn/destroy of objects** — objects now have stable `ObjectId`s (`Board::objects` is a `BTreeMap<ObjectId, ObjectDef>` with a `next_object_id` counter; `add_object` allocates), so references survive reordering. What's still missing: a `remove_object`, and wiring live spawn/destroy into the `ScriptHost` (it builds one `ObjectRuntime` per object once in `GameState::new`, so an object added after that has no script runtime until the host is rebuilt).
- **Portals & multi-board** — `PortalDef` is parsed but not runtime-wired. Multi-board world loading **is done**: `world::load` loads all boards as `Rc<RefCell<Board>>`, `GameState` owns the whole `World` and tracks `current_board_name`. What's still missing: a `GameState::enter_board(name)` method that updates `current_board_name` and rebuilds the `ScriptHost` for the new board's objects.
- **Portals & multi-board** — **done**: `world::load` loads all boards as `Rc<RefCell<Board>>`, `GameState` owns the whole `World` and tracks `current_board_name`. `try_move` detects stepping onto a portal and calls `GameState::enter_board(target_map, target_entry)`, which switches `current_board_name`, places the player at the named arrival portal, clears per-board transient state (speech/scroll), rebuilds the `ScriptHost` for the new board, re-runs `init()`, and sets `board_transition` as a hook. kiln-tui detects the board change in `try_move_with_transition` and plays the `TransitionAnimation` wipe. (Errors — missing target board or entry — are logged, not fatal.)
- **Load-error surfacing** — `Board::load_errors()` is collected but no front-end displays it yet (kiln-tui could append it to the in-game log at startup).
Generated
+19
View File
@@ -651,9 +651,18 @@ version = "0.1.0"
dependencies = [
"color",
"kiln-core",
"kiln-ui",
"ratatui",
]
[[package]]
name = "kiln-ui"
version = "0.1.0"
dependencies = [
"ratatui",
"tui-input",
]
[[package]]
name = "lab"
version = "0.11.0"
@@ -1636,6 +1645,16 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tui-input"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bd014a652e31cf25ea68d11b10a7b09549863449b19387505c9933f11eb05fa"
dependencies = [
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "typenum"
version = "1.20.1"
+5 -1
View File
@@ -1,3 +1,7 @@
[workspace]
members = ["kiln-core", "kiln-tui"]
members = ["kiln-core", "kiln-tui", "kiln-ui"]
resolver = "2"
[workspace.dependencies]
ratatui = { version = "0.30.1" }
tui-input = { version = "0.15.3", default-features = false }
+2 -1
View File
@@ -5,5 +5,6 @@ edition = "2024"
[dependencies]
kiln-core = { path = "../kiln-core" }
kiln-ui = { path = "../kiln-ui" }
color = "0.3.3"
ratatui = { version = "0.30.1", features = ["unstable-rendered-line-info"] }
ratatui = { workspace = true, features = ["unstable-rendered-line-info"] }
+202 -22
View File
@@ -2,24 +2,28 @@
//!
//! The editor is a separate top-level [`Mode`](crate::mode::Mode): it holds its
//! own freshly reloaded [`World`] and never ticks the game. The board is shown
//! with a resizable right-hand sidebar (just a "coming soon" placeholder for now)
//! and a blinking cursor moved with the arrow keys or a right-click.
//! with a blinking cursor (moved with the arrow keys or a right-click) and a
//! resizable right-hand sidebar that hosts the [menu tree](MenuLevel): a breadcrumb
//! title and letter-keyed choices that open [dialogs](kiln_ui::dialog). `Esc` walks
//! back up the tree, opening the overlay [editor menu](editor_menu_items) at the root.
use std::cell::Ref;
use kiln_ui::dialog::{Dialog, DialogResult, ListDialogResponse};
use crate::log::{LogWidget, log_preview_line};
use crate::menu::{MenuItem, MenuKey};
use crate::mode::PendingMode;
use crate::render::{BoardWidget, board_to_screen, screen_to_board};
use crate::ui::Ui;
use kiln_core::Board;
use kiln_core::log::LogLine;
use kiln_core::world::World;
use ratatui::Frame;
use ratatui::crossterm::event::Event;
use ratatui::layout::{Constraint, Layout, Rect, Spacing};
use ratatui::style::{Color, Style};
use ratatui::symbols::merge::MergeStrategy;
use ratatui::text::Line;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use crate::log::{log_preview_line, LogWidget};
use crate::menu::{MenuItem, MenuKey};
use crate::mode::PendingMode;
use crate::render::{board_to_screen, screen_to_board, BoardWidget};
use crate::ui::Ui;
use std::cell::Ref;
/// How long each half of the cursor blink lasts, in seconds.
const BLINK_SECS: f32 = 0.5;
@@ -30,6 +34,40 @@ const MIN_PANEL_WIDTH: u16 = 8;
/// The cursor glyph: a solid full block (CP437 219) drawn in light gray.
const CURSOR_CHAR: char = '█';
/// One level in the editor's sidebar menu tree.
///
/// The menu is a stack of levels (the current one is the stack's last element); a
/// breadcrumb is built from their [`title`](MenuLevel::title)s. Today only the
/// top-level [`World`](MenuLevel::World) exists — every choice opens a dialog rather
/// than a sub-menu — but the stack keeps room for nested menus later.
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum MenuLevel {
/// The top-level world menu: set name / entry board, browse scripts / boards.
World,
}
impl MenuLevel {
/// The breadcrumb segment shown for this level.
fn title(self) -> &'static str {
match self {
MenuLevel::World => "World",
}
}
/// The static `(key, label)` choices shown for this level. Keep in sync with the
/// key dispatch in [`handle_editor_input`](crate::input::handle_editor_input).
fn choices(self) -> &'static [(char, &'static str)] {
match self {
MenuLevel::World => &[
('n', "Name"),
('e', "Entry board"),
('s', "Scripts"),
('b', "Boards"),
],
}
}
}
/// All state for an active editing session.
///
/// Built by reloading the world file, so the running game has no bearing on what
@@ -40,6 +78,12 @@ pub(crate) struct EditorState {
world: World,
/// Key (in [`World::boards`]) of the board currently being edited.
board_name: String,
/// The sidebar menu navigation stack; the last element is the current level.
/// Starts as `[MenuLevel::World]` and is never emptied below that root.
menu: Vec<MenuLevel>,
/// The currently open dialog overlay, if any. While `Some`, the editor is in
/// [`InputMode::Dialog`](crate::input::InputMode::Dialog).
pub(crate) dialog: Option<Dialog<EditorState>>,
/// Cursor position in board cells, clamped to the board bounds.
cursor: (i32, i32),
/// Width of the right-hand sidebar in terminal columns (mouse-resizable).
@@ -63,6 +107,8 @@ impl EditorState {
Self {
world,
board_name,
menu: vec![MenuLevel::World],
dialog: None,
cursor: (0, 0),
sidebar_width: DEFAULT_SIDEBAR_WIDTH,
blink_on: true,
@@ -72,6 +118,106 @@ impl EditorState {
}
}
/// The breadcrumb for the current menu path, e.g. `"World"` or `"World > Scripts"`.
fn breadcrumb(&self) -> String {
self.menu
.iter()
.map(|l| l.title())
.collect::<Vec<_>>()
.join(" > ")
}
/// The menu level currently shown in the sidebar (top of the stack).
fn current_menu(&self) -> MenuLevel {
*self.menu.last().expect("menu stack is never empty")
}
/// Handles `Esc` in the menu: pops to the parent level, or — at the root world
/// menu — opens the existing overlay editor menu (Play / Quit / Resume).
pub(crate) fn menu_escape(&mut self, ui: &mut Ui) {
if self.menu.len() > 1 {
self.menu.pop();
} else {
ui.open_menu(editor_menu_items());
}
}
/// Dispatches a letter key against the current menu level. Returns `true` if the
/// key matched a choice (and opened a dialog), so the caller can stop handling it.
pub(crate) fn menu_key(&mut self, c: char) -> bool {
match (self.current_menu(), c) {
(MenuLevel::World, 'n') => {
self.open_name_dialog();
true
}
(MenuLevel::World, 'e') => {
self.open_entry_dialog();
true
}
(MenuLevel::World, 's') => {
self.open_scripts_dialog();
true
}
(MenuLevel::World, 'b') => {
self.open_boards_dialog();
true
}
_ => false,
}
}
/// The "current value" shown beneath a menu choice in the sidebar, if any.
/// Used for `[n] Name` (current world name) and `[e] Entry board` (current start).
fn menu_value(&self, key: char) -> Option<String> {
match (self.current_menu(), key) {
(MenuLevel::World, 'n') => Some(self.world.name.clone()),
(MenuLevel::World, 'e') => Some(self.world.start.clone()),
_ => None,
}
}
/// Opens a text dialog to rename the world; OK writes [`World::name`].
fn open_name_dialog(&mut self) {
let current = self.world.name.clone();
self.dialog = Some(Dialog::text("World Name", current, |opt, ed: &mut EditorState| {
if let Some(s) = opt {
ed.world.name = s;
}
}));
}
/// Opens a list dialog to choose the entry board; selecting one writes [`World::start`].
fn open_entry_dialog(&mut self) {
let mut names: Vec<String> = self.world.boards.keys().cloned().collect();
names.sort();
self.dialog = Some(Dialog::list(
"Entry Board",
names,
false,
|resp, ed: &mut EditorState| {
if let ListDialogResponse::Select(s) = resp {
ed.world.start = s;
}
},
));
}
/// Opens a list dialog of the world's scripts. Select-only for now (no side effect).
fn open_scripts_dialog(&mut self) {
let mut names: Vec<String> = self.world.scripts.keys().cloned().collect();
names.sort();
self.dialog = Some(Dialog::list("Scripts", names, false, |resp, ed: &mut EditorState| {
ed.log.push(LogLine::raw(format!("Script dialog: {:?}", resp)))
}));
}
/// Opens a list dialog of the world's boards. Select-only for now (no side effect).
fn open_boards_dialog(&mut self) {
let mut names: Vec<String> = self.world.boards.keys().cloned().collect();
names.sort();
self.dialog = Some(Dialog::list("Boards", names, false, |_resp, _ed: &mut EditorState| {}));
}
/// Borrows the board currently being edited.
pub(crate) fn board(&self) -> Ref<'_, Board> {
self.world.boards[&self.board_name].borrow()
@@ -88,7 +234,10 @@ impl EditorState {
/// Moves the cursor by `(dx, dy)`, clamped to the board bounds.
pub(crate) fn move_cursor(&mut self, dx: i32, dy: i32) {
let (w, h) = { let b = self.board(); (b.width as i32, b.height as i32) };
let (w, h) = {
let b = self.board();
(b.width as i32, b.height as i32)
};
self.cursor.0 = (self.cursor.0 + dx).clamp(0, w - 1);
self.cursor.1 = (self.cursor.1 + dy).clamp(0, h - 1);
}
@@ -131,6 +280,24 @@ pub(crate) fn editor_menu_items() -> Vec<MenuItem> {
]
}
/// Handles an input event while a dialog is open ([`InputMode::Dialog`](crate::input::InputMode::Dialog)).
///
/// Forwards the event to the dialog; on `Submit`/`Cancel` it takes the dialog out of
/// [`EditorState::dialog`] (so the callback receives an un-borrowed `&mut EditorState`)
/// and fires the callback via [`Dialog::finish`].
pub(crate) fn handle_dialog_input(event: Event, ed: &mut EditorState) {
let Some(dialog) = ed.dialog.as_mut() else {
return;
};
match dialog.handle_event(&event) {
DialogResult::Continue => {}
result => {
let dialog = ed.dialog.take().expect("dialog present");
dialog.finish(result, ed);
}
}
}
/// Renders the editor: the board (with a blinking cursor and coordinate readout),
/// the right-hand sidebar, and — when open — the bottom log panel spanning the
/// full width (board and sidebar sit side-by-side above it).
@@ -169,20 +336,33 @@ pub(crate) fn draw_editor(frame: &mut Frame, ed: &mut EditorState, ui: &mut Ui)
// Record the board rect for right-click hit-testing (board borrow now dropped).
ed.last_board_area = inner;
// ── Sidebar: a placeholder "coming soon" label centered in the panel ──────
let sb_block = Block::bordered().title(" Editor ").merge_borders(MergeStrategy::Exact);
// ── Sidebar: the current menu (breadcrumb title + letter-keyed choices) ───
let sb_block = Block::bordered()
.title(format!(" {} ", ed.breadcrumb()))
.merge_borders(MergeStrategy::Exact);
let sb_inner = sb_block.inner(sidebar_area);
frame.render_widget(sb_block, sidebar_area);
// Center one line vertically, then horizontally via Paragraph::centered.
let [_, mid, _] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
]).areas(sb_inner);
frame.render_widget(
Paragraph::new("coming soon").style(Style::default().fg(Color::DarkGray)).centered(),
mid,
);
// One line per choice (`[k] Label`); a choice with a current value (e.g. the
// world name) gets a second, indented line beneath it in a distinct color.
let key_style = Style::default().fg(Color::Rgb(150, 200, 255));
let label_style = Style::default().fg(Color::White);
let value_style = Style::default().fg(Color::Rgb(200, 180, 120));
let mut lines: Vec<Line> = Vec::new();
for (k, label) in ed.current_menu().choices() {
lines.push(Line::from(vec![
Span::styled(format!("[{k}] "), key_style),
Span::styled(*label, label_style),
]));
if let Some(val) = ed.menu_value(*k) {
lines.push(Line::from(Span::styled(format!(" {val}"), value_style)));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"[esc] menu",
Style::default().fg(Color::DarkGray),
)));
frame.render_widget(Paragraph::new(lines), sb_inner);
// ── Log panel (full width, below board + sidebar) ─────────────────────────
if let Some(log_area) = log_area {
+68 -35
View File
@@ -1,10 +1,10 @@
use ratatui::crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind};
use kiln_core::Direction;
use kiln_core::game::{GameState, ScrollLine};
use crate::editor::{editor_menu_items, EditorState};
use crate::menu::{MenuItem, MenuKey, pause_menu_items, ActionRc as MenuActionRc};
use crate::editor::EditorState;
use crate::menu::{ActionRc as MenuActionRc, MenuItem, MenuKey, pause_menu_items};
use crate::mode::Mode;
use crate::ui::Ui;
use kiln_core::Direction;
use kiln_core::game::{GameState, ScrollLine};
use ratatui::crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind};
/// Moves the player and, if the move crosses a portal, captures both board Rcs
/// and stores them in [`Ui::pending_transition`] for the next draw call.
@@ -29,6 +29,8 @@ pub enum InputMode {
Menu,
/// Editing a world: arrow keys move the cursor, the sidebar is shown.
Editor,
/// A dialog overlay is open over the editor; all input drives the dialog.
Dialog,
/// An animation is running and has not claimed any input mode; all input is discarded.
Ignore,
/// Reserved for a post-transition freeze; nothing currently produces this mode.
@@ -42,32 +44,52 @@ pub(crate) fn current_input_mode(mode: &Mode, ui: &Ui) -> InputMode {
if let Some(anim) = &ui.active_animation {
return anim.input_mode_during();
}
if ui.pending_transition.is_some() { return InputMode::Ignore; }
if ui.menu.is_some() { return InputMode::Menu; }
if ui.pending_transition.is_some() {
return InputMode::Ignore;
}
if ui.menu.is_some() {
return InputMode::Menu;
}
match mode {
Mode::Edit(_) => InputMode::Editor,
Mode::Edit(ed) => {
if ed.dialog.is_some() {
InputMode::Dialog
} else {
InputMode::Editor
}
}
Mode::Play(game) => {
if game.active_scroll.is_some() { InputMode::Scroll } else { InputMode::Board }
if game.active_scroll.is_some() {
InputMode::Scroll
} else {
InputMode::Board
}
}
}
}
/// Handles an input event in [`InputMode::Board`]. Returns `true` if the player quit.
pub(crate) fn handle_board_input(event: Event, log_open: bool, terminal_h: u16, game: &mut GameState, ui: &mut Ui) -> bool {
pub(crate) fn handle_board_input(
event: Event,
log_open: bool,
terminal_h: u16,
game: &mut GameState,
ui: &mut Ui,
) -> bool {
match event {
Event::Key(key) => match key.code {
KeyCode::Esc => ui.open_menu(pause_menu_items()),
KeyCode::Up => try_move_with_transition(game, ui, Direction::North),
KeyCode::Down => try_move_with_transition(game, ui, Direction::South),
KeyCode::Left => try_move_with_transition(game, ui, Direction::West),
KeyCode::Up => try_move_with_transition(game, ui, Direction::North),
KeyCode::Down => try_move_with_transition(game, ui, Direction::South),
KeyCode::Left => try_move_with_transition(game, ui, Direction::West),
KeyCode::Right => try_move_with_transition(game, ui, Direction::East),
KeyCode::Char('l') => ui.log.toggle(),
KeyCode::PageUp if log_open => ui.log.scroll_by(-5, game.log.len()),
KeyCode::PageUp if log_open => ui.log.scroll_by(-5, game.log.len()),
KeyCode::PageDown if log_open => ui.log.scroll_by(5, game.log.len()),
_ => {}
},
Event::Mouse(m) if log_open => match m.kind {
MouseEventKind::ScrollUp => ui.log.scroll_by(-1, game.log.len()),
MouseEventKind::ScrollUp => ui.log.scroll_by(-1, game.log.len()),
MouseEventKind::ScrollDown => ui.log.scroll_by(1, game.log.len()),
MouseEventKind::Drag(_) => {
// The divider sits at row `total - height`; dragging up grows the panel.
@@ -87,20 +109,19 @@ pub(crate) fn handle_board_input(event: Event, log_open: bool, terminal_h: u16,
/// A right-click jumps the cursor to the clicked board cell, and dragging the
/// board/sidebar divider resizes the sidebar (the divider sits at `term_w -
/// sidebar_width`, mirroring the log's bottom divider).
pub(crate) fn handle_editor_input(
event: Event,
term_w: u16,
ed: &mut EditorState,
ui: &mut Ui,
) {
pub(crate) fn handle_editor_input(event: Event, term_w: u16, ed: &mut EditorState, ui: &mut Ui) {
match event {
Event::Key(key) => match key.code {
KeyCode::Esc => ui.open_menu(editor_menu_items()),
KeyCode::Up => ed.move_cursor(0, -1),
KeyCode::Down => ed.move_cursor(0, 1),
KeyCode::Left => ed.move_cursor(-1, 0),
KeyCode::Esc => ed.menu_escape(ui),
KeyCode::Up => ed.move_cursor(0, -1),
KeyCode::Down => ed.move_cursor(0, 1),
KeyCode::Left => ed.move_cursor(-1, 0),
KeyCode::Right => ed.move_cursor(1, 0),
KeyCode::Char('l') => ui.log.toggle(),
// A menu letter opens its dialog; unmatched letters are ignored.
KeyCode::Char(c) => {
ed.menu_key(c);
}
_ => {}
},
Event::Mouse(m) => match m.kind {
@@ -119,7 +140,9 @@ fn resolve_choice(lines: &[ScrollLine], c: char) -> Option<String> {
let mut letter = b'a';
for line in lines {
if let ScrollLine::Choice { choice, .. } = line {
if c == letter as char { return Some(choice.clone()); }
if c == letter as char {
return Some(choice.clone());
}
letter += 1;
}
}
@@ -128,7 +151,9 @@ fn resolve_choice(lines: &[ScrollLine], c: char) -> Option<String> {
/// Handles an input event in [`InputMode::Scroll`].
pub(crate) fn handle_scroll_input(event: Event, game: &mut GameState, ui: &mut Ui) {
let lines: Vec<ScrollLine> = game.active_scroll.as_ref()
let lines: Vec<ScrollLine> = game
.active_scroll
.as_ref()
.map(|s| s.lines.clone())
.unwrap_or_default();
let has_choices = lines.iter().any(|l| matches!(l, ScrollLine::Choice { .. }));
@@ -137,7 +162,7 @@ pub(crate) fn handle_scroll_input(event: Event, game: &mut GameState, ui: &mut U
Event::Key(key) => match key.code {
// Esc dismisses only when there are no choices.
KeyCode::Esc if !has_choices => ui.begin_close(None, game),
KeyCode::Up => ui.scroll_lines(-1),
KeyCode::Up => ui.scroll_lines(-1),
KeyCode::Down => ui.scroll_lines(1),
KeyCode::Char(c) => {
if let Some(ch) = resolve_choice(&lines, c) {
@@ -147,7 +172,7 @@ pub(crate) fn handle_scroll_input(event: Event, game: &mut GameState, ui: &mut U
_ => {}
},
Event::Mouse(m) => match m.kind {
MouseEventKind::ScrollUp => ui.scroll_lines(-1),
MouseEventKind::ScrollUp => ui.scroll_lines(-1),
MouseEventKind::ScrollDown => ui.scroll_lines(1),
_ => {}
},
@@ -163,15 +188,23 @@ pub(crate) fn handle_menu_input(event: Event, ui: &mut Ui) {
match event {
Event::Key(key) => {
let pressed = match key.code {
KeyCode::Esc => Some(MenuKey::Esc),
KeyCode::Char(c) => Some(MenuKey::Char(c)),
KeyCode::Up => { ui.scroll_menu(-1); None }
KeyCode::Down => { ui.scroll_menu(1); None }
KeyCode::Esc => Some(MenuKey::Esc),
KeyCode::Char(c) => Some(MenuKey::Char(c)),
KeyCode::Up => {
ui.scroll_menu(-1);
None
}
KeyCode::Down => {
ui.scroll_menu(1);
None
}
_ => None,
};
if let Some(mk) = pressed {
// Clone the Rc to release the borrow on ui.menu before calling the action.
let action: Option<MenuActionRc> = ui.menu.as_ref()
let action: Option<MenuActionRc> = ui
.menu
.as_ref()
.and_then(|m| m.items.iter().find(|i| i.key == mk))
.map(|i| i.action_rc());
if let Some(action) = action {
@@ -180,7 +213,7 @@ pub(crate) fn handle_menu_input(event: Event, ui: &mut Ui) {
}
}
Event::Mouse(m) => match m.kind {
MouseEventKind::ScrollUp => ui.scroll_menu(-1),
MouseEventKind::ScrollUp => ui.scroll_menu(-1),
MouseEventKind::ScrollDown => ui.scroll_menu(1),
_ => {}
},
+62 -33
View File
@@ -10,19 +10,31 @@
mod animation;
mod cp437;
mod editor;
mod input;
mod log;
mod menu;
mod mode;
mod overlay;
mod render;
mod scroll_overlay;
mod speech;
mod term;
mod transition;
mod utils;
mod speech;
mod ui;
mod input;
mod utils;
use crate::animation::{AnimWidget, AnimationLayer};
use crate::editor::{EditorState, draw_editor, handle_dialog_input};
use crate::input::{
InputMode, current_input_mode, handle_board_input, handle_editor_input, handle_menu_input,
handle_scroll_input,
};
use crate::log::{LogWidget, log_preview_line};
use crate::menu::MenuWidget;
use crate::mode::{Mode, PendingMode};
use crate::scroll_overlay::ScrollOverlayWidget;
use crate::speech::SpeechBubblesWidget;
use crate::ui::Ui;
use kiln_core::game::GameState;
use kiln_core::log::LogLine;
use kiln_core::world;
@@ -39,18 +51,6 @@ use std::io;
use std::process::ExitCode;
use std::time::{Duration, Instant};
use term::TerminalCaps;
use crate::animation::{AnimationLayer, AnimWidget};
use crate::editor::{draw_editor, EditorState};
use crate::input::{
current_input_mode, handle_board_input, handle_editor_input, handle_menu_input,
handle_scroll_input, InputMode,
};
use crate::log::{log_preview_line, LogWidget};
use crate::menu::MenuWidget;
use crate::mode::{Mode, PendingMode};
use crate::scroll_overlay::ScrollOverlayWidget;
use crate::speech::SpeechBubblesWidget;
use crate::ui::Ui;
/// Entry point: parse the map path, load the board, then run the play loop with
/// the terminal in raw/alternate-screen mode (restored on every exit path).
@@ -96,7 +96,10 @@ fn main() -> ExitCode {
let mut mode = Mode::Play(game);
// Remember the world path so the editor (and play-reload) can reload from it.
let mut ui = Ui { world_path: path.clone(), ..Ui::default() };
let mut ui = Ui {
world_path: path.clone(),
..Ui::default()
};
let result = run(&mut terminal, &mut mode, &mut ui);
if caps.keyboard_enhancement {
@@ -121,11 +124,7 @@ const FRAME: Duration = Duration::from_nanos(1_000_000_000 / 30);
/// only until the next frame deadline (`event::poll`), then advances the game by
/// the real time elapsed since the last tick. `poll` returns the instant a key
/// arrives, so input stays responsive while the game keeps ticking on its own.
fn run(
terminal: &mut ratatui::DefaultTerminal,
mode: &mut Mode,
ui: &mut Ui,
) -> io::Result<()> {
fn run(terminal: &mut ratatui::DefaultTerminal, mode: &mut Mode, ui: &mut Ui) -> io::Result<()> {
let mut last_tick = Instant::now();
loop {
// Redraw every iteration; ratatui diffs against the previous frame so
@@ -158,14 +157,18 @@ fn run(
handle_board_input(event, ui.log.open, size.height, game, ui);
}
InputMode::Scroll => handle_scroll_input(event, game, ui),
_ => unreachable!("Menu and Ignore handled above; Editor input mode only occurs in Edit mode"),
_ => unreachable!(
"Menu and Ignore handled above; Editor input mode only occurs in Edit mode"
),
},
Mode::Edit(ed) => match input_mode {
InputMode::Editor => handle_editor_input(event, size.width, ed, ui),
_ => unreachable!("Menu and Ignore handled above; Board/Scroll/Frozen input modes only occur in Play mode")
InputMode::Dialog => handle_dialog_input(event, ed),
_ => unreachable!(
"Menu and Ignore handled above; Board/Scroll/Frozen input modes only occur in Play mode"
),
},
}
}
}
@@ -194,7 +197,9 @@ fn run(
/// [`GameState`] (so it reflects any edits) and re-runs object `init()` hooks. A
/// load failure is logged to the play game (if any) and leaves the mode unchanged.
fn apply_pending_mode(mode: &mut Mode, ui: &mut Ui) {
let Some(pending) = ui.pending_mode.take() else { return };
let Some(pending) = ui.pending_mode.take() else {
return;
};
match world::load(&ui.world_path) {
Ok(world) => match pending {
PendingMode::EnterEditor => *mode = Mode::Edit(EditorState::new(world)),
@@ -225,19 +230,38 @@ fn draw(frame: &mut Frame, mode: &mut Mode, ui: &mut Ui) {
}
// Overlay layer: animation > menu > scroll (mutually exclusive at runtime).
let is_overlay_anim = ui.active_animation.as_ref()
let is_overlay_anim = ui
.active_animation
.as_ref()
.map(|a| matches!(a.layer(), AnimationLayer::Overlay))
.unwrap_or(false);
let area = frame.area();
if is_overlay_anim {
frame.render_widget(AnimWidget(ui.active_animation.as_ref().unwrap().as_ref()), area);
frame.render_widget(
AnimWidget(ui.active_animation.as_ref().unwrap().as_ref()),
area,
);
} else if let Some(menu) = ui.menu.as_mut() {
frame.render_stateful_widget(MenuWidget, area, menu);
} else if let Mode::Play(game) = mode {
// The scroll window only exists while playing.
if let Some(scroll) = &game.active_scroll {
frame.render_stateful_widget(ScrollOverlayWidget::new(&scroll.lines), area, &mut ui.overlay);
} else {
match mode {
// The scroll window only exists while playing.
Mode::Play(game) => {
if let Some(scroll) = &game.active_scroll {
frame.render_stateful_widget(
ScrollOverlayWidget::new(&scroll.lines),
area,
&mut ui.overlay,
);
}
}
// The dialog overlay only exists while editing.
Mode::Edit(ed) => {
if let Some(d) = &ed.dialog {
d.draw(frame);
}
}
}
}
}
@@ -284,13 +308,18 @@ fn draw_board_area(frame: &mut Frame, inner: Rect, game: &GameState, ui: &mut Ui
ui.start_transition(&old_rc.borrow(), &new_rc.borrow(), inner);
}
let is_board_anim = ui.active_animation.as_ref()
let is_board_anim = ui
.active_animation
.as_ref()
.map(|a| matches!(a.layer(), AnimationLayer::Board))
.unwrap_or(false);
if is_board_anim {
// Board-layer animation (wipe): replaces the board entirely.
frame.render_widget(AnimWidget(ui.active_animation.as_ref().unwrap().as_ref()), inner);
frame.render_widget(
AnimWidget(ui.active_animation.as_ref().unwrap().as_ref()),
inner,
);
} else {
let board = &game.board();
frame.render_widget(BoardWidget::new(board), inner);
+17 -16
View File
@@ -4,6 +4,7 @@
//! centering, animated height, and the bordered block. Callers fill the
//! returned content area with their own content.
use kiln_ui::dim_area;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
@@ -35,7 +36,9 @@ pub(crate) fn render_overlay_frame(
area: Rect,
buf: &mut Buffer,
) -> Option<(Rect, Rect)> {
if progress <= 0.0 { return None; }
if progress <= 0.0 {
return None;
}
let outer_w = area.width / style.width_div;
let outer_h = (area.height as f32 * style.height_fraction) as u16 & !1;
@@ -43,19 +46,19 @@ pub(crate) fn render_overlay_frame(
let actual_h = ((outer_h as f32 * progress).round() as u16 & !1).max(2);
let left = area.x + area.width.saturating_sub(outer_w) / 2;
let top = area.y + area.height.saturating_sub(actual_h) / 2;
let overlay_rect = Rect { x: left, y: top, width: outer_w, height: actual_h };
let top = area.y + area.height.saturating_sub(actual_h) / 2;
let overlay_rect = Rect {
x: left,
y: top,
width: outer_w,
height: actual_h,
};
// Dim all background cells so board/game content doesn't show through.
let dim_style = Style::default().fg(Color::Rgb(60, 60, 60)).bg(Color::Black);
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(dim_style);
}
}
}
Fill::new(" ").style(Style::default().bg(style.bg)).render(overlay_rect, buf);
dim_area(area, buf);
Fill::new(" ")
.style(Style::default().bg(style.bg))
.render(overlay_rect, buf);
let block = Block::bordered()
.border_style(Style::default().fg(style.border_fg).bg(style.bg))
@@ -64,10 +67,8 @@ pub(crate) fn render_overlay_frame(
block.render(overlay_rect, buf);
// Split inner into content (left) and scrollbar column (right).
let [content_rect, scrollbar_rect] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(1),
]).areas(inner);
let [content_rect, scrollbar_rect] =
Layout::horizontal([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
Some((content_rect, scrollbar_rect))
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "kiln-ui"
version = "0.1.0"
edition = "2024"
[dependencies]
ratatui = { workspace = true }
tui-input = { workspace = true }
+499
View File
@@ -0,0 +1,499 @@
//! Self-contained dialog overlays: a [text dialog](Dialog::text) (single free-text
//! field) and a [list dialog](Dialog::list) (a filterable, arrow-selectable list).
//!
//! Both are [`Dialog<Ctx>`] values generic over a host context type (the front-end
//! supplies its own; the editor uses its `EditorState`). While a dialog is open the
//! host routes all input to it; when dismissed it fires a stored callback with the
//! result **and** `&mut Ctx`, so the callback can apply the choice to host state. The
//! text field is backed by [`tui_input::Input`], driven through [`InputRequest`] so we
//! never depend on `tui-input`'s own event backend (avoiding any crossterm-version coupling).
//!
//! Drawing is a method ([`Dialog::draw`]) taking `&mut Frame` rather than a ratatui
//! `Widget` because it needs the [`Frame`] to place a real terminal cursor in the
//! active text field.
use crate::dim_area;
use ratatui::Frame;
use ratatui::crossterm::event::{Event, KeyCode};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph};
use tui_input::{Input, InputRequest};
/// Background color filling a dialog window.
const BG: Color = Color::Rgb(10, 15, 35);
/// Border color for a dialog window.
const BORDER: Color = Color::Rgb(80, 130, 210);
/// Color for the currently selected list entry's background.
const SELECT_BG: Color = Color::Rgb(40, 60, 110);
/// Background color of an editable text field, so it reads as an input box.
const FIELD_BG: Color = Color::Rgb(35, 45, 70);
/// The outcome a list dialog reports to its callback.
#[derive(Clone, Debug, PartialEq)]
pub enum ListDialogResponse {
/// The player chose an existing entry from the list.
Select(String),
/// The player typed a new value and confirmed it (only possible when the list
/// dialog was opened with `allow_create = true`). No current caller enables
/// creation, so the payload is unused for now — kept as part of the dialog API.
#[allow(dead_code)]
Create(String),
/// The player dismissed the dialog with `Esc`.
Cancel,
}
/// What kind of field a dialog shows, plus its associated state.
enum DialogBody {
/// A single free-text field.
Text { input: Input },
/// A filter field above a selectable list. `selected` indexes the *filtered*
/// view (entries of `all` whose text starts with the filter).
List {
input: Input,
all: Vec<String>,
selected: usize,
allow_create: bool,
},
}
/// Boxed callback for a text dialog: `Some(value)` on OK, `None` on Cancel.
type TextCallback<Ctx> = Box<dyn FnOnce(Option<String>, &mut Ctx)>;
/// Boxed callback for a list dialog: see [`ListDialogResponse`].
type ListCallback<Ctx> = Box<dyn FnOnce(ListDialogResponse, &mut Ctx)>;
/// The callback fired once when a dialog is dismissed, paired by body kind.
enum DialogCallback<Ctx> {
/// Text-dialog callback.
Text(TextCallback<Ctx>),
/// List-dialog callback.
List(ListCallback<Ctx>),
}
/// How a key event affected an open dialog.
pub enum DialogResult {
/// The dialog stays open; nothing more to do.
Continue,
/// The player confirmed (`Enter` with a valid choice) — fire the callback with the value.
Submit,
/// The player cancelled (`Esc`) — fire the callback with the cancel result.
Cancel,
}
/// An open dialog overlay, generic over the host context `Ctx` its callback mutates.
pub struct Dialog<Ctx> {
/// Title shown in the window's top border.
title: String,
/// The field kind + state.
body: DialogBody,
/// The callback fired exactly once on dismissal.
callback: DialogCallback<Ctx>,
}
impl<Ctx> Dialog<Ctx> {
/// Builds a text dialog. `initial` pre-fills the field; `on_done` receives
/// `Some(value)` on OK or `None` on Cancel, plus `&mut Ctx`.
pub fn text(
title: impl Into<String>,
initial: impl Into<String>,
on_done: impl FnOnce(Option<String>, &mut Ctx) + 'static,
) -> Self {
Self {
title: title.into(),
body: DialogBody::Text {
input: Input::new(initial.into()),
},
callback: DialogCallback::Text(Box::new(on_done)),
}
}
/// Builds a list dialog over `items`. Typing filters the list to entries starting
/// with the typed text; arrow keys move the selection. When `allow_create` is true
/// the player may confirm typed text that matches nothing (→ [`ListDialogResponse::Create`]);
/// otherwise OK is disabled unless a list entry is selected. `on_done` receives the
/// [`ListDialogResponse`] plus `&mut Ctx`.
pub fn list(
title: impl Into<String>,
items: Vec<String>,
allow_create: bool,
on_done: impl FnOnce(ListDialogResponse, &mut Ctx) + 'static,
) -> Self {
Self {
title: title.into(),
body: DialogBody::List {
input: Input::new(String::new()),
all: items,
selected: 0,
allow_create,
},
callback: DialogCallback::List(Box::new(on_done)),
}
}
/// Updates dialog state for an input event and reports whether it should close.
///
/// `Esc` → [`DialogResult::Cancel`]; `Enter` → [`DialogResult::Submit`] only when OK
/// is enabled (always for text; for a list, when a valid entry is selected or
/// `allow_create` and the field is non-empty). Arrow keys move a list's selection;
/// any other key edits the field (and re-clamps a list's selection to the new filter).
pub fn handle_event(&mut self, event: &Event) -> DialogResult {
let Event::Key(key) = event else {
return DialogResult::Continue;
};
match key.code {
KeyCode::Esc => DialogResult::Cancel,
KeyCode::Enter => {
if self.ok_enabled() {
DialogResult::Submit
} else {
DialogResult::Continue
}
}
// Arrow up/down only mean something for a list (move selection within the filtered view).
KeyCode::Up | KeyCode::Down => {
// Compute the filtered length (immutable borrow) before taking the
// mutable borrow of `selected`.
let n = self.filtered_len();
if let DialogBody::List { selected, .. } = &mut self.body
&& n > 0
{
// Wrap-free clamp: up decrements, down increments, bounded to [0, n-1].
if matches!(key.code, KeyCode::Up) {
*selected = selected.saturating_sub(1);
} else {
*selected = (*selected + 1).min(n - 1);
}
}
DialogResult::Continue
}
// Everything else edits the text/filter field.
code => {
if let Some(req) = key_to_request(code) {
self.input_mut().handle(req);
// The filter changed, so the previous selection index may now be
// out of range — clamp it back into the filtered list. Compute the
// new length first (immutable borrow), then clamp (mutable borrow).
let n = self.filtered_len();
if let DialogBody::List { selected, .. } = &mut self.body {
*selected = (*selected).min(n.saturating_sub(1));
}
}
DialogResult::Continue
}
}
}
/// Consumes the dialog and fires its callback. `result` must be the
/// [`DialogResult::Submit`] or [`DialogResult::Cancel`] just returned by
/// [`handle_event`](Dialog::handle_event); `Continue` is treated as cancel.
pub fn finish(self, result: DialogResult, ctx: &mut Ctx) {
let submit = matches!(result, DialogResult::Submit);
match (self.body, self.callback) {
(DialogBody::Text { input }, DialogCallback::Text(cb)) => {
cb(submit.then(|| input.value().to_string()), ctx);
}
(
DialogBody::List {
input,
all,
selected,
allow_create,
},
DialogCallback::List(cb),
) => {
let resp = if !submit {
ListDialogResponse::Cancel
} else if let Some(entry) = filtered_items(&all, input.value())
.into_iter()
.nth(selected)
{
ListDialogResponse::Select(entry.clone())
} else if allow_create {
ListDialogResponse::Create(input.value().to_string())
} else {
ListDialogResponse::Cancel
};
cb(resp, ctx);
}
// The body and callback are always constructed as a matching pair.
_ => unreachable!("dialog body/callback kinds always match"),
}
}
/// Whether the OK action is currently allowed (controls Enter + label styling).
fn ok_enabled(&self) -> bool {
match &self.body {
DialogBody::Text { .. } => true,
DialogBody::List {
input,
allow_create,
..
} => self.filtered_len() > 0 || (*allow_create && !input.value().is_empty()),
}
}
/// Mutable access to whichever [`Input`] field this dialog owns.
fn input_mut(&mut self) -> &mut Input {
match &mut self.body {
DialogBody::Text { input } | DialogBody::List { input, .. } => input,
}
}
/// Number of list entries currently passing the filter (0 for a text dialog).
fn filtered_len(&self) -> usize {
filtered_count(&self.body)
}
/// Draws the dialog centered over the whole frame: dims the background, then
/// renders the bordered window, the field (centered for a text dialog, atop the
/// list for a list dialog), and the OK/Cancel labels.
///
/// Takes `&mut Frame` (not a `Widget`) because the text field needs a real
/// terminal cursor placed via [`Frame::set_cursor_position`].
pub fn draw(&self, frame: &mut Frame) {
let area = frame.area();
dim_area(area, frame.buffer_mut());
// Size the window: a list dialog is taller to show its entries.
let is_list = matches!(self.body, DialogBody::List { .. });
let want_w = 50u16;
let want_h = if is_list { 16 } else { 8 };
let w = want_w.min(area.width.saturating_sub(2)).max(8);
let h = want_h.min(area.height.saturating_sub(2)).max(5);
let rect = Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w,
height: h,
};
// Window frame: clear underlying content, then a bordered titled block.
frame.render_widget(Clear, rect);
let block = Block::bordered()
.title(format!(" {} ", self.title))
.border_style(Style::default().fg(BORDER).bg(BG))
.style(Style::default().bg(BG));
let inner = block.inner(rect);
frame.render_widget(block, rect);
// Carve the footer off the bottom; the rest is the body (field [+ list]).
let [body_area, footer_area] =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
let input = match &self.body {
DialogBody::Text { input } | DialogBody::List { input, .. } => input,
};
if let DialogBody::List {
all,
selected,
input,
..
} = &self.body
{
// List dialog: the filter field sits atop the scrolling list.
let [field_area, list_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(body_area);
draw_field(frame, field_area, input);
draw_list(frame, list_area, all, input.value(), *selected);
} else {
// Text dialog: a single field box, centered horizontally and vertically.
let fw = body_area.width.saturating_sub(4).max(1);
let field_area = Rect {
x: body_area.x + body_area.width.saturating_sub(fw) / 2,
y: body_area.y + body_area.height / 2,
width: fw,
height: 1,
};
draw_field(frame, field_area, input);
}
// Footer: [enter] OK (dimmed when disabled) and [esc] Cancel.
let ok_style = if self.ok_enabled() {
Style::default().fg(Color::White).bg(BG)
} else {
Style::default().fg(Color::DarkGray).bg(BG)
};
let key_style = Style::default().fg(Color::Rgb(150, 200, 255)).bg(BG);
let footer = Line::from(vec![
Span::styled("[enter] ", key_style),
Span::styled("OK ", ok_style),
Span::styled("[esc] ", key_style),
Span::styled("Cancel", Style::default().fg(Color::White).bg(BG)),
]);
frame.render_widget(Paragraph::new(footer), footer_area);
}
}
/// Counts the entries passing a list body's current filter (0 for a text body).
fn filtered_count(body: &DialogBody) -> usize {
match body {
DialogBody::List { all, input, .. } => filtered_items(all, input.value()).len(),
DialogBody::Text { .. } => 0,
}
}
/// Returns references to the entries of `all` that start with `filter` (prefix match).
fn filtered_items<'a>(all: &'a [String], filter: &str) -> Vec<&'a String> {
all.iter().filter(|s| s.starts_with(filter)).collect()
}
/// Maps a key code to a [`tui_input`] edit request, or `None` if it doesn't edit text.
fn key_to_request(code: KeyCode) -> Option<InputRequest> {
match code {
KeyCode::Char(c) => Some(InputRequest::InsertChar(c)),
KeyCode::Backspace => Some(InputRequest::DeletePrevChar),
KeyCode::Delete => Some(InputRequest::DeleteNextChar),
KeyCode::Left => Some(InputRequest::GoToPrevChar),
KeyCode::Right => Some(InputRequest::GoToNextChar),
KeyCode::Home => Some(InputRequest::GoToStart),
KeyCode::End => Some(InputRequest::GoToEnd),
_ => None,
}
}
/// Renders an editable text field into `area`: a distinct [`FIELD_BG`] background
/// (so it reads as an input box), the current value, and a real terminal cursor at
/// the input's visual cursor column.
fn draw_field(frame: &mut Frame, area: Rect, input: &Input) {
if area.width == 0 {
return;
}
// The paragraph's style fills the whole field area, including empty cells.
frame.render_widget(
Paragraph::new(input.value()).style(Style::default().fg(Color::White).bg(FIELD_BG)),
area,
);
let cur_x = area.x + (input.visual_cursor() as u16).min(area.width.saturating_sub(1));
frame.set_cursor_position((cur_x, area.y));
}
/// Renders the filtered list into `area`, highlighting `selected` and scrolling so
/// the selection stays visible.
fn draw_list(frame: &mut Frame, area: Rect, all: &[String], filter: &str, selected: usize) {
if area.height == 0 {
return;
}
let items = filtered_items(all, filter);
let rows = area.height as usize;
// Scroll so the selected row is within the visible window.
let top = selected
.saturating_sub(rows.saturating_sub(1))
.min(items.len().saturating_sub(rows.max(1)));
let lines: Vec<Line> = items
.iter()
.enumerate()
.skip(top)
.take(rows)
.map(|(i, s)| {
let style = if i == selected {
Style::default().fg(Color::White).bg(SELECT_BG)
} else {
Style::default().fg(Color::Gray).bg(BG)
};
Line::from(Span::styled(s.as_str(), style))
})
.collect();
frame.render_widget(Paragraph::new(lines).style(Style::default().bg(BG)), area);
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
/// Builds a key-press event for a given key code.
fn key(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
/// Three sample list entries; "alpha"/"alps" share the "al" prefix.
fn items() -> Vec<String> {
vec!["alpha".into(), "beta".into(), "alps".into()]
}
#[test]
fn typing_filters_by_prefix_and_clamps_selection() {
let mut d: Dialog<()> = Dialog::list("t", items(), false, |_, _| {});
// No filter: all three entries pass.
assert_eq!(d.filtered_len(), 3);
// Move selection to the last entry, then filter so fewer remain.
d.handle_event(&key(KeyCode::Down));
d.handle_event(&key(KeyCode::Down)); // selected = 2
d.handle_event(&key(KeyCode::Char('a'))); // filter "a" -> alpha, alps
assert_eq!(d.filtered_len(), 2);
// The out-of-range selection is clamped back into the filtered list.
let DialogBody::List { selected, .. } = &d.body else {
unreachable!()
};
assert_eq!(*selected, 1);
}
#[test]
fn ok_disabled_when_no_match_and_create_not_allowed() {
let mut d: Dialog<()> = Dialog::list("t", items(), false, |_, _| {});
for c in "zzz".chars() {
d.handle_event(&key(KeyCode::Char(c)));
}
assert_eq!(d.filtered_len(), 0);
assert!(!d.ok_enabled());
// Enter is ignored while OK is disabled.
assert!(matches!(
d.handle_event(&key(KeyCode::Enter)),
DialogResult::Continue
));
}
#[test]
fn list_submit_selects_highlighted_entry() {
let mut out: Option<ListDialogResponse> = None;
let mut d: Dialog<Option<ListDialogResponse>> =
Dialog::list("t", items(), false, |r, c| *c = Some(r));
d.handle_event(&key(KeyCode::Down)); // select index 1 ("beta")
let res = d.handle_event(&key(KeyCode::Enter));
d.finish(res, &mut out);
assert!(matches!(out, Some(ListDialogResponse::Select(s)) if s == "beta"));
}
#[test]
fn list_submit_creates_when_allowed_and_no_match() {
let mut out: Option<ListDialogResponse> = None;
let mut d: Dialog<Option<ListDialogResponse>> =
Dialog::list("t", items(), true, |r, c| *c = Some(r));
for c in "gamma".chars() {
d.handle_event(&key(KeyCode::Char(c)));
}
assert!(d.ok_enabled());
let res = d.handle_event(&key(KeyCode::Enter));
d.finish(res, &mut out);
assert!(matches!(out, Some(ListDialogResponse::Create(s)) if s == "gamma"));
}
#[test]
fn esc_yields_cancel_for_list() {
let mut out: Option<ListDialogResponse> = None;
let d: Dialog<Option<ListDialogResponse>> =
Dialog::list("t", items(), false, |r, c| *c = Some(r));
d.finish(DialogResult::Cancel, &mut out);
assert!(matches!(out, Some(ListDialogResponse::Cancel)));
}
#[test]
fn text_dialog_reports_value_on_submit_and_none_on_cancel() {
// Submit returns the edited value.
let mut got: Option<Option<String>> = None;
let mut d: Dialog<Option<Option<String>>> =
Dialog::text("t", "hi", |v, c| *c = Some(v));
d.handle_event(&key(KeyCode::Char('!')));
let res = d.handle_event(&key(KeyCode::Enter));
d.finish(res, &mut got);
assert_eq!(got, Some(Some("hi!".to_string())));
// Cancel returns None.
let mut got2: Option<Option<String>> = None;
let d2: Dialog<Option<Option<String>>> = Dialog::text("t", "hi", |v, c| *c = Some(v));
d2.finish(DialogResult::Cancel, &mut got2);
assert_eq!(got2, Some(None));
}
}
+39
View File
@@ -0,0 +1,39 @@
//! `kiln-ui` — reusable, game-agnostic terminal UI widgets built on [`ratatui`].
//!
//! This crate is the home for kiln's front-end UI widgets, kept deliberately separate
//! from game concerns:
//!
//! - **Widget responsibilities only.** Each widget owns its *presentation* (drawing)
//! and *input handling*; it never references game/world types. No dependency on
//! `kiln-core`.
//! - **Generic over a host context.** A widget that needs to apply a result mutates a
//! caller-supplied context `Ctx` *only through callbacks* (e.g. [`dialog::Dialog<Ctx>`]),
//! so the front-end decides what a choice does without the widget knowing about it.
//! - **The host drives the loop.** Widgets expose plain `handle_event` / `draw` entry
//! points; the front-end owns the event loop, decides when a widget is focused, and
//! routes input to it.
//!
//! This is also where a full-screen script text-editor widget will live (wrapping a
//! third-party editor crate) once that work begins.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
pub mod dialog;
/// Dims every cell in `area` so a modal overlay drawn on top stands out and the
/// content beneath does not show through.
///
/// Shared scaffolding: used by [`dialog`] here, and re-borrowed by kiln-tui's
/// overlay-frame renderer so both modal styles dim the background identically.
pub fn dim_area(area: Rect, buf: &mut Buffer) {
let dim_style = Style::default().fg(Color::Rgb(60, 60, 60)).bg(Color::Black);
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(dim_style);
}
}
}
}