diff --git a/CLAUDE.md b/CLAUDE.md index ead19dc..3e7f23e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. -**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: - 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()`. - `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_open, log_height, scroll, scroll_offset, scroll_anim }` — view-only panel state kept in the front-end (not on `GameState`). -- `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. +- `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/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: -- `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. -- `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. -- `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. + +**`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. +- 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` — 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.0–1.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: ` 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: - `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. diff --git a/kiln-tui/src/speech.rs b/kiln-tui/src/speech.rs index b429c1c..5116ffc 100644 --- a/kiln-tui/src/speech.rs +++ b/kiln-tui/src/speech.rs @@ -95,12 +95,20 @@ impl Widget for SpeechBubblesWidget<'_> { .fg(Color::Rgb(160, 160, 220)) .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 { 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 }; + 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). - let box_rect = Rect { height: r.height - 1, ..r }; + let box_rect = Rect { height: r.height - 1, ..*r }; let block = Block::bordered() .border_style(border_style) .style(bubble_style); @@ -114,15 +122,19 @@ impl Widget for SpeechBubblesWidget<'_> { .collect(); Paragraph::new(para_lines).render(text_rect, buf); - // Overwrite the tail-join cell on the bottom border, then draw the tail itself. - // These are the only two cells that ratatui's Block can't produce for us. + // Overwrite the tail-join cell on the bottom border, then draw the full tail + // 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 border_bottom = r.y + r.height - 2; if let Some(cell) = buf.cell_mut((tail_col, border_bottom)) { cell.set_char('┬').set_style(border_style); } - if let Some(cell) = buf.cell_mut((tail_col, border_bottom + 1)) { - cell.set_char('│').set_style(border_style); + 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); + } } } }