This commit is contained in:
2026-06-11 22:40:50 -05:00
parent 7f41f704ef
commit 8e79b30583
2 changed files with 49 additions and 12 deletions
+32 -7
View File
@@ -119,26 +119,51 @@ The core types that were formerly monolithic in `game.rs` are now split into foc
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. 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.
**Drawing rule: prefer ratatui primitives.** Before writing manual cell-by-cell buffer code in `render.rs`, 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 `draw_scroll_overlay` (no ratatui primitive for "tint the whole buffer"), and the two tail characters in `draw_speech_bubbles` (the tail-join `` and the descending `│`, written after ratatui renders the box). **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.
**`kiln-tui/src/main.rs`** — entry point and play loop: **`kiln-tui/src/main.rs`** — entry point and play loop:
- Parses a single positional arg (the map path); prints usage and exits non-zero if missing. Loads the board via `kiln_core::map_file::load` *before* touching the terminal so load errors print to a normal screen. - Parses a single positional arg (the map path); prints usage and exits non-zero if missing. Loads the board via `kiln_core::map_file::load` *before* touching the terminal so load errors print to a normal screen.
- `ratatui::init()` → detect `TerminalCaps` → push Kitty flags when supported → `run` loop → pop Kitty flags → `ratatui::restore()`. - `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. - `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`. - `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_open, log_height, scroll, scroll_offset, scroll_anim }` — view-only panel state kept in the front-end (not on `GameState`). - `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.
- `ScrollAnimState { Opening(f32), Open, Closing { progress, choice } }` — tracks the 0.5 s open/close animation for the scroll overlay. `begin_close(ui, choice)` starts closing. When `Closing` reaches 0, `game.close_scroll(choice)` is called and the animation resets.
**`kiln-tui/src/cp437.rs`** — CP437 → Unicode mapping: **`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.). - `CP437: [char; 256]` — full code-page-437 table (graphic glyphs for 0x000x1F, box-drawing for 0xB00xDF, 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. - `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:
- `rgba8_to_color(Rgba8) -> Color::Rgb` — lossless RGB bridge (alpha dropped); truecolor terminals show exact colors, 256-color ones approximate.
- `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_screen_pos(area, board, bx, by) -> Option<(u16, u16)>` — converts a board cell coordinate to terminal screen coordinates, returning `None` if the cell is scrolled off-screen. Used by the speech-bubble overlay.
- `draw_speech_bubbles(buf, area, board, bubbles)` — draws speech bubbles over the board for all active `SpeechBubble`s. Uses `Block::bordered().border_type(BorderType::Rounded)` + `Paragraph` for the box; only the tail-join character (`╮` on the bottom border at the speaker's column) and the descending tail `│` are written manually. Bubbles that would overlap are shifted upward one row at a time until no collision. **`kiln-tui/src/speech.rs`** — speech bubble widget:
- `draw_scroll_overlay(buf, area, scroll, offset, anim)` — draws the full-screen scroll overlay. Dims `area` manually (no ratatui primitive for this), then renders a `Block`-bordered window at half screen width × 3/4 screen height (height forced even). Height animates with `anim` (0.0→1.0 = opening, 1.0→0.0 = closing), always staying even. Lines starting with ASCII whitespace are stripped and centered via `Line::centered()`; choice lines show `[a]`/`[b]` prefixes. `Paragraph::line_count(inner_w)` measures wrapped content height; `Layout::vertical` centers short content. `offset` scrolls the content. - `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.
- Each bubble: `Block::bordered()` + `Paragraph` for the box; the tail-join `┬` on the bottom border and a column of `│` chars from below the box down to the source object are written manually. Bubbles that would overlap are shifted upward one row at a time (up to 20 attempts) until clear.
- `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.
- `logline_to_line(line) -> Line` — converts a core `LogLine` into a styled ratatui `Line`; each `LogSpan`'s optional fg/bg is applied only when present.
- `log_preview_line(logs) -> Line` — builds the `[l]og: <latest>` preview span for the board's bottom border.
**`kiln-tui/src/utils.rs`** — shared rendering helpers:
- `rgba8_to_color(Rgba8) -> Color` — lossless RGB bridge (alpha dropped).
- `rects_overlap(a, b) -> bool` — true if two `Rect`s share at least one cell; used by bubble placement.
**`kiln-tui/src/term.rs`** — terminal capability detection and Kitty setup: **`kiln-tui/src/term.rs`** — terminal capability detection and Kitty setup:
- `TerminalCaps { keyboard_enhancement: bool, truecolor: bool }` — detected once at startup. `keyboard_enhancement` is a real query/response (`supports_keyboard_enhancement()`); `truecolor` reads `$COLORTERM`. Intended to be handed to scripts so they can pick bindings the terminal actually supports. - `TerminalCaps { keyboard_enhancement: bool, truecolor: bool }` — detected once at startup. `keyboard_enhancement` is a real query/response (`supports_keyboard_enhancement()`); `truecolor` reads `$COLORTERM`. Intended to be handed to scripts so they can pick bindings the terminal actually supports.
+16 -4
View File
@@ -95,12 +95,20 @@ impl Widget for SpeechBubblesWidget<'_> {
.fg(Color::Rgb(160, 160, 220)) .fg(Color::Rgb(160, 160, 220))
.bg(Color::Rgb(20, 20, 40)); .bg(Color::Rgb(20, 20, 40));
// Pass 1: collect placements in the same bottom-first order.
let mut placements: Vec<(u16, u16, Vec<&str>, Rect)> = Vec::new();
for (obj_sx, obj_sy, bubble) in items { for (obj_sx, obj_sy, bubble) in items {
let lines: Vec<&str> = bubble.text.split('\n').collect(); let lines: Vec<&str> = bubble.text.split('\n').collect();
let Some(r) = Self::place_bubble(area, obj_sx, obj_sy, &lines, &mut placed) else { continue }; let Some(r) = Self::place_bubble(area, obj_sx, obj_sy, &lines, &mut placed) else { continue };
placements.push((obj_sx, obj_sy, lines, r));
}
// Pass 2: draw top-most bubbles first so lower boxes naturally cover extended tails.
for (obj_sx, obj_sy, lines, r) in placements.iter().rev() {
let (obj_sx, obj_sy) = (*obj_sx, *obj_sy);
// Render the bordered box (top border + one row per line + bottom border). // Render the bordered box (top border + one row per line + bottom border).
let box_rect = Rect { height: r.height - 1, ..r }; let box_rect = Rect { height: r.height - 1, ..*r };
let block = Block::bordered() let block = Block::bordered()
.border_style(border_style) .border_style(border_style)
.style(bubble_style); .style(bubble_style);
@@ -114,18 +122,22 @@ impl Widget for SpeechBubblesWidget<'_> {
.collect(); .collect();
Paragraph::new(para_lines).render(text_rect, buf); Paragraph::new(para_lines).render(text_rect, buf);
// Overwrite the tail-join cell on the bottom border, then draw the tail itself. // Overwrite the tail-join cell on the bottom border, then draw the full tail
// These are the only two cells that ratatui's Block can't produce for us. // from below the box down to the source object. Lower bubbles are drawn after
// us (pass 2 iterates top-first), so their boxes naturally cover any tail chars
// that pass through them — no explicit occlusion check needed.
let tail_col = obj_sx.clamp(r.x + 1, r.x + r.width - 2); let tail_col = obj_sx.clamp(r.x + 1, r.x + r.width - 2);
let border_bottom = r.y + r.height - 2; let border_bottom = r.y + r.height - 2;
if let Some(cell) = buf.cell_mut((tail_col, border_bottom)) { if let Some(cell) = buf.cell_mut((tail_col, border_bottom)) {
cell.set_char('┬').set_style(border_style); cell.set_char('┬').set_style(border_style);
} }
if let Some(cell) = buf.cell_mut((tail_col, border_bottom + 1)) { for y in (border_bottom + 1)..obj_sy {
if let Some(cell) = buf.cell_mut((tail_col, y)) {
cell.set_char('│').set_style(border_style); cell.set_char('│').set_style(border_style);
} }
} }
} }
}
} }
/// Converts a board cell coordinate to terminal screen coordinates within `area`, /// Converts a board cell coordinate to terminal screen coordinates within `area`,