diff --git a/CLAUDE.md b/CLAUDE.md index c4fc16a..ead19dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` — 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. diff --git a/Cargo.lock b/Cargo.lock index aea9df7..d2f0103 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index 49ce94b..24c1c6b 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -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"); +} diff --git a/kiln-tui/Cargo.toml b/kiln-tui/Cargo.toml index ef50ebb..ebed490 100644 --- a/kiln-tui/Cargo.toml +++ b/kiln-tui/Cargo.toml @@ -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"] } diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index e29a784..6f5f5d3 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -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, } @@ -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) { @@ -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); } } diff --git a/kiln-tui/src/render.rs b/kiln-tui/src/render.rs index 9dcde31..6af35e1 100644 --- a/kiln-tui/src/render.rs +++ b/kiln-tui/src/render.rs @@ -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!(" {: 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,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 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 = 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; lines.push(Line::from(vec![ - Span::styled(format!(" [{key}] "), key_style), + Span::styled(format!("[{key}] "), key_style), Span::styled(display.clone(), choice_style), ])); 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. - 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); -} -/// 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 { - if max_width == 0 { return vec![text.to_string()]; } - let mut lines: Vec = 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); - } + 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); } - if !current.is_empty() { lines.push(current); } - if lines.is_empty() { lines.push(String::new()); } - lines + + max_scroll } diff --git a/maps/start.toml b/maps/start.toml index a154cc4..0ec2247 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -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() { @@ -162,4 +172,37 @@ fn ignore() { log("You walk away from the muffin. Wise."); 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.", + ]); +} """ \ No newline at end of file