glyph picker
This commit is contained in:
@@ -40,7 +40,7 @@ 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` 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-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-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. Holds the dialog/text-field system (`dialog::Dialog<Ctx>`), the shared `dim_area` overlay helper, and a hand-written modeless script code editor (`code_editor::CodeEditor`). The **syntect** (highlighting) and **arboard** (clipboard) deps make this crate desktop/terminal-only (not WASM), unlike its ratatui core.
|
- **`kiln-ui`** — reusable terminal UI widgets on **ratatui**, mostly **game-agnostic**. Each widget owns its presentation + input handling and is generic over a host context `Ctx` it mutates only through callbacks. Holds the dialog/text-field system (`dialog::Dialog<Ctx>`), the shared `dim_area` overlay helper, a hand-written modeless script code editor (`code_editor::CodeEditor`), and the **glyph picker** (`glyph_dialog::GlyphDialog<Ctx>`). The glyph picker is the **one** widget that depends on `kiln-core` (for `Glyph`/`Rgba8` + the CP437 table); everything else is game-agnostic. The **syntect** (highlighting) and **arboard** (clipboard) deps make this crate desktop/terminal-only (not WASM), unlike its ratatui core.
|
||||||
- **`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`).
|
- **`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", "kiln-ui"]`, with `ratatui` pinned in `[workspace.dependencies]` so kiln-ui and kiln-tui share one version (their public APIs exchange ratatui types).
|
Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui", "kiln-ui"]`, with `ratatui` pinned in `[workspace.dependencies]` so kiln-ui and kiln-tui share one version (their public APIs exchange ratatui types).
|
||||||
@@ -52,6 +52,9 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
|
|||||||
**`kiln-core/src/glyph.rs`** — per-cell visual:
|
**`kiln-core/src/glyph.rs`** — per-cell visual:
|
||||||
- `Glyph` (`Copy`, `Eq`, `Hash`) — `tile: u32` (tilesheet index), `fg/bg: Rgba8`. `Glyph::player()` is a `const fn` (tile 64 `@`, white on dark blue); all other glyphs come from map files. `Hash` is hand-implemented via packed `u32` representations so it stays in sync with `Eq`.
|
- `Glyph` (`Copy`, `Eq`, `Hash`) — `tile: u32` (tilesheet index), `fg/bg: Rgba8`. `Glyph::player()` is a `const fn` (tile 64 `@`, white on dark blue); all other glyphs come from map files. `Hash` is hand-implemented via packed `u32` representations so it stays in sync with `Eq`.
|
||||||
|
|
||||||
|
**`kiln-core/src/cp437.rs`** — tile-index → character mapping (`pub`):
|
||||||
|
- `CP437: [char; 256]` — full code-page-437 table (graphic glyphs for 0x00–0x1F, box-drawing for 0xB0–0xDF, etc.). `tile_to_char(tile: u32) -> char` indexes it for `tile < 256`; falls back to `char::from_u32` then a blank space. Unit-tested. Lives in core (not a front-end) because it is the *meaning* of a tile index under the default font; used by kiln-tui's renderer (`render.rs`) and kiln-ui's glyph picker alike.
|
||||||
|
|
||||||
**`kiln-core/src/archetype.rs`** — element taxonomy:
|
**`kiln-core/src/archetype.rs`** — element taxonomy:
|
||||||
- `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Pusher(Direction)`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()`. `Crate` pushable any direction (CP437 ■, char 254); `HCrate`/`VCrate` pushable east/west (↔, char 29) / north/south (↕, char 18) only. `Pusher(dir)` (`pusher_north|south|east|west`) is a **map-file keyword only**: at load it expands into a scripted object (see [`builtin_scripts`]), never a terrain cell. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` parses by name; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`).
|
- `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Pusher(Direction)`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()`. `Crate` pushable any direction (CP437 ■, char 254); `HCrate`/`VCrate` pushable east/west (↔, char 29) / north/south (↕, char 18) only. `Pusher(dir)` (`pusher_north|south|east|west`) is a **map-file keyword only**: at load it expands into a scripted object (see [`builtin_scripts`]), never a terrain cell. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` parses by name; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`).
|
||||||
|
|
||||||
@@ -131,11 +134,11 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
|
|||||||
|
|
||||||
### kiln-ui modules
|
### 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.
|
Reusable terminal UI widgets on **ratatui**, mostly **game-agnostic**. 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).
|
||||||
|
|
||||||
**`kiln-ui/src/lib.rs`** — crate root:
|
**`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.
|
- `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.
|
||||||
- Modules: `pub mod dialog; pub mod code_editor; pub mod text_field;` (widgets) plus `mod clipboard; mod text;` (`pub(crate)` shared internals — see below).
|
- 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:
|
**`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`.
|
- `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`.
|
||||||
@@ -147,6 +150,11 @@ Reusable, **game-agnostic** terminal UI widgets on **ratatui** — no `kiln-core
|
|||||||
- `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`.
|
- `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`.
|
- `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-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 hex-color `TextField`s (`fg`/`bg`, 6 bare hex digits), and a `Focus` (`Grid`/`Fg`/`Bg`).
|
||||||
|
- `handle_event(&Event) -> DialogResult` (reuses `dialog::DialogResult`) — `Esc`→`Cancel`; `Enter`→`Submit` only when both color fields parse (`parse_hex6`); `Tab`/`Shift-Tab`/`BackTab` cycle focus; when the grid is focused arrows move the selection in a 32×8 grid (clamped), otherwise keys edit the focused field. `finish(result, &mut Ctx)` fires the callback (a `Submit` with both colors valid yields `Some(Glyph{..})`).
|
||||||
|
- `draw(&self, &mut Frame)` — dims + centered bordered box; renders the 256-char grid via `kiln_core::cp437::tile_to_char` (each glyph drawn in the chosen fg/bg when both colors are valid, else gray-on-black; the selected cell is a bright cursor when the grid is focused, else reversed), the labeled `fg`/`bg` swatch fields (focused label's background highlighted; invalid field boxed red), and the standard footer. Places a real terminal cursor in the focused color field. Unit-tested (seed round-trip, focus cycling, grid clamp, invalid-hex disables OK, Submit/Cancel outcomes) 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; replaced `tui-input`):
|
**`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; replaced `tui-input`):
|
||||||
- `TextField` — `text: String` + a char-indexed `cursor` + a `Clipboard`. `new(initial)`, `value()`, `display_cursor()` (caret's display column), 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 (typing, delete, movement, paste, wide-char cursor).
|
- `TextField` — `text: String` + a char-indexed `cursor` + a `Clipboard`. `new(initial)`, `value()`, `display_cursor()` (caret's display column), 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 (typing, delete, movement, paste, wide-char cursor).
|
||||||
|
|
||||||
@@ -167,24 +175,25 @@ The terminal **player + world editor**, depending on both `kiln-core` and `kiln-
|
|||||||
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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's glyph picker (`GlyphDialog::draw`, when open) / dialog (`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:
|
**`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.
|
- `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:
|
**`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>>`, `code_editor: Option<CodeEditor>` (the open script editor; replaces the board view), 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`.
|
- `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>>`, `code_editor: Option<CodeEditor>` (the open script editor; replaces the board view), `glyph_dialog: Option<GlyphDialog<EditorState>>` (the open glyph picker overlay), 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.
|
- `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`; boards → select-only; scripts → **opens the script in `code_editor`**). `open_script_editor(name)` builds a `CodeEditor` from `world.scripts[name]`; `close_script_editor()` saves its text back into `world.scripts` (in-memory, like the other dialogs).
|
- 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`; boards → select-only; scripts → **opens the script in `code_editor`**). `open_script_editor(name)` builds a `CodeEditor` from `world.scripts[name]`; `close_script_editor()` saves its text back into `world.scripts` (in-memory, like the other dialogs).
|
||||||
- `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)`. `handle_code_editor_input(event, ed)` — forwards to the open code editor and, on `Exit` (Esc), calls `close_script_editor()`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — the **code editor (when open) else** the board (with blinking cursor + coord readout) in the left area, a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel.
|
- `open_glyph_picker()` — seeds a `GlyphDialog` from `board.glyph_at(cursor)` and opens it; its callback logs the chosen glyph to `ed.log` (no paint tool yet). Bound to **`g`** in `handle_editor_input`.
|
||||||
|
- `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)`. `handle_glyph_dialog_input(event, ed)` — the same pattern for the open `glyph_dialog`. `handle_code_editor_input(event, ed)` — forwards to the open code editor and, on `Exit` (Esc), calls `close_script_editor()`. `draw_editor(frame, ed, ui)` / `editor_layout(..)` — the **code editor (when open) else** the board (with blinking cursor + coord readout) in the left area, a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel.
|
||||||
|
|
||||||
**`kiln-tui/src/ui.rs`** — front-end presentation state (not on `GameState`):
|
**`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.
|
- `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.
|
- `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:
|
**`kiln-tui/src/input.rs`** — input mode + dispatch:
|
||||||
- `InputMode { Board, Scroll, Menu, Editor, Dialog, CodeEditor, 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 `CodeEditor` when a script editor is open, else `Editor`; `Play` → `Scroll` when a scroll is active else `Board`).
|
- `InputMode { Board, Scroll, Menu, Editor, Dialog, CodeEditor, GlyphDialog, Ignore, Frozen }`. `current_input_mode(mode, ui)` — derives the mode: an active animation declares its own (`input_mode_during`); a pending transition → `Ignore`; an open `ui.menu` → `Menu`; else from `Mode` (`Edit` → `GlyphDialog` when the glyph picker is open, else `Dialog` when a dialog is open, else `CodeEditor` when a script editor 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.
|
- `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, `g` opens the glyph picker, `Esc` → `menu_escape`, other letters → `menu_key`, right-click moves the cursor, drag resizes the sidebar), `handle_scroll_input` (scroll nav + choice letters 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:
|
**`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`.
|
- `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`.
|
||||||
@@ -201,10 +210,6 @@ The terminal **player + world editor**, depending on both `kiln-core` and `kiln-
|
|||||||
**`kiln-tui/src/scroll_overlay.rs`** — scroll overlay widget:
|
**`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.
|
- `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 0x00–0x1F, box-drawing for 0xB0–0xDF, etc.).
|
|
||||||
- `tile_to_char(tile: u32) -> char` — indexes the table for `tile < 256`; falls back to `char::from_u32` then a blank space. Unit-tested.
|
|
||||||
|
|
||||||
**`kiln-tui/src/render.rs`** — board → ratatui buffer:
|
**`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.
|
- `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`).
|
- `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`).
|
||||||
|
|||||||
Generated
+2
@@ -775,6 +775,8 @@ name = "kiln-ui"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
|
"color",
|
||||||
|
"kiln-core",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"syntect",
|
"syntect",
|
||||||
"unicode-width",
|
"unicode-width",
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
//! CP437 → Unicode mapping for rendering glyph tile indices as terminal text.
|
//! CP437 → Unicode mapping for interpreting glyph tile indices as characters.
|
||||||
//!
|
//!
|
||||||
//! kiln stores each cell's visual as a `tile: u32` index into a bitmap font.
|
//! kiln stores each cell's visual as a `tile: u32` index into a bitmap font.
|
||||||
//! The default kiln font is IBM CP437, where the tile index equals the CP437
|
//! The default kiln font is IBM CP437, where the tile index equals the CP437
|
||||||
//! code point. A terminal can't draw the bitmap font, so kiln-tui reinterprets
|
//! code point. A terminal can't draw the bitmap font, so a front-end reinterprets
|
||||||
//! the tile index as a character via this table and prints that character with
|
//! the tile index as a character via this table and prints that character with
|
||||||
//! the cell's foreground/background colors.
|
//! the cell's foreground/background colors. This lives in kiln-core (not a
|
||||||
|
//! front-end) because it is the *meaning* of a tile index under the default font,
|
||||||
|
//! shared by every renderer and by the editor's glyph picker.
|
||||||
|
|
||||||
/// CP437 code point → Unicode scalar value.
|
/// CP437 code point → Unicode scalar value.
|
||||||
///
|
///
|
||||||
@@ -2,6 +2,8 @@ mod action;
|
|||||||
mod archetype;
|
mod archetype;
|
||||||
mod board;
|
mod board;
|
||||||
mod builtin_scripts;
|
mod builtin_scripts;
|
||||||
|
/// CP437 tile-index → character mapping ([`cp437::tile_to_char`]) for the default font.
|
||||||
|
pub mod cp437;
|
||||||
/// Procedural floor generators ([`floor::FloorGenerator`]).
|
/// Procedural floor generators ([`floor::FloorGenerator`]).
|
||||||
pub mod floor;
|
pub mod floor;
|
||||||
/// Core game types: [`board::Board`], [`glyph::Glyph`], [`archetype::Archetype`], etc.
|
/// Core game types: [`board::Board`], [`glyph::Glyph`], [`archetype::Archetype`], etc.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
use kiln_ui::code_editor::{CodeEditor, CodeEditorOutcome};
|
use kiln_ui::code_editor::{CodeEditor, CodeEditorOutcome};
|
||||||
use kiln_ui::dialog::{Dialog, DialogResult, ListDialogResponse};
|
use kiln_ui::dialog::{Dialog, DialogResult, ListDialogResponse};
|
||||||
|
use kiln_ui::glyph_dialog::GlyphDialog;
|
||||||
use crate::log::{log_preview_line, LogWidget};
|
use crate::log::{log_preview_line, LogWidget};
|
||||||
use crate::render::{board_to_screen, screen_to_board, BoardWidget};
|
use crate::render::{board_to_screen, screen_to_board, BoardWidget};
|
||||||
use crate::ui::Ui;
|
use crate::ui::Ui;
|
||||||
@@ -55,6 +56,9 @@ pub(crate) struct EditorState {
|
|||||||
/// The open script code editor, if any. While `Some` it replaces the board view and
|
/// The open script code editor, if any. While `Some` it replaces the board view and
|
||||||
/// the editor is in [`InputMode::CodeEditor`](crate::input::InputMode::CodeEditor).
|
/// the editor is in [`InputMode::CodeEditor`](crate::input::InputMode::CodeEditor).
|
||||||
pub(crate) code_editor: Option<CodeEditor>,
|
pub(crate) code_editor: Option<CodeEditor>,
|
||||||
|
/// The open glyph picker overlay, if any. While `Some` the editor is in
|
||||||
|
/// [`InputMode::GlyphDialog`](crate::input::InputMode::GlyphDialog).
|
||||||
|
pub(crate) glyph_dialog: Option<GlyphDialog<EditorState>>,
|
||||||
/// Cursor position in board cells, clamped to the board bounds.
|
/// Cursor position in board cells, clamped to the board bounds.
|
||||||
cursor: (i32, i32),
|
cursor: (i32, i32),
|
||||||
/// Width of the right-hand sidebar in terminal columns (mouse-resizable).
|
/// Width of the right-hand sidebar in terminal columns (mouse-resizable).
|
||||||
@@ -81,6 +85,7 @@ impl EditorState {
|
|||||||
menu: vec![MenuLevel::Main],
|
menu: vec![MenuLevel::Main],
|
||||||
dialog: None,
|
dialog: None,
|
||||||
code_editor: None,
|
code_editor: None,
|
||||||
|
glyph_dialog: None,
|
||||||
cursor: (0, 0),
|
cursor: (0, 0),
|
||||||
sidebar_width: DEFAULT_SIDEBAR_WIDTH,
|
sidebar_width: DEFAULT_SIDEBAR_WIDTH,
|
||||||
blink_on: true,
|
blink_on: true,
|
||||||
@@ -176,6 +181,27 @@ impl EditorState {
|
|||||||
self.dialog = Some(Dialog::list("Boards", names, false, |_resp, _ed: &mut EditorState| {}));
|
self.dialog = Some(Dialog::list("Boards", names, false, |_resp, _ed: &mut EditorState| {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens the glyph picker seeded from the glyph currently under the cursor.
|
||||||
|
/// On OK the chosen glyph is logged (there is no paint tool yet).
|
||||||
|
pub(crate) fn open_glyph_picker(&mut self) {
|
||||||
|
let glyph = {
|
||||||
|
let board = self.board();
|
||||||
|
board.glyph_at(self.cursor.0 as usize, self.cursor.1 as usize)
|
||||||
|
};
|
||||||
|
self.glyph_dialog = Some(GlyphDialog::new(
|
||||||
|
"Glyph",
|
||||||
|
glyph,
|
||||||
|
|chosen, ed: &mut EditorState| {
|
||||||
|
if let Some(g) = chosen {
|
||||||
|
ed.log.push(LogLine::raw(format!(
|
||||||
|
"glyph tile={} fg=#{:02X}{:02X}{:02X} bg=#{:02X}{:02X}{:02X}",
|
||||||
|
g.tile, g.fg.r, g.fg.g, g.fg.b, g.bg.r, g.bg.g, g.bg.b
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/// Borrows the board currently being edited.
|
/// Borrows the board currently being edited.
|
||||||
pub(crate) fn board(&self) -> Ref<'_, Board> {
|
pub(crate) fn board(&self) -> Ref<'_, Board> {
|
||||||
self.world.boards[&self.board_name].borrow()
|
self.world.boards[&self.board_name].borrow()
|
||||||
@@ -235,6 +261,24 @@ pub(crate) fn handle_dialog_input(event: Event, ed: &mut EditorState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles an input event while the glyph picker is open ([`InputMode::GlyphDialog`](crate::input::InputMode::GlyphDialog)).
|
||||||
|
///
|
||||||
|
/// Mirrors [`handle_dialog_input`]: forwards to the picker and, on `Submit`/`Cancel`,
|
||||||
|
/// takes it out of [`EditorState::glyph_dialog`] and fires its callback via
|
||||||
|
/// [`GlyphDialog::finish`].
|
||||||
|
pub(crate) fn handle_glyph_dialog_input(event: Event, ed: &mut EditorState) {
|
||||||
|
let Some(dialog) = ed.glyph_dialog.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match dialog.handle_event(&event) {
|
||||||
|
DialogResult::Continue => {}
|
||||||
|
result => {
|
||||||
|
let dialog = ed.glyph_dialog.take().expect("glyph dialog present");
|
||||||
|
dialog.finish(result, ed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handles an input event while the code editor is open ([`InputMode::CodeEditor`](crate::input::InputMode::CodeEditor)).
|
/// Handles an input event while the code editor is open ([`InputMode::CodeEditor`](crate::input::InputMode::CodeEditor)).
|
||||||
///
|
///
|
||||||
/// Forwards key/mouse events to the editor; on `Exit` (Esc) it closes and saves the script.
|
/// Forwards key/mouse events to the editor; on `Exit` (Esc) it closes and saves the script.
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ pub enum InputMode {
|
|||||||
Dialog,
|
Dialog,
|
||||||
/// The script code editor is open; all input drives the editor.
|
/// The script code editor is open; all input drives the editor.
|
||||||
CodeEditor,
|
CodeEditor,
|
||||||
|
/// The glyph picker is open over the editor; all input drives the picker.
|
||||||
|
GlyphDialog,
|
||||||
/// An animation is running and has not claimed any input mode; all input is discarded.
|
/// An animation is running and has not claimed any input mode; all input is discarded.
|
||||||
Ignore,
|
Ignore,
|
||||||
/// Reserved for a post-transition freeze; nothing currently produces this mode.
|
/// Reserved for a post-transition freeze; nothing currently produces this mode.
|
||||||
@@ -54,7 +56,9 @@ pub(crate) fn current_input_mode(mode: &Mode, ui: &Ui) -> InputMode {
|
|||||||
}
|
}
|
||||||
match mode {
|
match mode {
|
||||||
Mode::Edit(ed) => {
|
Mode::Edit(ed) => {
|
||||||
if ed.dialog.is_some() {
|
if ed.glyph_dialog.is_some() {
|
||||||
|
InputMode::GlyphDialog
|
||||||
|
} else if ed.dialog.is_some() {
|
||||||
InputMode::Dialog
|
InputMode::Dialog
|
||||||
} else if ed.code_editor.is_some() {
|
} else if ed.code_editor.is_some() {
|
||||||
InputMode::CodeEditor
|
InputMode::CodeEditor
|
||||||
@@ -122,6 +126,8 @@ pub(crate) fn handle_editor_input(event: Event, term_w: u16, ed: &mut EditorStat
|
|||||||
KeyCode::Left => ed.move_cursor(-1, 0),
|
KeyCode::Left => ed.move_cursor(-1, 0),
|
||||||
KeyCode::Right => ed.move_cursor(1, 0),
|
KeyCode::Right => ed.move_cursor(1, 0),
|
||||||
KeyCode::Char('l') => ui.log.toggle(),
|
KeyCode::Char('l') => ui.log.toggle(),
|
||||||
|
// `g` opens the glyph picker for the cell under the cursor.
|
||||||
|
KeyCode::Char('g') => ed.open_glyph_picker(),
|
||||||
// A menu letter opens its dialog; unmatched letters are ignored.
|
// A menu letter opens its dialog; unmatched letters are ignored.
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
ed.menu_key(c);
|
ed.menu_key(c);
|
||||||
|
|||||||
+10
-5
@@ -5,10 +5,9 @@
|
|||||||
//! (or HJKL). There is no editor — this is a play-only front-end.
|
//! (or HJKL). There is no editor — this is a play-only front-end.
|
||||||
//!
|
//!
|
||||||
//! Rendering is text-based: kiln's bitmap-font tile indices are reinterpreted
|
//! Rendering is text-based: kiln's bitmap-font tile indices are reinterpreted
|
||||||
//! as characters (see [`cp437`]) and drawn with each cell's RGB colors.
|
//! as characters (see [`kiln_core::cp437`]) and drawn with each cell's RGB colors.
|
||||||
|
|
||||||
mod animation;
|
mod animation;
|
||||||
mod cp437;
|
|
||||||
mod editor;
|
mod editor;
|
||||||
mod input;
|
mod input;
|
||||||
mod log;
|
mod log;
|
||||||
@@ -25,7 +24,10 @@ mod utils;
|
|||||||
mod editor_menu;
|
mod editor_menu;
|
||||||
|
|
||||||
use crate::animation::{AnimWidget, AnimationLayer};
|
use crate::animation::{AnimWidget, AnimationLayer};
|
||||||
use crate::editor::{EditorState, draw_editor, handle_code_editor_input, handle_dialog_input};
|
use crate::editor::{
|
||||||
|
EditorState, draw_editor, handle_code_editor_input, handle_dialog_input,
|
||||||
|
handle_glyph_dialog_input,
|
||||||
|
};
|
||||||
use crate::input::{
|
use crate::input::{
|
||||||
InputMode, current_input_mode, handle_board_input, handle_editor_input, handle_menu_input,
|
InputMode, current_input_mode, handle_board_input, handle_editor_input, handle_menu_input,
|
||||||
handle_scroll_input,
|
handle_scroll_input,
|
||||||
@@ -170,6 +172,7 @@ fn run(terminal: &mut ratatui::DefaultTerminal, mode: &mut Mode, ui: &mut Ui) ->
|
|||||||
Mode::Edit(ed) => match input_mode {
|
Mode::Edit(ed) => match input_mode {
|
||||||
InputMode::Editor => handle_editor_input(event, size.width, ed, ui),
|
InputMode::Editor => handle_editor_input(event, size.width, ed, ui),
|
||||||
InputMode::Dialog => handle_dialog_input(event, ed),
|
InputMode::Dialog => handle_dialog_input(event, ed),
|
||||||
|
InputMode::GlyphDialog => handle_glyph_dialog_input(event, ed),
|
||||||
InputMode::CodeEditor => handle_code_editor_input(event, ed),
|
InputMode::CodeEditor => handle_code_editor_input(event, ed),
|
||||||
_ => unreachable!(
|
_ => unreachable!(
|
||||||
"Menu and Ignore handled above; Board/Scroll/Frozen input modes only occur in Play mode"
|
"Menu and Ignore handled above; Board/Scroll/Frozen input modes only occur in Play mode"
|
||||||
@@ -263,9 +266,11 @@ fn draw(frame: &mut Frame, mode: &mut Mode, ui: &mut Ui) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The dialog overlay only exists while editing.
|
// The dialog / glyph-picker overlays only exist while editing.
|
||||||
Mode::Edit(ed) => {
|
Mode::Edit(ed) => {
|
||||||
if let Some(d) = &ed.dialog {
|
if let Some(g) = &ed.glyph_dialog {
|
||||||
|
g.draw(frame);
|
||||||
|
} else if let Some(d) = &ed.dialog {
|
||||||
d.draw(frame);
|
d.draw(frame);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
//! Rendering a kiln [`Board`] into a ratatui buffer as colored text.
|
//! Rendering a kiln [`Board`] into a ratatui buffer as colored text.
|
||||||
//!
|
//!
|
||||||
//! Each board cell becomes one terminal cell: the glyph's tile index is mapped
|
//! Each board cell becomes one terminal cell: the glyph's tile index is mapped
|
||||||
//! to a character (see [`crate::cp437`]) and drawn with the glyph's foreground
|
//! to a character (see [`kiln_core::cp437`]) and drawn with the glyph's foreground
|
||||||
//! and background colors. Objects are drawn over their floor cell, and the
|
//! and background colors. Objects are drawn over their floor cell, and the
|
||||||
//! player is drawn on top of everything.
|
//! player is drawn on top of everything.
|
||||||
|
|
||||||
use crate::cp437::tile_to_char;
|
use kiln_core::cp437::tile_to_char;
|
||||||
use kiln_core::Board;
|
use kiln_core::Board;
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
# First kiln-core dependency in kiln-ui: the glyph picker needs `Glyph`/`Rgba8`.
|
||||||
|
kiln-core = { path = "../kiln-core" }
|
||||||
|
color = "0.3.3"
|
||||||
# text-only clipboard: drop the default `image-data` deps (image/tiff/jpeg), add Linux Wayland support
|
# text-only clipboard: drop the default `image-data` deps (image/tiff/jpeg), add Linux Wayland support
|
||||||
arboard = { version = "3", default-features = false, features = ["wayland-data-control"] }
|
arboard = { version = "3", default-features = false, features = ["wayland-data-control"] }
|
||||||
ratatui = { workspace = true }
|
ratatui = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
//! A modal **glyph picker** dialog: choose a [`Glyph`] (a CP437 tile index plus
|
||||||
|
//! foreground/background colors) from a 32×8 character grid and two hex color fields.
|
||||||
|
//!
|
||||||
|
//! This mirrors the [`dialog`](crate::dialog) API — build a [`GlyphDialog`], route
|
||||||
|
//! events to [`handle_event`](GlyphDialog::handle_event), [`draw`](GlyphDialog::draw)
|
||||||
|
//! it, and on dismissal call [`finish`](GlyphDialog::finish), which fires a stored
|
||||||
|
//! callback with the chosen [`Glyph`] (or `None` on cancel) plus `&mut Ctx`.
|
||||||
|
//!
|
||||||
|
//! It is the first kiln-ui widget that depends on `kiln-core` (for [`Glyph`]/`Rgba8`
|
||||||
|
//! and the [`tile_to_char`] table); kiln-ui is otherwise game-agnostic.
|
||||||
|
|
||||||
|
use color::Rgba8;
|
||||||
|
use kiln_core::cp437::tile_to_char;
|
||||||
|
use kiln_core::glyph::Glyph;
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
|
||||||
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Clear, Paragraph};
|
||||||
|
|
||||||
|
use crate::dialog::DialogResult;
|
||||||
|
use crate::dim_area;
|
||||||
|
use crate::text_field::TextField;
|
||||||
|
|
||||||
|
/// Background color filling the dialog window (matches [`crate::dialog`]).
|
||||||
|
const BG: Color = Color::Rgb(10, 15, 35);
|
||||||
|
/// Border color for the dialog window.
|
||||||
|
const BORDER: Color = Color::Rgb(80, 130, 210);
|
||||||
|
/// Background highlight applied to the *label* of the currently focused color field.
|
||||||
|
const LABEL_FOCUS_BG: Color = Color::Rgb(40, 60, 110);
|
||||||
|
/// Color for the bracketed key hints in the footer.
|
||||||
|
const KEY: Color = Color::Rgb(150, 200, 255);
|
||||||
|
/// Background of the selected grid cell while the grid is focused (a bright cursor).
|
||||||
|
const CURSOR_BG: Color = Color::Rgb(255, 215, 0);
|
||||||
|
|
||||||
|
/// Number of columns in the character grid (32 cols × 8 rows = 256 tiles).
|
||||||
|
const GRID_COLS: u32 = 32;
|
||||||
|
/// Number of rows in the character grid.
|
||||||
|
const GRID_ROWS: u32 = 8;
|
||||||
|
|
||||||
|
/// Which sub-control of the picker currently has keyboard focus.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Focus {
|
||||||
|
/// The 32×8 character grid (arrow keys move the selection).
|
||||||
|
Grid,
|
||||||
|
/// The foreground-color hex field.
|
||||||
|
Fg,
|
||||||
|
/// The background-color hex field.
|
||||||
|
Bg,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Boxed callback fired once when the picker is dismissed: `Some(glyph)` on OK,
|
||||||
|
/// `None` on cancel.
|
||||||
|
type GlyphCallback<Ctx> = Box<dyn FnOnce(Option<Glyph>, &mut Ctx)>;
|
||||||
|
|
||||||
|
/// An open glyph-picker dialog, generic over the host context `Ctx` its callback mutates.
|
||||||
|
pub struct GlyphDialog<Ctx> {
|
||||||
|
/// Title shown in the window's top border.
|
||||||
|
title: String,
|
||||||
|
/// The currently selected tile index (`0..256`).
|
||||||
|
tile: u32,
|
||||||
|
/// Foreground color as bare hex digits (6 chars when valid, no `#`).
|
||||||
|
fg: TextField,
|
||||||
|
/// Background color as bare hex digits (6 chars when valid, no `#`).
|
||||||
|
bg: TextField,
|
||||||
|
/// Which sub-control currently has focus.
|
||||||
|
focus: Focus,
|
||||||
|
/// The callback fired exactly once on dismissal.
|
||||||
|
callback: GlyphCallback<Ctx>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Ctx> GlyphDialog<Ctx> {
|
||||||
|
/// Builds a glyph picker seeded from `initial`. The tile selection starts at
|
||||||
|
/// `initial.tile` and the two hex fields are pre-filled from `initial.fg`/`initial.bg`
|
||||||
|
/// (6 uppercase hex digits). `on_done` receives `Some(glyph)` on OK or `None` on
|
||||||
|
/// cancel, plus `&mut Ctx`.
|
||||||
|
pub fn new(
|
||||||
|
title: impl Into<String>,
|
||||||
|
initial: Glyph,
|
||||||
|
on_done: impl FnOnce(Option<Glyph>, &mut Ctx) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
title: title.into(),
|
||||||
|
tile: initial.tile,
|
||||||
|
fg: TextField::sized(hex6(initial.fg), 6usize),
|
||||||
|
bg: TextField::sized(hex6(initial.bg), 6usize),
|
||||||
|
focus: Focus::Grid,
|
||||||
|
callback: Box::new(on_done),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the picker for an input event and reports whether it should close.
|
||||||
|
///
|
||||||
|
/// `Esc` → [`DialogResult::Cancel`]; `Enter` → [`DialogResult::Submit`] only when
|
||||||
|
/// both color fields are valid hex; `Tab`/`Shift-Tab` cycle focus; arrow keys move
|
||||||
|
/// the grid selection when the grid is focused, otherwise edit the focused field.
|
||||||
|
pub fn handle_event(&mut self, event: &Event) -> DialogResult {
|
||||||
|
let Event::Key(key) = event else {
|
||||||
|
return DialogResult::Continue;
|
||||||
|
};
|
||||||
|
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => DialogResult::Cancel,
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if self.ok_enabled() {
|
||||||
|
DialogResult::Submit
|
||||||
|
} else {
|
||||||
|
DialogResult::Continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Tab cycles focus forward; Shift-Tab (or BackTab) cycles backward.
|
||||||
|
KeyCode::Tab if shift => self.cycle_focus(false),
|
||||||
|
KeyCode::Tab => self.cycle_focus(true),
|
||||||
|
KeyCode::BackTab => self.cycle_focus(false),
|
||||||
|
// Everything else either moves the grid cursor or edits the focused field.
|
||||||
|
_ => {
|
||||||
|
match self.focus {
|
||||||
|
Focus::Grid => self.move_grid(key.code),
|
||||||
|
Focus::Fg => {
|
||||||
|
self.fg.handle_key(*key);
|
||||||
|
}
|
||||||
|
Focus::Bg => {
|
||||||
|
self.bg.handle_key(*key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DialogResult::Continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes the dialog and fires its callback. `result` must be the
|
||||||
|
/// [`DialogResult`] just returned by [`handle_event`](GlyphDialog::handle_event);
|
||||||
|
/// only [`DialogResult::Submit`] with both colors valid yields `Some(glyph)`.
|
||||||
|
pub fn finish(self, result: DialogResult, ctx: &mut Ctx) {
|
||||||
|
let chosen = if matches!(result, DialogResult::Submit) {
|
||||||
|
self.glyph()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
(self.callback)(chosen, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The chosen [`Glyph`], or `None` if either color field is currently invalid.
|
||||||
|
fn glyph(&self) -> Option<Glyph> {
|
||||||
|
let fg = parse_hex6(self.fg.value())?;
|
||||||
|
let bg = parse_hex6(self.bg.value())?;
|
||||||
|
Some(Glyph {
|
||||||
|
tile: self.tile,
|
||||||
|
fg,
|
||||||
|
bg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether OK is currently allowed (both color fields parse as valid hex).
|
||||||
|
fn ok_enabled(&self) -> bool {
|
||||||
|
parse_hex6(self.fg.value()).is_some() && parse_hex6(self.bg.value()).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves focus to the next (or previous) sub-control, wrapping around.
|
||||||
|
fn cycle_focus(&mut self, forward: bool) -> DialogResult {
|
||||||
|
self.focus = match (self.focus, forward) {
|
||||||
|
(Focus::Grid, true) => Focus::Fg,
|
||||||
|
(Focus::Fg, true) => Focus::Bg,
|
||||||
|
(Focus::Bg, true) => Focus::Grid,
|
||||||
|
(Focus::Grid, false) => Focus::Bg,
|
||||||
|
(Focus::Fg, false) => Focus::Grid,
|
||||||
|
(Focus::Bg, false) => Focus::Fg,
|
||||||
|
};
|
||||||
|
DialogResult::Continue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the grid selection one cell for an arrow key, clamped at the edges.
|
||||||
|
fn move_grid(&mut self, code: KeyCode) {
|
||||||
|
let (col, row) = (self.tile % GRID_COLS, self.tile / GRID_COLS);
|
||||||
|
let (col, row) = match code {
|
||||||
|
KeyCode::Left => (col.saturating_sub(1), row),
|
||||||
|
KeyCode::Right => ((col + 1).min(GRID_COLS - 1), row),
|
||||||
|
KeyCode::Up => (col, row.saturating_sub(1)),
|
||||||
|
KeyCode::Down => (col, (row + 1).min(GRID_ROWS - 1)),
|
||||||
|
_ => (col, row),
|
||||||
|
};
|
||||||
|
self.tile = row * GRID_COLS + col;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws the picker centered over the whole frame: dims the background, then
|
||||||
|
/// renders the bordered window, the character grid, the two color fields, and the
|
||||||
|
/// OK/Cancel footer. Takes `&mut Frame` (not a `Widget`) so it can place a real
|
||||||
|
/// terminal cursor in the focused color field.
|
||||||
|
pub fn draw(&self, frame: &mut Frame) {
|
||||||
|
let area = frame.area();
|
||||||
|
dim_area(area, frame.buffer_mut());
|
||||||
|
|
||||||
|
// Size the window: wide enough for the 32-cell grid, tall enough for the grid
|
||||||
|
// (8) + a gap + the fields row + the footer, all within borders.
|
||||||
|
let want_w = 40u16;
|
||||||
|
let want_h = 14u16;
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Stack: grid (8 rows), gap, fields row, filler, footer.
|
||||||
|
let [grid_area, _gap, fields_area, _filler, footer_area] = Layout::vertical([
|
||||||
|
Constraint::Length(GRID_ROWS as u16),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.areas(inner);
|
||||||
|
|
||||||
|
// Both colors must be valid for the grid to preview them; otherwise gray-on-black.
|
||||||
|
let colors = parse_hex6(self.fg.value()).zip(parse_hex6(self.bg.value()));
|
||||||
|
|
||||||
|
self.draw_grid(frame, grid_area, colors);
|
||||||
|
let field_cursor = self.draw_fields(frame, fields_area);
|
||||||
|
self.draw_footer(frame, footer_area);
|
||||||
|
|
||||||
|
// Place a real terminal cursor in the focused field (none when the grid is focused).
|
||||||
|
if let Some((cx, cy)) = field_cursor {
|
||||||
|
frame.set_cursor_position((cx, cy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws the 32×8 character grid into `area`, centered horizontally. When `colors`
|
||||||
|
/// is `Some((fg, bg))` each glyph is drawn in those colors; otherwise gray-on-black.
|
||||||
|
/// The selected cell is shown reversed (a bright cursor when the grid is focused).
|
||||||
|
fn draw_grid(&self, frame: &mut Frame, area: Rect, colors: Option<(Rgba8, Rgba8)>) {
|
||||||
|
// Center the fixed-width grid within whatever inner width we got.
|
||||||
|
let grid_x = area.x + area.width.saturating_sub(GRID_COLS as u16) / 2;
|
||||||
|
let base = match colors {
|
||||||
|
Some((fg, bg)) => Style::default().fg(to_color(fg)).bg(to_color(bg)),
|
||||||
|
None => Style::default().fg(Color::Gray).bg(Color::Black),
|
||||||
|
};
|
||||||
|
let buf = frame.buffer_mut();
|
||||||
|
for tile in 0..(GRID_COLS * GRID_ROWS) {
|
||||||
|
let x = grid_x + (tile % GRID_COLS) as u16;
|
||||||
|
let y = area.y + (tile / GRID_COLS) as u16;
|
||||||
|
// Skip cells that would fall outside the (possibly clamped) grid area.
|
||||||
|
if x >= area.right() || y >= area.bottom() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let style = if tile == self.tile {
|
||||||
|
if self.focus == Focus::Grid {
|
||||||
|
// Bright cursor so the selection reads even against the colored grid.
|
||||||
|
Style::default().fg(Color::Black).bg(CURSOR_BG)
|
||||||
|
} else {
|
||||||
|
base.add_modifier(Modifier::REVERSED)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
};
|
||||||
|
if let Some(cell) = buf.cell_mut((x, y)) {
|
||||||
|
cell.set_symbol(&tile_to_char(tile).to_string());
|
||||||
|
cell.set_style(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws the `fg`/`bg` labeled hex fields into `area`, centered. Returns the screen
|
||||||
|
/// position of the terminal cursor for the focused field (or `None` if the grid is
|
||||||
|
/// focused), so the caller can place it after dropping the buffer borrow.
|
||||||
|
fn draw_fields(&self, frame: &mut Frame, area: Rect) -> Option<(u16, u16)> {
|
||||||
|
// Each field box is at least 6 wide, growing if the (invalid) value is longer.
|
||||||
|
let fg_w = self.fg.value().chars().count().max(6) as u16;
|
||||||
|
let bg_w = self.bg.value().chars().count().max(6) as u16;
|
||||||
|
// Layout: "fg " + field + " " + "bg " + field.
|
||||||
|
let total = 3 + fg_w + 2 + 3 + bg_w;
|
||||||
|
let start_x = area.x + area.width.saturating_sub(total) / 2;
|
||||||
|
let y = area.y;
|
||||||
|
|
||||||
|
let fg_label_x = start_x;
|
||||||
|
let fg_field_x = fg_label_x + 3;
|
||||||
|
let bg_label_x = fg_field_x + fg_w + 2;
|
||||||
|
let bg_field_x = bg_label_x + 3;
|
||||||
|
|
||||||
|
let buf = frame.buffer_mut();
|
||||||
|
draw_label(buf, fg_label_x, y, "fg ", self.focus == Focus::Fg);
|
||||||
|
draw_swatch(buf, fg_field_x, y, fg_w, self.fg.value());
|
||||||
|
draw_label(buf, bg_label_x, y, "bg ", self.focus == Focus::Bg);
|
||||||
|
draw_swatch(buf, bg_field_x, y, bg_w, self.bg.value());
|
||||||
|
|
||||||
|
// The terminal cursor goes in the focused field, clamped to its box.
|
||||||
|
match self.focus {
|
||||||
|
Focus::Fg => Some((fg_field_x + (self.fg.display_cursor() as u16).min(fg_w - 1), y)),
|
||||||
|
Focus::Bg => Some((bg_field_x + (self.bg.display_cursor() as u16).min(bg_w - 1), y)),
|
||||||
|
Focus::Grid => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws the `[enter] OK [esc] Cancel` footer (OK dimmed when disabled).
|
||||||
|
fn draw_footer(&self, frame: &mut Frame, area: Rect) {
|
||||||
|
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(KEY).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), area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws a field label, highlighting its background when the field is focused.
|
||||||
|
fn draw_label(buf: &mut ratatui::buffer::Buffer, x: u16, y: u16, text: &str, focused: bool) {
|
||||||
|
let style = if focused {
|
||||||
|
Style::default().fg(Color::White).bg(LABEL_FOCUS_BG)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Gray).bg(BG)
|
||||||
|
};
|
||||||
|
buf.set_string(x, y, text, style);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws a color swatch box of width `w`: the parsed color as background with
|
||||||
|
/// contrasting text when `value` is valid hex, otherwise a red "invalid" box.
|
||||||
|
fn draw_swatch(buf: &mut ratatui::buffer::Buffer, x: u16, y: u16, w: u16, value: &str) {
|
||||||
|
let style = match parse_hex6(value) {
|
||||||
|
Some(c) => Style::default().fg(contrast(c)).bg(to_color(c)),
|
||||||
|
None => Style::default().fg(Color::White).bg(Color::Rgb(150, 30, 30)),
|
||||||
|
};
|
||||||
|
// Left-align the value in a fixed-width box so the swatch fills the whole field.
|
||||||
|
let shown = format!("{value:<width$}", width = w as usize);
|
||||||
|
buf.set_string(x, y, shown, style);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats an [`Rgba8`] as 6 uppercase hex digits (no `#`); alpha is dropped.
|
||||||
|
fn hex6(c: Rgba8) -> String {
|
||||||
|
format!("{:02X}{:02X}{:02X}", c.r, c.g, c.b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses exactly 6 hex digits (no `#`) into an opaque [`Rgba8`], or `None` if the
|
||||||
|
/// string is not exactly six hex characters.
|
||||||
|
fn parse_hex6(s: &str) -> Option<Rgba8> {
|
||||||
|
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Rgba8 {
|
||||||
|
r: u8::from_str_radix(&s[0..2], 16).ok()?,
|
||||||
|
g: u8::from_str_radix(&s[2..4], 16).ok()?,
|
||||||
|
b: u8::from_str_radix(&s[4..6], 16).ok()?,
|
||||||
|
a: 255,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a core [`Rgba8`] to a ratatui [`Color`] (alpha dropped).
|
||||||
|
fn to_color(c: Rgba8) -> Color {
|
||||||
|
Color::Rgb(c.r, c.g, c.b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picks black or white for legible text on a `c` background, by luminance.
|
||||||
|
fn contrast(c: Rgba8) -> Color {
|
||||||
|
// Rec. 601 luma; > ~50% brightness reads better with black text.
|
||||||
|
let luma = 0.299 * c.r as f32 + 0.587 * c.g as f32 + 0.114 * c.b as f32;
|
||||||
|
if luma > 128.0 {
|
||||||
|
Color::Black
|
||||||
|
} else {
|
||||||
|
Color::White
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
|
/// A glyph with distinct, valid colors for seeding tests.
|
||||||
|
fn sample() -> Glyph {
|
||||||
|
Glyph {
|
||||||
|
tile: 5,
|
||||||
|
fg: Rgba8 {
|
||||||
|
r: 0xFF,
|
||||||
|
g: 0x00,
|
||||||
|
b: 0x00,
|
||||||
|
a: 255,
|
||||||
|
},
|
||||||
|
bg: Rgba8 {
|
||||||
|
r: 0x00,
|
||||||
|
g: 0x00,
|
||||||
|
b: 0xFF,
|
||||||
|
a: 255,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a key-press event with no modifiers.
|
||||||
|
fn key(code: KeyCode) -> Event {
|
||||||
|
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seed_round_trips_tile_and_colors() {
|
||||||
|
let d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
|
||||||
|
assert_eq!(d.tile, 5);
|
||||||
|
assert_eq!(d.fg.value(), "FF0000");
|
||||||
|
assert_eq!(d.bg.value(), "0000FF");
|
||||||
|
assert!(d.ok_enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_cycles_focus_both_ways() {
|
||||||
|
let mut d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
|
||||||
|
assert!(d.focus == Focus::Grid);
|
||||||
|
d.handle_event(&key(KeyCode::Tab));
|
||||||
|
assert!(d.focus == Focus::Fg);
|
||||||
|
d.handle_event(&key(KeyCode::Tab));
|
||||||
|
assert!(d.focus == Focus::Bg);
|
||||||
|
d.handle_event(&key(KeyCode::Tab));
|
||||||
|
assert!(d.focus == Focus::Grid);
|
||||||
|
// BackTab goes the other way.
|
||||||
|
d.handle_event(&key(KeyCode::BackTab));
|
||||||
|
assert!(d.focus == Focus::Bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrows_move_grid_selection_and_clamp() {
|
||||||
|
let mut g = sample();
|
||||||
|
g.tile = 0;
|
||||||
|
let mut d: GlyphDialog<()> = GlyphDialog::new("t", g, |_, _| {});
|
||||||
|
d.handle_event(&key(KeyCode::Right));
|
||||||
|
assert_eq!(d.tile, 1);
|
||||||
|
d.handle_event(&key(KeyCode::Down));
|
||||||
|
assert_eq!(d.tile, 1 + GRID_COLS);
|
||||||
|
d.handle_event(&key(KeyCode::Left));
|
||||||
|
assert_eq!(d.tile, GRID_COLS);
|
||||||
|
d.handle_event(&key(KeyCode::Up));
|
||||||
|
assert_eq!(d.tile, 0);
|
||||||
|
// Clamps at the top-left corner.
|
||||||
|
d.handle_event(&key(KeyCode::Up));
|
||||||
|
d.handle_event(&key(KeyCode::Left));
|
||||||
|
assert_eq!(d.tile, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_hex_disables_ok_and_blocks_enter() {
|
||||||
|
let mut d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {});
|
||||||
|
// Focus the fg field and delete a digit → "FF000" (5 chars) is invalid.
|
||||||
|
d.handle_event(&key(KeyCode::Tab));
|
||||||
|
d.handle_event(&key(KeyCode::Backspace));
|
||||||
|
assert_eq!(d.fg.value(), "FF000");
|
||||||
|
assert!(!d.ok_enabled());
|
||||||
|
assert!(matches!(
|
||||||
|
d.handle_event(&key(KeyCode::Enter)),
|
||||||
|
DialogResult::Continue
|
||||||
|
));
|
||||||
|
// Type the digit back → valid again.
|
||||||
|
d.handle_event(&key(KeyCode::Char('0')));
|
||||||
|
assert_eq!(d.fg.value(), "FF0000");
|
||||||
|
assert!(d.ok_enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn submit_yields_glyph_cancel_yields_none() {
|
||||||
|
// Submit returns the chosen glyph.
|
||||||
|
let mut out: Option<Option<Glyph>> = None;
|
||||||
|
let mut d: GlyphDialog<Option<Option<Glyph>>> =
|
||||||
|
GlyphDialog::new("t", sample(), |g, c| *c = Some(g));
|
||||||
|
d.handle_event(&key(KeyCode::Right)); // tile 5 -> 6
|
||||||
|
let res = d.handle_event(&key(KeyCode::Enter));
|
||||||
|
d.finish(res, &mut out);
|
||||||
|
let chosen = out.unwrap().unwrap();
|
||||||
|
assert_eq!(chosen.tile, 6);
|
||||||
|
assert_eq!(chosen.fg, sample().fg);
|
||||||
|
assert_eq!(chosen.bg, sample().bg);
|
||||||
|
|
||||||
|
// Cancel returns None.
|
||||||
|
let mut out2: Option<Option<Glyph>> = None;
|
||||||
|
let d2: GlyphDialog<Option<Option<Glyph>>> =
|
||||||
|
GlyphDialog::new("t", sample(), |g, c| *c = Some(g));
|
||||||
|
d2.finish(DialogResult::Cancel, &mut out2);
|
||||||
|
assert!(out2.unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_hex6_validation() {
|
||||||
|
assert!(parse_hex6("FF0000").is_some());
|
||||||
|
assert!(parse_hex6("ff00 aa").is_none()); // space / wrong length
|
||||||
|
assert!(parse_hex6("FF00").is_none()); // too short
|
||||||
|
assert!(parse_hex6("GGGGGG").is_none()); // non-hex
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ use ratatui::style::{Color, Style};
|
|||||||
mod clipboard;
|
mod clipboard;
|
||||||
pub mod code_editor;
|
pub mod code_editor;
|
||||||
pub mod dialog;
|
pub mod dialog;
|
||||||
|
pub mod glyph_dialog;
|
||||||
mod text;
|
mod text;
|
||||||
pub mod text_field;
|
pub mod text_field;
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ pub struct TextField {
|
|||||||
cursor: usize,
|
cursor: usize,
|
||||||
/// Clipboard for `ctrl`/`⌘`+`v` paste.
|
/// Clipboard for `ctrl`/`⌘`+`v` paste.
|
||||||
clipboard: Clipboard,
|
clipboard: Clipboard,
|
||||||
|
/// How long we will allow the string to be:
|
||||||
|
max_length: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextField {
|
impl TextField {
|
||||||
@@ -25,7 +27,14 @@ impl TextField {
|
|||||||
pub fn new(initial: impl Into<String>) -> Self {
|
pub fn new(initial: impl Into<String>) -> Self {
|
||||||
let text = initial.into();
|
let text = initial.into();
|
||||||
let cursor = text.chars().count();
|
let cursor = text.chars().count();
|
||||||
Self { text, cursor, clipboard: Clipboard::new() }
|
Self { text, cursor, clipboard: Clipboard::new(), max_length: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sized(initial: impl Into<String>, max_length: usize) -> Self {
|
||||||
|
let mut text = initial.into();
|
||||||
|
text.truncate(max_length);
|
||||||
|
let cursor = text.chars().count();
|
||||||
|
Self { text, cursor, clipboard: Clipboard::new(), max_length: Some(max_length) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The current text.
|
/// The current text.
|
||||||
@@ -70,8 +79,13 @@ impl TextField {
|
|||||||
|
|
||||||
fn insert(&mut self, c: char) {
|
fn insert(&mut self, c: char) {
|
||||||
let b = byte_of(&self.text, self.cursor);
|
let b = byte_of(&self.text, self.cursor);
|
||||||
self.text.insert(b, c);
|
if self.max_length.is_none_or(|max_length| max_length > self.text.len() ) {
|
||||||
self.cursor += 1;
|
self.text.insert(b, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.max_length.is_none_or(|max_length| max_length > self.cursor ) {
|
||||||
|
self.cursor += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backspace(&mut self) -> bool {
|
fn backspace(&mut self) -> bool {
|
||||||
@@ -168,6 +182,35 @@ mod tests {
|
|||||||
assert_eq!(f.display_cursor(), 0);
|
assert_eq!(f.display_cursor(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sized_truncates_overlong_initial_value() {
|
||||||
|
// `sized` clamps the seed text to the max length up front.
|
||||||
|
let f = TextField::sized("FF0000extra", 6);
|
||||||
|
assert_eq!(f.value(), "FF0000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn max_length_caps_typed_input() {
|
||||||
|
// Typing into a sized field fills up to the cap, then further input is dropped.
|
||||||
|
let mut f = TextField::sized("", 3);
|
||||||
|
for c in "abcd".chars() {
|
||||||
|
f.handle_key(key(KeyCode::Char(c)));
|
||||||
|
}
|
||||||
|
assert_eq!(f.value(), "abc");
|
||||||
|
|
||||||
|
// A field seeded at its cap ignores further typing (the glyph picker's hex fields).
|
||||||
|
let mut g = TextField::sized("FF0000", 6);
|
||||||
|
g.handle_key(key(KeyCode::Char('9')));
|
||||||
|
assert_eq!(g.value(), "FF0000");
|
||||||
|
|
||||||
|
// An unsized field is unbounded.
|
||||||
|
let mut u = TextField::new("");
|
||||||
|
for c in "abcdef".chars() {
|
||||||
|
u.handle_key(key(KeyCode::Char(c)));
|
||||||
|
}
|
||||||
|
assert_eq!(u.value(), "abcdef");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unhandled_keys_return_false() {
|
fn unhandled_keys_return_false() {
|
||||||
let mut f = TextField::new("x");
|
let mut f = TextField::new("x");
|
||||||
|
|||||||
Reference in New Issue
Block a user