scrolls
This commit is contained in:
@@ -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.
|
||||
|
||||
**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:
|
||||
- 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()`.
|
||||
@@ -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.
|
||||
- `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 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_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.
|
||||
- `wrap_text(text, max_width) -> Vec<String>` — word-wraps a string to lines no longer than `max_width` characters.
|
||||
- `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/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.
|
||||
|
||||
Generated
+101
-34
@@ -37,6 +37,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "atomic"
|
||||
version = "0.6.1"
|
||||
@@ -81,9 +90,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
@@ -100,6 +109,12 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "by_address"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
@@ -185,13 +200,19 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "critical-section"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"crossterm_winapi",
|
||||
"derive_more",
|
||||
"document-features",
|
||||
@@ -369,6 +390,12 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fast-srgb8"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
@@ -505,6 +532,11 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -646,13 +678,19 @@ version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "line-clipping"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -684,11 +722,11 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.16.4"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||
checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -746,7 +784,7 @@ version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -816,6 +854,30 @@ dependencies = [
|
||||
"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]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -1009,9 +1071,9 @@ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.30.0"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc"
|
||||
checksum = "1695748e3a735b34968c887ceea5a380b43545903868ae8f5b666593100f6b68"
|
||||
dependencies = [
|
||||
"instability",
|
||||
"ratatui-core",
|
||||
@@ -1019,21 +1081,25 @@ dependencies = [
|
||||
"ratatui-macros",
|
||||
"ratatui-termwiz",
|
||||
"ratatui-widgets",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-core"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293"
|
||||
checksum = "42d3603f354bba8c595fa47860e60142d7372b7210c27044c6a7d0e1a4336b44"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"compact_str",
|
||||
"hashbrown 0.16.1",
|
||||
"critical-section",
|
||||
"hashbrown 0.17.1",
|
||||
"indoc",
|
||||
"itertools",
|
||||
"kasuari",
|
||||
"lru",
|
||||
"palette",
|
||||
"serde",
|
||||
"strum",
|
||||
"thiserror 2.0.18",
|
||||
"unicode-segmentation",
|
||||
@@ -1043,9 +1109,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-crossterm"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3"
|
||||
checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossterm",
|
||||
@@ -1055,9 +1121,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-macros"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4"
|
||||
checksum = "80fac59720679490d89d200df411faa249be728681adcabed3d047ae72c48f1d"
|
||||
dependencies = [
|
||||
"ratatui-core",
|
||||
"ratatui-widgets",
|
||||
@@ -1065,9 +1131,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-termwiz"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c"
|
||||
checksum = "386b8ff8f74ed749509391c56d549761a2fcdb408e1f42e467286bcb7dac8967"
|
||||
dependencies = [
|
||||
"ratatui-core",
|
||||
"termwiz",
|
||||
@@ -1075,17 +1141,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-widgets"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db"
|
||||
checksum = "7ef4f17dd7ac3abf5adc2b920a03c61eee4bfe6a88fa5191936895525371d79c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"hashbrown 0.16.1",
|
||||
"bitflags 2.13.0",
|
||||
"hashbrown 0.17.1",
|
||||
"indoc",
|
||||
"instability",
|
||||
"itertools",
|
||||
"line-clipping",
|
||||
"ratatui-core",
|
||||
"serde",
|
||||
"strum",
|
||||
"time",
|
||||
"unicode-segmentation",
|
||||
@@ -1098,7 +1165,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1137,7 +1204,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f9ef5dabe4c0b43d8f1187dc6beb67b53fe607fff7e30c5eb7f71b814b8c2c1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"rhai_codegen",
|
||||
@@ -1173,7 +1240,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@@ -1341,18 +1408,18 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.2"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.27.2"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
|
||||
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
@@ -1411,7 +1478,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"fancy-regex",
|
||||
"filedescriptor",
|
||||
"finl_unicode",
|
||||
@@ -1746,7 +1813,7 @@ version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
@@ -1944,7 +2011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::time::Duration;
|
||||
use crate::board::tests::open_board;
|
||||
use crate::game::GameState;
|
||||
use crate::game::{GameState, ScrollLine};
|
||||
use crate::utils::Direction;
|
||||
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));
|
||||
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
@@ -6,4 +6,4 @@ edition = "2024"
|
||||
[dependencies]
|
||||
kiln-core = { path = "../kiln-core" }
|
||||
color = "0.3.3"
|
||||
ratatui = "0.30"
|
||||
ratatui = { version = "0.30.1", features = ["unstable-rendered-line-info"] }
|
||||
|
||||
+22
-6
@@ -53,6 +53,8 @@ struct Ui {
|
||||
scroll: u16,
|
||||
/// Vertical scroll offset within an active scroll overlay.
|
||||
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.
|
||||
scroll_anim: Option<ScrollAnimState>,
|
||||
}
|
||||
@@ -64,11 +66,20 @@ impl Default for Ui {
|
||||
log_height: 10,
|
||||
scroll: 0,
|
||||
scroll_offset: 0,
|
||||
scroll_max: 0,
|
||||
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.
|
||||
/// Uses the current opening progress if the overlay was still animating in.
|
||||
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;
|
||||
// with choices present the player must select one.
|
||||
KeyCode::Esc if !has_choices => begin_close(ui, None),
|
||||
KeyCode::Up => ui.scroll_offset = ui.scroll_offset.saturating_sub(1),
|
||||
KeyCode::Down => ui.scroll_offset = ui.scroll_offset.saturating_add(1),
|
||||
KeyCode::Up => ui.scroll_lines(-1),
|
||||
KeyCode::Down => ui.scroll_lines(1),
|
||||
KeyCode::Char(c) => {
|
||||
if let Some(ch) = resolve_choice(&scroll.lines, c) {
|
||||
begin_close(ui, Some(ch));
|
||||
@@ -233,8 +244,8 @@ fn run(
|
||||
&& !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing { .. }));
|
||||
if scroll_active {
|
||||
match m.kind {
|
||||
MouseEventKind::ScrollUp => ui.scroll_offset = ui.scroll_offset.saturating_sub(1),
|
||||
MouseEventKind::ScrollDown => ui.scroll_offset = ui.scroll_offset.saturating_add(1),
|
||||
MouseEventKind::ScrollUp => ui.scroll_lines(-1),
|
||||
MouseEventKind::ScrollDown => ui.scroll_lines(1),
|
||||
_ => {}
|
||||
}
|
||||
} else if ui.log_open {
|
||||
@@ -278,6 +289,7 @@ fn run(
|
||||
game.close_scroll(ch.as_deref());
|
||||
ui.scroll_anim = None;
|
||||
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
|
||||
/// previewed in the board's bottom-left border after a `[l]og:` label.
|
||||
/// 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).
|
||||
let board = game.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.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+87
-77
@@ -11,10 +11,11 @@ use kiln_core::Board;
|
||||
use kiln_core::game::{Scroll, ScrollLine, SpeechBubble};
|
||||
use kiln_core::log::LogLine;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
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`].
|
||||
///
|
||||
@@ -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 });
|
||||
|
||||
// Row 0: ╭──...──╮
|
||||
write_cell(buf, left, top, '╭', border_style);
|
||||
for cx in (left + 1)..(left + box_w - 1) {
|
||||
write_cell(buf, cx, top, '─', border_style);
|
||||
}
|
||||
write_cell(buf, left + box_w - 1, top, '╮', border_style);
|
||||
// Render the rounded box (3 rows: top border, text, bottom border) with ratatui.
|
||||
let box_rect = Rect { x: left, y: top, width: box_w, height: 3 };
|
||||
let block = Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(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)
|
||||
let inner_w = (box_w - 2) as usize;
|
||||
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)
|
||||
// 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.
|
||||
let tail_col = obj_sx.clamp(left + 1, left + box_w - 2);
|
||||
write_cell(buf, left, 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 + 2, '╮', 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.
|
||||
///
|
||||
/// The overlay dims the entire background, then renders a `Block`-bordered window
|
||||
/// whose height animates from 0 → full (opening) or full → 0 (closing) based on
|
||||
/// `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.
|
||||
/// Choice lines show an `[a]` / `[b]` key prefix in yellow. `offset` scrolls the
|
||||
/// content vertically.
|
||||
pub fn draw_scroll_overlay(buf: &mut Buffer, area: Rect, scroll: &Scroll, offset: u16, anim: f32) {
|
||||
if anim <= 0.0 { return; }
|
||||
/// Returns the maximum valid scroll offset (0 when all content fits). The caller should
|
||||
/// store this and use it to clamp `offset` on future frames.
|
||||
///
|
||||
/// The overlay dims the entire background, then renders a `Block`-bordered window.
|
||||
/// Width is half the screen; height is 3/4 the screen rounded down to an even number
|
||||
/// so the open/close animation (0.0 → 1.0) grows by 2 rows at a time from the center.
|
||||
/// If the content is shorter than the window, it is vertically centered via `Layout`.
|
||||
/// 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 text_style = Style::default().fg(Color::White).bg(bg);
|
||||
@@ -252,18 +245,32 @@ 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 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.
|
||||
// max_text_w is the usable text width inside the block (border + 1 pad each side).
|
||||
let max_text_w = (area.width as usize).saturating_sub(6).clamp(10, 60);
|
||||
// Fixed proportional sizing. Height is forced even so each animation step
|
||||
// expands the window by exactly 2 rows (one on each side from the center).
|
||||
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 choice_letter = b'a';
|
||||
for item in &scroll.lines {
|
||||
match item {
|
||||
ScrollLine::Text(s) => {
|
||||
for wrapped in wrap_text(s, max_text_w) {
|
||||
// Leading space provides left-padding inside the border.
|
||||
lines.push(Line::styled(format!(" {wrapped}"), text_style));
|
||||
}
|
||||
let text = s.trim();
|
||||
let line = Line::styled(text.to_string(), text_style);
|
||||
lines.push(if s.starts_with(|c: char| c.is_ascii_whitespace()) {
|
||||
line.centered()
|
||||
} else {
|
||||
line
|
||||
});
|
||||
}
|
||||
ScrollLine::Choice { display, .. } => {
|
||||
let key = choice_letter as char;
|
||||
@@ -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.
|
||||
let content_w = lines.iter().map(|l| l.width()).max().unwrap_or(0).min(max_text_w);
|
||||
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.
|
||||
// Dim the whole background, then use Fill to paint the overlay area with solid
|
||||
// background-colored spaces so board glyphs can't show through.
|
||||
let dim_style = Style::default().fg(Color::Rgb(60, 60, 60)).bg(Color::Black);
|
||||
for y in area.top()..area.bottom() {
|
||||
for x in area.left()..area.right() {
|
||||
@@ -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 inner = block.inner(overlay_rect);
|
||||
block.render(overlay_rect, buf);
|
||||
Paragraph::new(lines).scroll((offset, 0)).render(inner, buf);
|
||||
|
||||
let [content_area, scrollbar_area] = Layout::horizontal([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(1),
|
||||
]).areas(inner);
|
||||
|
||||
// Measure wrapped content height using the narrower content_area width, clamp
|
||||
// scroll so content can't be pushed out of view.
|
||||
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
|
||||
let content_lines = para.line_count(content_area.width) as u16;
|
||||
let max_scroll = content_lines.saturating_sub(content_area.height);
|
||||
let clamped = offset.min(max_scroll);
|
||||
let para = para.scroll((clamped, 0));
|
||||
|
||||
// Center content vertically when it's shorter than the window.
|
||||
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);
|
||||
}
|
||||
|
||||
/// Wraps `text` at word boundaries so no line exceeds `max_width` characters.
|
||||
/// Used to pre-compute line count for the scroll overlay's animated height.
|
||||
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
|
||||
if max_width == 0 { return vec![text.to_string()]; }
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let mut current = String::new();
|
||||
for word in text.split_whitespace() {
|
||||
if current.is_empty() {
|
||||
current.push_str(word);
|
||||
} else if current.len() + 1 + word.len() <= max_width {
|
||||
current.push(' ');
|
||||
current.push_str(word);
|
||||
} else {
|
||||
lines.push(std::mem::take(&mut current));
|
||||
current.push_str(word);
|
||||
}
|
||||
}
|
||||
if !current.is_empty() { lines.push(current); }
|
||||
if lines.is_empty() { lines.push(String::new()); }
|
||||
lines
|
||||
max_scroll
|
||||
}
|
||||
|
||||
+46
-3
@@ -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"
|
||||
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]
|
||||
greeter = """
|
||||
fn init() {
|
||||
@@ -163,3 +173,36 @@ fn ignore() {
|
||||
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.",
|
||||
]);
|
||||
}
|
||||
"""
|
||||
Reference in New Issue
Block a user