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.
**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
View File
@@ -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",
+65 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
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"
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.",
]);
}
"""