This commit is contained in:
2026-06-11 18:53:52 -05:00
parent f73c02437d
commit a1cce10432
7 changed files with 327 additions and 126 deletions
+4 -3
View File
@@ -119,6 +119,8 @@ 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).
**`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()`.
@@ -135,9 +137,8 @@ The terminal player. Renders the board as text: each `Glyph.tile` index is reint
- `rgba8_to_color(Rgba8) -> Color::Rgb` — lossless RGB bridge (alpha dropped); truecolor terminals show exact colors, 256-color ones approximate. - `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. - `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 box-drawing speech bubbles over the board for all active `SpeechBubble`s. Each bubble shows a `╭─╮` / `│ text │` / `╰─╮─╯` / `│` frame with the tail at the object's column. Bubbles that would overlap are shifted upward one row at a time until no collision. - `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 the entire `area` (fg=Rgb(60,60,60), bg=Black), then renders a centered box-drawing window whose height is scaled by `anim` (0.0→1.0 = opening; 1.0→0.0 = closing). Text lines are word-wrapped via `wrap_text`; choice lines show `[a]`/`[b]` key prefixes in yellow with amber label text. `offset` scrolls the content vertically. - `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.
- `wrap_text(text, max_width) -> Vec<String>` — word-wraps a string to lines no longer than `max_width` characters.
**`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.
Generated
+101 -34
View File
@@ -37,6 +37,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "atomic" name = "atomic"
version = "0.6.1" version = "0.6.1"
@@ -81,9 +90,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.1" version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
@@ -100,6 +109,12 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "by_address"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
[[package]] [[package]]
name = "bytemuck" name = "bytemuck"
version = "1.25.0" version = "1.25.0"
@@ -185,13 +200,19 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]] [[package]]
name = "crossterm" name = "crossterm"
version = "0.29.0" version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.13.0",
"crossterm_winapi", "crossterm_winapi",
"derive_more", "derive_more",
"document-features", "document-features",
@@ -369,6 +390,12 @@ dependencies = [
"regex", "regex",
] ]
[[package]]
name = "fast-srgb8"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
[[package]] [[package]]
name = "filedescriptor" name = "filedescriptor"
version = "0.8.3" version = "0.8.3"
@@ -505,6 +532,11 @@ name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
]
[[package]] [[package]]
name = "heck" name = "heck"
@@ -646,13 +678,19 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "line-clipping" name = "line-clipping"
version = "0.3.7" version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.13.0",
] ]
[[package]] [[package]]
@@ -684,11 +722,11 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]] [[package]]
name = "lru" name = "lru"
version = "0.16.4" version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9"
dependencies = [ dependencies = [
"hashbrown 0.16.1", "hashbrown 0.17.1",
] ]
[[package]] [[package]]
@@ -746,7 +784,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.13.0",
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases",
"libc", "libc",
@@ -816,6 +854,30 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "palette"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6"
dependencies = [
"approx",
"fast-srgb8",
"libm",
"palette_derive",
]
[[package]]
name = "palette_derive"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30"
dependencies = [
"by_address",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -1009,9 +1071,9 @@ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]] [[package]]
name = "ratatui" name = "ratatui"
version = "0.30.0" version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" checksum = "1695748e3a735b34968c887ceea5a380b43545903868ae8f5b666593100f6b68"
dependencies = [ dependencies = [
"instability", "instability",
"ratatui-core", "ratatui-core",
@@ -1019,21 +1081,25 @@ dependencies = [
"ratatui-macros", "ratatui-macros",
"ratatui-termwiz", "ratatui-termwiz",
"ratatui-widgets", "ratatui-widgets",
"serde",
] ]
[[package]] [[package]]
name = "ratatui-core" name = "ratatui-core"
version = "0.1.0" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" checksum = "42d3603f354bba8c595fa47860e60142d7372b7210c27044c6a7d0e1a4336b44"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.13.0",
"compact_str", "compact_str",
"hashbrown 0.16.1", "critical-section",
"hashbrown 0.17.1",
"indoc", "indoc",
"itertools", "itertools",
"kasuari", "kasuari",
"lru", "lru",
"palette",
"serde",
"strum", "strum",
"thiserror 2.0.18", "thiserror 2.0.18",
"unicode-segmentation", "unicode-segmentation",
@@ -1043,9 +1109,9 @@ dependencies = [
[[package]] [[package]]
name = "ratatui-crossterm" name = "ratatui-crossterm"
version = "0.1.0" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"crossterm", "crossterm",
@@ -1055,9 +1121,9 @@ dependencies = [
[[package]] [[package]]
name = "ratatui-macros" name = "ratatui-macros"
version = "0.7.0" version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" checksum = "80fac59720679490d89d200df411faa249be728681adcabed3d047ae72c48f1d"
dependencies = [ dependencies = [
"ratatui-core", "ratatui-core",
"ratatui-widgets", "ratatui-widgets",
@@ -1065,9 +1131,9 @@ dependencies = [
[[package]] [[package]]
name = "ratatui-termwiz" name = "ratatui-termwiz"
version = "0.1.0" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" checksum = "386b8ff8f74ed749509391c56d549761a2fcdb408e1f42e467286bcb7dac8967"
dependencies = [ dependencies = [
"ratatui-core", "ratatui-core",
"termwiz", "termwiz",
@@ -1075,17 +1141,18 @@ dependencies = [
[[package]] [[package]]
name = "ratatui-widgets" name = "ratatui-widgets"
version = "0.3.0" version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" checksum = "7ef4f17dd7ac3abf5adc2b920a03c61eee4bfe6a88fa5191936895525371d79c"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.13.0",
"hashbrown 0.16.1", "hashbrown 0.17.1",
"indoc", "indoc",
"instability", "instability",
"itertools", "itertools",
"line-clipping", "line-clipping",
"ratatui-core", "ratatui-core",
"serde",
"strum", "strum",
"time", "time",
"unicode-segmentation", "unicode-segmentation",
@@ -1098,7 +1165,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.13.0",
] ]
[[package]] [[package]]
@@ -1137,7 +1204,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f9ef5dabe4c0b43d8f1187dc6beb67b53fe607fff7e30c5eb7f71b814b8c2c1" checksum = "1f9ef5dabe4c0b43d8f1187dc6beb67b53fe607fff7e30c5eb7f71b814b8c2c1"
dependencies = [ dependencies = [
"ahash", "ahash",
"bitflags 2.11.1", "bitflags 2.13.0",
"num-traits", "num-traits",
"once_cell", "once_cell",
"rhai_codegen", "rhai_codegen",
@@ -1173,7 +1240,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.13.0",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
@@ -1341,18 +1408,18 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "strum" name = "strum"
version = "0.27.2" version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [ dependencies = [
"strum_macros", "strum_macros",
] ]
[[package]] [[package]]
name = "strum_macros" name = "strum_macros"
version = "0.27.2" version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
@@ -1411,7 +1478,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
"bitflags 2.11.1", "bitflags 2.13.0",
"fancy-regex", "fancy-regex",
"filedescriptor", "filedescriptor",
"finl_unicode", "finl_unicode",
@@ -1746,7 +1813,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.13.0",
"hashbrown 0.15.5", "hashbrown 0.15.5",
"indexmap", "indexmap",
"semver", "semver",
@@ -1944,7 +2011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitflags 2.11.1", "bitflags 2.13.0",
"indexmap", "indexmap",
"log", "log",
"serde", "serde",
+65 -1
View File
@@ -1,6 +1,6 @@
use std::time::Duration; use std::time::Duration;
use crate::board::tests::open_board; use crate::board::tests::open_board;
use crate::game::GameState; use crate::game::{GameState, ScrollLine};
use crate::utils::Direction; use crate::utils::Direction;
use super::{board_with_object, scripted_object, log_texts}; use super::{board_with_object, scripted_object, log_texts};
@@ -261,3 +261,67 @@ fn player_bump_fires_with_negative_one() {
game.tick(Duration::from_millis(16)); game.tick(Duration::from_millis(16));
assert!(log_texts(&game).iter().any(|t| t == "bumped by -1")); assert!(log_texts(&game).iter().any(|t| t == "bumped by -1"));
} }
#[test]
fn scroll_opens_on_player_bump() {
// A solid object's bump() calls scroll([text, [choice, display]]). After the
// player bumps it and a tick resolves the queued action, active_scroll is set
// with the correct ScrollLine variants.
let board = open_board(
3, 1, (0, 0),
vec![scripted_object(1, 0, "s")],
&[("s", r#"fn bump(id) { scroll(["Hello world", ["eat", "Eat it"]]); }"#)],
);
let mut game = GameState::new(board);
game.run_init();
game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
let scroll = game.active_scroll.as_ref().expect("scroll should be active after bump");
assert_eq!(scroll.lines.len(), 2);
assert!(matches!(&scroll.lines[0], ScrollLine::Text(t) if t == "Hello world"));
assert!(matches!(&scroll.lines[1], ScrollLine::Choice { choice, display }
if choice == "eat" && display == "Eat it"));
}
#[test]
fn close_scroll_without_choice_clears_it() {
let board = open_board(
3, 1, (0, 0),
vec![scripted_object(1, 0, "s")],
&[("s", r#"fn bump(id) { scroll(["Hello"]); }"#)],
);
let mut game = GameState::new(board);
game.run_init();
game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
assert!(game.active_scroll.is_some());
game.close_scroll(None);
assert!(game.active_scroll.is_none());
}
#[test]
fn close_scroll_with_choice_dispatches_send_to_source() {
// Closing with a choice string fires send() on the object that opened the
// scroll; fn eat() logging "eaten" confirms the dispatch arrived.
let board = open_board(
3, 1, (0, 0),
vec![scripted_object(1, 0, "s")],
&[("s", r#"
fn bump(id) { scroll(["Muffin?", ["eat", "Eat it"]]); }
fn eat() { log("eaten"); }
"#)],
);
let mut game = GameState::new(board);
game.run_init();
game.try_move(Direction::East);
game.tick(Duration::from_millis(16));
assert!(game.active_scroll.is_some());
game.close_scroll(Some("eat"));
assert!(game.active_scroll.is_none());
// eat() queues Action::Log onto the board queue; a tick resolves it.
game.tick(Duration::from_millis(16));
assert!(log_texts(&game).iter().any(|t| t == "eaten"), "eat() should have logged");
}
+1 -1
View File
@@ -6,4 +6,4 @@ edition = "2024"
[dependencies] [dependencies]
kiln-core = { path = "../kiln-core" } kiln-core = { path = "../kiln-core" }
color = "0.3.3" color = "0.3.3"
ratatui = "0.30" ratatui = { version = "0.30.1", features = ["unstable-rendered-line-info"] }
+22 -6
View File
@@ -53,6 +53,8 @@ struct Ui {
scroll: u16, scroll: u16,
/// Vertical scroll offset within an active scroll overlay. /// Vertical scroll offset within an active scroll overlay.
scroll_offset: u16, scroll_offset: u16,
/// Maximum valid value for `scroll_offset`; updated each frame by the renderer.
scroll_max: u16,
/// Animation state for the scroll overlay; `None` when no overlay is active. /// Animation state for the scroll overlay; `None` when no overlay is active.
scroll_anim: Option<ScrollAnimState>, scroll_anim: Option<ScrollAnimState>,
} }
@@ -64,11 +66,20 @@ impl Default for Ui {
log_height: 10, log_height: 10,
scroll: 0, scroll: 0,
scroll_offset: 0, scroll_offset: 0,
scroll_max: 0,
scroll_anim: None, scroll_anim: None,
} }
} }
} }
impl Ui {
/// Scrolls the overlay by `delta` lines, clamped to the valid range.
fn scroll_lines(&mut self, delta: i32) {
self.scroll_offset = (self.scroll_offset as i32 + delta)
.clamp(0, self.scroll_max as i32) as u16;
}
}
/// Starts the scroll-close animation with the optional player choice. /// Starts the scroll-close animation with the optional player choice.
/// Uses the current opening progress if the overlay was still animating in. /// Uses the current opening progress if the overlay was still animating in.
fn begin_close(ui: &mut Ui, choice: Option<String>) { fn begin_close(ui: &mut Ui, choice: Option<String>) {
@@ -197,8 +208,8 @@ fn run(
// Esc dismisses the scroll only when there are no choices; // Esc dismisses the scroll only when there are no choices;
// with choices present the player must select one. // with choices present the player must select one.
KeyCode::Esc if !has_choices => begin_close(ui, None), KeyCode::Esc if !has_choices => begin_close(ui, None),
KeyCode::Up => ui.scroll_offset = ui.scroll_offset.saturating_sub(1), KeyCode::Up => ui.scroll_lines(-1),
KeyCode::Down => ui.scroll_offset = ui.scroll_offset.saturating_add(1), KeyCode::Down => ui.scroll_lines(1),
KeyCode::Char(c) => { KeyCode::Char(c) => {
if let Some(ch) = resolve_choice(&scroll.lines, c) { if let Some(ch) = resolve_choice(&scroll.lines, c) {
begin_close(ui, Some(ch)); begin_close(ui, Some(ch));
@@ -233,8 +244,8 @@ fn run(
&& !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing { .. })); && !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing { .. }));
if scroll_active { if scroll_active {
match m.kind { match m.kind {
MouseEventKind::ScrollUp => ui.scroll_offset = ui.scroll_offset.saturating_sub(1), MouseEventKind::ScrollUp => ui.scroll_lines(-1),
MouseEventKind::ScrollDown => ui.scroll_offset = ui.scroll_offset.saturating_add(1), MouseEventKind::ScrollDown => ui.scroll_lines(1),
_ => {} _ => {}
} }
} else if ui.log_open { } else if ui.log_open {
@@ -278,6 +289,7 @@ fn run(
game.close_scroll(ch.as_deref()); game.close_scroll(ch.as_deref());
ui.scroll_anim = None; ui.scroll_anim = None;
ui.scroll_offset = 0; ui.scroll_offset = 0;
ui.scroll_max = 0;
} }
} }
_ => {} _ => {}
@@ -306,7 +318,11 @@ fn scroll_log(ui: &mut Ui, game: &GameState, delta: i32) {
/// panel across the bottom. When the panel is closed the latest log message is /// panel across the bottom. When the panel is closed the latest log message is
/// previewed in the board's bottom-left border after a `[l]og:` label. /// previewed in the board's bottom-left border after a `[l]og:` label.
/// A scroll overlay is drawn on top of everything when active. /// A scroll overlay is drawn on top of everything when active.
fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) { ///
/// TODO: `draw` mutates `ui.scroll_max` as a side-effect of rendering. This is a wart —
/// ideally render functions are pure and all state lives in the game loop. The max scroll
/// should be computed independently of drawing and stored before the draw call.
fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) {
// Borrow the board for the duration of the frame (no script runs during draw). // Borrow the board for the duration of the frame (no script runs during draw).
let board = game.board(); let board = game.board();
let board = &*board; let board = &*board;
@@ -360,7 +376,7 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) {
}; };
// Capture area before the mutable buffer borrow to satisfy the borrow checker. // Capture area before the mutable buffer borrow to satisfy the borrow checker.
let area = frame.area(); let area = frame.area();
render::draw_scroll_overlay(frame.buffer_mut(), area, scroll, ui.scroll_offset, anim); ui.scroll_max = render::draw_scroll_overlay(frame.buffer_mut(), area, scroll, ui.scroll_offset, anim);
} }
} }
+88 -78
View File
@@ -11,10 +11,11 @@ use kiln_core::Board;
use kiln_core::game::{Scroll, ScrollLine, SpeechBubble}; use kiln_core::game::{Scroll, ScrollLine, SpeechBubble};
use kiln_core::log::LogLine; use kiln_core::log::LogLine;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style}; use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget}; use ratatui::widgets::{Block, BorderType, Fill, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, StatefulWidget, Widget, Wrap};
/// Converts a core [`Rgba8`] color into a ratatui truecolor [`Color`]. /// Converts a core [`Rgba8`] color into a ratatui truecolor [`Color`].
/// ///
@@ -190,32 +191,21 @@ pub fn draw_speech_bubbles(buf: &mut Buffer, area: Rect, board: &Board, bubbles:
placed.push(Rect { x: left, y: top, width: box_w, height: box_h }); placed.push(Rect { x: left, y: top, width: box_w, height: box_h });
// Row 0: ╭──...──╮ // Render the rounded box (3 rows: top border, text, bottom border) with ratatui.
write_cell(buf, left, top, '╭', border_style); let box_rect = Rect { x: left, y: top, width: box_w, height: 3 };
for cx in (left + 1)..(left + box_w - 1) { let block = Block::bordered()
write_cell(buf, cx, top, '─', border_style); .border_type(BorderType::Rounded)
} .border_style(border_style)
write_cell(buf, left + box_w - 1, top, '╮', border_style); .style(bubble_style);
let text_rect = block.inner(box_rect);
block.render(box_rect, buf);
Paragraph::new(Line::styled(format!(" {}", bubble.text), bubble_style))
.render(text_rect, buf);
// Row 1: │ text │ (pad text to box_w - 2 inner width) // Overwrite the tail-join cell on the bottom border, then draw the tail itself.
let inner_w = (box_w - 2) as usize; // These are the only two cells that ratatui's Block can't produce for us.
let padded: String = format!(" {:<width$} ", bubble.text, width = inner_w - 2);
write_cell(buf, left, top + 1, '│', border_style);
for (i, ch) in padded.chars().enumerate() {
write_cell(buf, left + 1 + i as u16, top + 1, ch, bubble_style);
}
write_cell(buf, left + box_w - 1, top + 1, '│', border_style);
// Row 2: ╰──...─╮─...──╯ with the tail join at obj_sx (clamped inside the box)
let tail_col = obj_sx.clamp(left + 1, left + box_w - 2); let tail_col = obj_sx.clamp(left + 1, left + box_w - 2);
write_cell(buf, left, top + 2, '', border_style); write_cell(buf, tail_col, top + 2, '', border_style);
for cx in (left + 1)..(left + box_w - 1) {
let ch = if cx == tail_col { '╮' } else { '─' };
write_cell(buf, cx, top + 2, ch, border_style);
}
write_cell(buf, left + box_w - 1, top + 2, '╯', border_style);
// Row 3: vertical tail descending to the object
write_cell(buf, tail_col, top + 3, '│', border_style); write_cell(buf, tail_col, top + 3, '│', border_style);
} }
} }
@@ -237,14 +227,17 @@ fn write_cell(buf: &mut Buffer, x: u16, y: u16, ch: char, style: Style) {
/// Draws a scrollable text/choice overlay centered on `area`, used for `scroll()` calls. /// Draws a scrollable text/choice overlay centered on `area`, used for `scroll()` calls.
/// ///
/// The overlay dims the entire background, then renders a `Block`-bordered window /// Returns the maximum valid scroll offset (0 when all content fits). The caller should
/// whose height animates from 0 → full (opening) or full → 0 (closing) based on /// store this and use it to clamp `offset` on future frames.
/// `anim` (0.0 = invisible, 1.0 = fully open). Text is pre-wrapped so the exact ///
/// line count is known for the animation; a `Paragraph` renders the content. /// The overlay dims the entire background, then renders a `Block`-bordered window.
/// Choice lines show an `[a]` / `[b]` key prefix in yellow. `offset` scrolls the /// Width is half the screen; height is 3/4 the screen rounded down to an even number
/// content vertically. /// so the open/close animation (0.0 → 1.0) grows by 2 rows at a time from the center.
pub fn draw_scroll_overlay(buf: &mut Buffer, area: Rect, scroll: &Scroll, offset: u16, anim: f32) { /// If the content is shorter than the window, it is vertically centered via `Layout`.
if anim <= 0.0 { return; } /// Text lines whose source starts with whitespace are rendered horizontally centered
/// (whitespace stripped); all other lines are left-aligned. `offset` scrolls content.
pub fn draw_scroll_overlay(buf: &mut Buffer, area: Rect, scroll: &Scroll, offset: u16, anim: f32) -> u16 {
if anim <= 0.0 { return 0; }
let bg = Color::Rgb(30, 25, 10); let bg = Color::Rgb(30, 25, 10);
let text_style = Style::default().fg(Color::White).bg(bg); let text_style = Style::default().fg(Color::White).bg(bg);
@@ -252,23 +245,37 @@ pub fn draw_scroll_overlay(buf: &mut Buffer, area: Rect, scroll: &Scroll, offset
let choice_style = Style::default().fg(Color::Rgb(200, 200, 100)).bg(bg); let choice_style = Style::default().fg(Color::Rgb(200, 200, 100)).bg(bg);
let border_style = Style::default().fg(Color::Rgb(180, 160, 100)).bg(bg); let border_style = Style::default().fg(Color::Rgb(180, 160, 100)).bg(bg);
// Pre-wrap text so we know the total line count for the animation height. // Fixed proportional sizing. Height is forced even so each animation step
// max_text_w is the usable text width inside the block (border + 1 pad each side). // expands the window by exactly 2 rows (one on each side from the center).
let max_text_w = (area.width as usize).saturating_sub(6).clamp(10, 60); let outer_w = area.width / 2;
let outer_h = (area.height * 3 / 4) & !1;
// Animate height: keep it even at every intermediate frame too.
let actual_h = ((outer_h as f32 * anim).round() as u16 & !1).max(2);
let left = area.x + area.width.saturating_sub(outer_w) / 2;
let top = area.y + area.height.saturating_sub(actual_h) / 2;
let overlay_rect = Rect { x: left, y: top, width: outer_w, height: actual_h };
// Build Lines without pre-wrapping — Paragraph handles wrapping at render time.
// Lines whose source starts with whitespace are centered; others are left-aligned.
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
let mut choice_letter = b'a'; let mut choice_letter = b'a';
for item in &scroll.lines { for item in &scroll.lines {
match item { match item {
ScrollLine::Text(s) => { ScrollLine::Text(s) => {
for wrapped in wrap_text(s, max_text_w) { let text = s.trim();
// Leading space provides left-padding inside the border. let line = Line::styled(text.to_string(), text_style);
lines.push(Line::styled(format!(" {wrapped}"), text_style)); lines.push(if s.starts_with(|c: char| c.is_ascii_whitespace()) {
} line.centered()
} else {
line
});
} }
ScrollLine::Choice { display, .. } => { ScrollLine::Choice { display, .. } => {
let key = choice_letter as char; let key = choice_letter as char;
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled(format!(" [{key}] "), key_style), Span::styled(format!("[{key}] "), key_style),
Span::styled(display.clone(), choice_style), Span::styled(display.clone(), choice_style),
])); ]));
choice_letter += 1; choice_letter += 1;
@@ -276,20 +283,8 @@ pub fn draw_scroll_overlay(buf: &mut Buffer, area: Rect, scroll: &Scroll, offset
} }
} }
// Derive overlay dimensions. Width fits the longest line; height animates. // Dim the whole background, then use Fill to paint the overlay area with solid
let content_w = lines.iter().map(|l| l.width()).max().unwrap_or(0).min(max_text_w); // background-colored spaces so board glyphs can't show through.
let outer_w = (content_w + 4) as u16; // border (1) + pad (1) each side = +4 total
let full_outer_h = (lines.len() + 2) as u16; // +2 for top/bottom border rows
let max_outer_h = ((area.height as f32 * 0.85) as u16).max(4);
let actual_outer_h = ((full_outer_h as f32 * anim).ceil() as u16)
.min(max_outer_h)
.max(1);
let left = area.x + area.width.saturating_sub(outer_w) / 2;
let top = area.y + area.height.saturating_sub(actual_outer_h) / 2;
let overlay_rect = Rect { x: left, y: top, width: outer_w, height: actual_outer_h };
// Dim the background — no ratatui widget does this, so it stays manual.
let dim_style = Style::default().fg(Color::Rgb(60, 60, 60)).bg(Color::Black); let dim_style = Style::default().fg(Color::Rgb(60, 60, 60)).bg(Color::Black);
for y in area.top()..area.bottom() { for y in area.top()..area.bottom() {
for x in area.left()..area.right() { for x in area.left()..area.right() {
@@ -298,32 +293,47 @@ pub fn draw_scroll_overlay(buf: &mut Buffer, area: Rect, scroll: &Scroll, offset
} }
} }
} }
Fill::new(" ").style(Style::default().bg(bg)).render(overlay_rect, buf);
// Render the bordered box, then the scrollable content inside it. // Render the bordered box, then split inner: text on the left, scrollbar on the right.
let block = Block::bordered().border_style(border_style).style(Style::default().bg(bg)); let block = Block::bordered().border_style(border_style).style(Style::default().bg(bg));
let inner = block.inner(overlay_rect); let inner = block.inner(overlay_rect);
block.render(overlay_rect, buf); block.render(overlay_rect, buf);
Paragraph::new(lines).scroll((offset, 0)).render(inner, buf);
}
/// Wraps `text` at word boundaries so no line exceeds `max_width` characters. let [content_area, scrollbar_area] = Layout::horizontal([
/// Used to pre-compute line count for the scroll overlay's animated height. Constraint::Min(0),
fn wrap_text(text: &str, max_width: usize) -> Vec<String> { Constraint::Length(1),
if max_width == 0 { return vec![text.to_string()]; } ]).areas(inner);
let mut lines: Vec<String> = Vec::new();
let mut current = String::new(); // Measure wrapped content height using the narrower content_area width, clamp
for word in text.split_whitespace() { // scroll so content can't be pushed out of view.
if current.is_empty() { let para = Paragraph::new(lines).wrap(Wrap { trim: false });
current.push_str(word); let content_lines = para.line_count(content_area.width) as u16;
} else if current.len() + 1 + word.len() <= max_width { let max_scroll = content_lines.saturating_sub(content_area.height);
current.push(' '); let clamped = offset.min(max_scroll);
current.push_str(word); let para = para.scroll((clamped, 0));
} else {
lines.push(std::mem::take(&mut current)); // Center content vertically when it's shorter than the window.
current.push_str(word); let pad = content_area.height.saturating_sub(content_lines) / 2;
} let [_, content_rect, _] = Layout::vertical([
Constraint::Length(pad),
Constraint::Length(content_lines.min(content_area.height)),
Constraint::Fill(1),
]).areas(content_area);
para.render(content_rect, buf);
// Scrollbar — only shown when content exceeds the visible height.
// content_length is set to max_scroll + 1 so that position 0 maps to the top
// of the track and position max_scroll maps exactly to the bottom. Using the
// full content_lines as content_length would leave the thumb short of the
// bottom at max scroll because ratatui's thumb formula is based on
// (content_length - 1) as the maximum position.
if content_lines > content_area.height {
let mut sb_state = ScrollbarState::new((max_scroll + 1) as usize)
.position(clamped as usize);
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.render(scrollbar_area, buf, &mut sb_state);
} }
if !current.is_empty() { lines.push(current); }
if lines.is_empty() { lines.push(String::new()); } max_scroll
lines
} }
+46 -3
View File
@@ -60,14 +60,13 @@ content = """
# # # #
# # # #
# # # #
# ###### # # ###### B #
# # # # # #
# +++ # # # +++ # #
# # # # # #
# | # # | #
# # # #
# oo G - M @ # # oo G - @ #
# #
# # # #
# # # #
# # # #
@@ -75,6 +74,7 @@ content = """
# # # #
# # # #
# # # #
# M #
# # # #
# # # #
# # # #
@@ -111,6 +111,16 @@ solid = true
name = "muffin" name = "muffin"
script_name = "muffin" script_name = "muffin"
# Notice board — long scroll to test overflow and scrolling.
[[objects]]
palette = "B"
tile = 240
fg = "#cc9944"
bg = "#000000"
solid = true
name = "noticeboard"
script_name = "noticeboard"
[scripts] [scripts]
greeter = """ greeter = """
fn init() { fn init() {
@@ -162,4 +172,37 @@ fn ignore() {
log("You walk away from the muffin. Wise."); log("You walk away from the muffin. Wise.");
say("Suspicious..."); say("Suspicious...");
} }
"""
noticeboard = """
fn bump(id) {
scroll([
" TOWN NOTICE BOARD",
" ",
"ROAD CLOSURE — The eastern road through Miller's Crossing remains closed",
"following flood damage to the main bridge. Repairs are underway but travellers",
"should expect delays of at least three weeks. The north fork via Ashwell is",
"passable, though it adds half a day to the journey.",
" ",
"LOST ANIMAL — One grey mule, answers to the name Pepper. Last seen grazing",
"near the old mill on the morning of the 14th. The animal has a notched left",
"ear and wears a blue rope halter. Any information to the innkeeper; a reward",
"of two silver pieces is offered for her safe return.",
" ",
"POSITION AVAILABLE — Captain Aldric of the town garrison seeks an experienced",
"wilderness guide for an expedition into the Darkwood. Duration: ten to fourteen",
"days. Pay is good and provisions will be supplied. Candidates must present",
"themselves at the barracks before the evening bell. Some risk is involved and",
"the captain asks that only those in sound health and good standing apply.",
" ",
"HARVEST FESTIVAL — The annual Harvest Festival will be held on the last",
"Saturday of the month. The market square will be closed to carts from dawn.",
"Stall permits must be obtained from the clerk's office no later than Thursday.",
"There will be music, a pie competition, and the traditional lantern parade at",
"dusk. All residents are warmly encouraged to attend.",
" ",
" Posted by order of the Town Council.",
" Unauthorised notices will be removed.",
]);
}
""" """