2026-06-03 20:10:02 -05:00
|
|
|
//! `kiln-tui` — a terminal "player" for kiln boards.
|
|
|
|
|
//!
|
|
|
|
|
//! Pass a `.toml` map file as the single command-line argument; kiln-tui loads
|
|
|
|
|
//! that board and lets you walk the player around it with the arrow keys
|
|
|
|
|
//! (or HJKL). There is no editor — this is a play-only front-end.
|
|
|
|
|
//!
|
|
|
|
|
//! Rendering is text-based: kiln's bitmap-font tile indices are reinterpreted
|
|
|
|
|
//! as characters (see [`cp437`]) and drawn with each cell's RGB colors.
|
|
|
|
|
|
|
|
|
|
mod cp437;
|
|
|
|
|
mod render;
|
2026-06-11 21:31:37 -05:00
|
|
|
mod scroll_overlay;
|
2026-06-03 20:10:02 -05:00
|
|
|
mod term;
|
2026-06-11 21:31:37 -05:00
|
|
|
mod utils;
|
|
|
|
|
mod speech;
|
2026-06-03 20:10:02 -05:00
|
|
|
|
2026-06-10 01:42:33 -05:00
|
|
|
use kiln_core::game::{GameState, ScrollLine};
|
2026-06-03 22:46:54 -05:00
|
|
|
use kiln_core::log::LogLine;
|
2026-06-03 20:10:02 -05:00
|
|
|
use kiln_core::map_file;
|
2026-06-08 22:15:44 -05:00
|
|
|
use kiln_core::Direction;
|
2026-06-03 20:10:02 -05:00
|
|
|
use ratatui::Frame;
|
2026-06-03 22:46:54 -05:00
|
|
|
use ratatui::crossterm::event::{
|
|
|
|
|
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind,
|
|
|
|
|
};
|
|
|
|
|
use ratatui::crossterm::execute;
|
|
|
|
|
use ratatui::layout::{Constraint, Layout, Spacing};
|
|
|
|
|
use ratatui::style::{Color, Style};
|
2026-06-04 23:52:33 -05:00
|
|
|
use ratatui::symbols::merge::MergeStrategy;
|
2026-06-03 22:46:54 -05:00
|
|
|
use ratatui::text::{Line, Span};
|
|
|
|
|
use ratatui::widgets::{Block, Paragraph, Wrap};
|
2026-06-11 21:31:37 -05:00
|
|
|
use render::{BoardWidget};
|
2026-06-03 22:46:54 -05:00
|
|
|
use std::io;
|
2026-06-03 20:10:02 -05:00
|
|
|
use std::process::ExitCode;
|
2026-06-03 23:21:05 -05:00
|
|
|
use std::time::{Duration, Instant};
|
2026-06-03 20:10:02 -05:00
|
|
|
use term::TerminalCaps;
|
2026-06-11 21:31:37 -05:00
|
|
|
use crate::scroll_overlay::{ScrollAnimState, ScrollOverlayState, ScrollOverlayWidget};
|
|
|
|
|
use crate::speech::SpeechBubblesWidget;
|
|
|
|
|
use crate::utils::logline_to_line;
|
2026-06-10 01:42:33 -05:00
|
|
|
|
|
|
|
|
/// View-only UI state for the log panel and scroll overlay. This is presentation
|
|
|
|
|
/// state, so it lives in the front-end rather than on the engine's [`GameState`].
|
2026-06-03 22:46:54 -05:00
|
|
|
struct Ui {
|
|
|
|
|
/// Whether the bottom log panel is open.
|
|
|
|
|
log_open: bool,
|
|
|
|
|
/// Height (in terminal rows, borders included) of the log panel when open.
|
|
|
|
|
log_height: u16,
|
|
|
|
|
/// Vertical scroll offset of the log panel, in lines from the top (newest).
|
|
|
|
|
scroll: u16,
|
2026-06-11 21:31:37 -05:00
|
|
|
/// Scroll position, bounds, and animation state for an active scroll overlay.
|
|
|
|
|
overlay: ScrollOverlayState,
|
2026-06-03 22:46:54 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for Ui {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
log_open: false,
|
|
|
|
|
log_height: 10,
|
|
|
|
|
scroll: 0,
|
2026-06-11 21:31:37 -05:00
|
|
|
overlay: ScrollOverlayState::default(),
|
2026-06-10 01:42:33 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 18:53:52 -05:00
|
|
|
impl Ui {
|
|
|
|
|
/// Scrolls the overlay by `delta` lines, clamped to the valid range.
|
|
|
|
|
fn scroll_lines(&mut self, delta: i32) {
|
2026-06-11 21:31:37 -05:00
|
|
|
self.overlay.offset = (self.overlay.offset as i32 + delta)
|
|
|
|
|
.clamp(0, self.overlay.max_scroll as i32) as u16;
|
2026-06-11 18:53:52 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 01:42:33 -05:00
|
|
|
/// 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>) {
|
2026-06-11 21:31:37 -05:00
|
|
|
let progress = match &ui.overlay.anim {
|
2026-06-10 01:42:33 -05:00
|
|
|
Some(ScrollAnimState::Opening(p)) => p.min(1.0),
|
|
|
|
|
_ => 1.0,
|
|
|
|
|
};
|
2026-06-11 21:31:37 -05:00
|
|
|
ui.overlay.pending_choice = choice;
|
|
|
|
|
ui.overlay.anim = Some(ScrollAnimState::Closing(progress));
|
2026-06-10 01:42:33 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the choice string for the given key character, or `None` if it doesn't
|
|
|
|
|
/// match any choice in the scroll. Choices are assigned letters a, b, c… in order.
|
|
|
|
|
fn resolve_choice(lines: &[ScrollLine], c: char) -> Option<String> {
|
|
|
|
|
let mut letter = b'a';
|
|
|
|
|
for line in lines {
|
|
|
|
|
if let ScrollLine::Choice { choice, .. } = line {
|
|
|
|
|
if c == letter as char {
|
|
|
|
|
return Some(choice.clone());
|
|
|
|
|
}
|
|
|
|
|
letter += 1;
|
2026-06-03 22:46:54 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-10 01:42:33 -05:00
|
|
|
None
|
2026-06-03 22:46:54 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-03 20:10:02 -05:00
|
|
|
/// Entry point: parse the map path, load the board, then run the play loop with
|
|
|
|
|
/// the terminal in raw/alternate-screen mode (restored on every exit path).
|
|
|
|
|
fn main() -> ExitCode {
|
|
|
|
|
// The single positional argument is the map file to play.
|
|
|
|
|
let Some(path) = std::env::args().nth(1) else {
|
|
|
|
|
eprintln!("usage: kiln-tui <map.toml>");
|
|
|
|
|
return ExitCode::from(2);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Load before touching the terminal so load errors print to a normal screen.
|
|
|
|
|
let board = match map_file::load(&path) {
|
|
|
|
|
Ok(board) => board,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
eprintln!("error loading {path}: {e}");
|
|
|
|
|
return ExitCode::FAILURE;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut game = GameState::new(board);
|
|
|
|
|
|
|
|
|
|
// `init` enters the alternate screen and raw mode; `restore` always undoes it.
|
|
|
|
|
let mut terminal = ratatui::init();
|
|
|
|
|
|
2026-06-03 22:46:54 -05:00
|
|
|
// Capture mouse events so the log panel can be scrolled with the wheel and
|
|
|
|
|
// resized by dragging its divider. Disabled again before restoring.
|
|
|
|
|
let _ = execute!(io::stdout(), EnableMouseCapture);
|
|
|
|
|
|
2026-06-03 20:10:02 -05:00
|
|
|
// Detect terminal capabilities (now that we're in raw mode) and enable the
|
|
|
|
|
// Kitty keyboard protocol when it's available, so scripts can rely on the
|
|
|
|
|
// richer bindings it provides. Disabled again before restoring the terminal.
|
|
|
|
|
let caps = TerminalCaps::detect();
|
|
|
|
|
if caps.keyboard_enhancement {
|
|
|
|
|
let _ = term::push_kitty_flags();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 22:46:54 -05:00
|
|
|
// Seed the log: the terminal-capabilities badge plus a couple of test
|
|
|
|
|
// messages (temporary, just to exercise the panel).
|
|
|
|
|
// Note these will appear in reverse order, latest on top
|
|
|
|
|
game.log(LogLine::raw("Press 'q' to exit."));
|
|
|
|
|
game.log(LogLine::raw("Welcome to kiln - ").append(caps.status_logline()));
|
|
|
|
|
|
2026-06-04 20:11:55 -05:00
|
|
|
// Now that the board is fully loaded and the terminal is ready, run each
|
|
|
|
|
// scripted object's `init()` hook.
|
|
|
|
|
game.run_init();
|
|
|
|
|
|
2026-06-03 22:46:54 -05:00
|
|
|
let mut ui = Ui::default();
|
|
|
|
|
let result = run(&mut terminal, &mut game, &mut ui);
|
2026-06-03 20:10:02 -05:00
|
|
|
|
|
|
|
|
if caps.keyboard_enhancement {
|
|
|
|
|
let _ = term::pop_kitty_flags();
|
|
|
|
|
}
|
2026-06-03 22:46:54 -05:00
|
|
|
let _ = execute!(io::stdout(), DisableMouseCapture);
|
2026-06-03 20:10:02 -05:00
|
|
|
ratatui::restore();
|
|
|
|
|
|
|
|
|
|
if let Err(e) = result {
|
|
|
|
|
eprintln!("error: {e}");
|
|
|
|
|
return ExitCode::FAILURE;
|
|
|
|
|
}
|
|
|
|
|
ExitCode::SUCCESS
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 23:21:05 -05:00
|
|
|
/// The target frame interval: 30 frames per second.
|
|
|
|
|
const FRAME: Duration = Duration::from_nanos(1_000_000_000 / 30);
|
|
|
|
|
|
|
|
|
|
/// Real-time draw / input / update loop at ~30 FPS. Returns once the user quits.
|
|
|
|
|
///
|
|
|
|
|
/// Unlike a turn-based loop, this never blocks indefinitely: it waits for input
|
|
|
|
|
/// only until the next frame deadline (`event::poll`), then advances the game by
|
|
|
|
|
/// the real time elapsed since the last tick. `poll` returns the instant a key
|
|
|
|
|
/// arrives, so input stays responsive while the game keeps ticking on its own.
|
2026-06-03 20:10:02 -05:00
|
|
|
fn run(
|
|
|
|
|
terminal: &mut ratatui::DefaultTerminal,
|
|
|
|
|
game: &mut GameState,
|
2026-06-03 22:46:54 -05:00
|
|
|
ui: &mut Ui,
|
2026-06-03 20:10:02 -05:00
|
|
|
) -> std::io::Result<()> {
|
2026-06-03 23:21:05 -05:00
|
|
|
let mut last_tick = Instant::now();
|
2026-06-03 20:10:02 -05:00
|
|
|
loop {
|
2026-06-03 22:46:54 -05:00
|
|
|
// Redraw every iteration; ratatui diffs against the previous frame so
|
|
|
|
|
// only changed cells are actually written to the terminal.
|
|
|
|
|
terminal.draw(|frame| draw(frame, game, ui))?;
|
|
|
|
|
|
2026-06-03 23:21:05 -05:00
|
|
|
// Wait for input only up to the next frame deadline so the loop wakes to
|
|
|
|
|
// tick even with no keypresses.
|
|
|
|
|
let timeout = FRAME.saturating_sub(last_tick.elapsed());
|
|
|
|
|
if event::poll(timeout)? {
|
|
|
|
|
match event::read()? {
|
2026-06-03 22:46:54 -05:00
|
|
|
// Act on Press and Repeat so holding a key moves the player
|
2026-06-03 23:21:05 -05:00
|
|
|
// continuously (the Kitty protocol emits Repeat events while a key
|
|
|
|
|
// is held). Ignore Release, which that protocol also emits and
|
|
|
|
|
// which would otherwise fire a second, spurious move per keypress.
|
2026-06-10 01:42:33 -05:00
|
|
|
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
|
|
|
|
// When a scroll overlay is active (and not already animating
|
|
|
|
|
// closed), intercept all keys for scroll navigation/choices.
|
|
|
|
|
let scroll_active = game.active_scroll.is_some()
|
2026-06-11 21:31:37 -05:00
|
|
|
&& !matches!(ui.overlay.anim, Some(ScrollAnimState::Closing(_) | ScrollAnimState::Done));
|
2026-06-10 01:42:33 -05:00
|
|
|
if scroll_active {
|
|
|
|
|
let scroll = game.active_scroll.as_ref().unwrap();
|
|
|
|
|
let has_choices = scroll.lines.iter()
|
|
|
|
|
.any(|l| matches!(l, ScrollLine::Choice { .. }));
|
|
|
|
|
match key.code {
|
|
|
|
|
// 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),
|
2026-06-11 18:53:52 -05:00
|
|
|
KeyCode::Up => ui.scroll_lines(-1),
|
|
|
|
|
KeyCode::Down => ui.scroll_lines(1),
|
2026-06-10 01:42:33 -05:00
|
|
|
KeyCode::Char(c) => {
|
|
|
|
|
if let Some(ch) = resolve_choice(&scroll.lines, c) {
|
|
|
|
|
begin_close(ui, Some(ch));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
match key.code {
|
|
|
|
|
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
|
|
|
|
KeyCode::Up => game.try_move(Direction::North),
|
|
|
|
|
KeyCode::Down => game.try_move(Direction::South),
|
|
|
|
|
KeyCode::Left => game.try_move(Direction::West),
|
|
|
|
|
// Right arrow moves right; 'l' is the log panel.
|
|
|
|
|
KeyCode::Right => game.try_move(Direction::East),
|
|
|
|
|
// 'l' toggles the log panel; reset scroll to newest.
|
|
|
|
|
KeyCode::Char('l') => {
|
|
|
|
|
ui.log_open = !ui.log_open;
|
|
|
|
|
ui.scroll = 0;
|
|
|
|
|
}
|
|
|
|
|
// PageUp/PageDown scroll the log when it's open.
|
|
|
|
|
KeyCode::PageUp if ui.log_open => scroll_log(ui, game, -5),
|
|
|
|
|
KeyCode::PageDown if ui.log_open => scroll_log(ui, game, 5),
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
2026-06-03 22:46:54 -05:00
|
|
|
}
|
2026-06-10 01:42:33 -05:00
|
|
|
}
|
|
|
|
|
// Mouse: scroll overlay captures wheel events when active; otherwise
|
|
|
|
|
// wheel scrolls the log and drag resizes it.
|
|
|
|
|
Event::Mouse(m) => {
|
|
|
|
|
let scroll_active = game.active_scroll.is_some()
|
2026-06-11 21:31:37 -05:00
|
|
|
&& !matches!(ui.overlay.anim, Some(ScrollAnimState::Closing(_) | ScrollAnimState::Done));
|
2026-06-10 01:42:33 -05:00
|
|
|
if scroll_active {
|
|
|
|
|
match m.kind {
|
2026-06-11 18:53:52 -05:00
|
|
|
MouseEventKind::ScrollUp => ui.scroll_lines(-1),
|
|
|
|
|
MouseEventKind::ScrollDown => ui.scroll_lines(1),
|
2026-06-10 01:42:33 -05:00
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
} else if ui.log_open {
|
|
|
|
|
match m.kind {
|
|
|
|
|
MouseEventKind::ScrollUp => scroll_log(ui, game, -1),
|
|
|
|
|
MouseEventKind::ScrollDown => scroll_log(ui, game, 1),
|
|
|
|
|
MouseEventKind::Drag(_) => {
|
|
|
|
|
// The divider sits at row `total - log_height`; dragging
|
|
|
|
|
// it up (smaller row) grows the panel. Keep both usable.
|
|
|
|
|
let total = terminal.size()?.height;
|
|
|
|
|
let target = total.saturating_sub(m.row);
|
|
|
|
|
ui.log_height = target.clamp(3, total.saturating_sub(3).max(3));
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
2026-06-03 23:21:05 -05:00
|
|
|
}
|
2026-06-10 01:42:33 -05:00
|
|
|
}
|
2026-06-03 20:10:02 -05:00
|
|
|
_ => {}
|
2026-06-03 23:21:05 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Advance the game by the real time elapsed since the last tick. Only
|
|
|
|
|
// resetting `last_tick` when we actually tick means time spent waiting on
|
|
|
|
|
// input (or skipped Release events) is preserved in the next `dt`.
|
|
|
|
|
if last_tick.elapsed() >= FRAME {
|
|
|
|
|
let dt = last_tick.elapsed();
|
|
|
|
|
last_tick = Instant::now();
|
2026-06-10 01:42:33 -05:00
|
|
|
|
|
|
|
|
// Advance scroll animation regardless of game pause state.
|
|
|
|
|
let dt_s = dt.as_secs_f32();
|
2026-06-11 21:31:37 -05:00
|
|
|
ui.overlay.advance_anim(dt_s);
|
|
|
|
|
if ui.overlay.close_finished() {
|
|
|
|
|
// Animation done — dispatch choice and clear the scroll.
|
|
|
|
|
game.close_scroll(ui.overlay.take_choice().as_deref());
|
|
|
|
|
ui.overlay = ScrollOverlayState::default();
|
2026-06-10 01:42:33 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Tick the game only when no scroll overlay is blocking.
|
|
|
|
|
if game.active_scroll.is_none() {
|
|
|
|
|
game.tick(dt);
|
|
|
|
|
// Detect a scroll just opened by the tick; start its open animation.
|
2026-06-11 21:31:37 -05:00
|
|
|
if game.active_scroll.is_some() && ui.overlay.anim.is_none() {
|
|
|
|
|
ui.overlay.anim = Some(ScrollAnimState::Opening(0.0));
|
2026-06-10 01:42:33 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-03 20:10:02 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 22:46:54 -05:00
|
|
|
/// Adjusts the log scroll offset by `delta` lines, clamped so it can neither go
|
|
|
|
|
/// above the newest message nor scroll past the oldest.
|
|
|
|
|
fn scroll_log(ui: &mut Ui, game: &GameState, delta: i32) {
|
|
|
|
|
let max = game.log.len().saturating_sub(1) as i32;
|
|
|
|
|
ui.scroll = (ui.scroll as i32 + delta).clamp(0, max) as u16;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Render a single frame: the board in a bordered block, and — when open — a log
|
|
|
|
|
/// 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.
|
2026-06-10 01:42:33 -05:00
|
|
|
/// A scroll overlay is drawn on top of everything when active.
|
2026-06-11 18:53:52 -05:00
|
|
|
///
|
|
|
|
|
fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) {
|
2026-06-04 23:52:33 -05:00
|
|
|
// Borrow the board for the duration of the frame (no script runs during draw).
|
|
|
|
|
let board = game.board();
|
|
|
|
|
let board = &*board;
|
2026-06-03 22:46:54 -05:00
|
|
|
|
|
|
|
|
if ui.log_open {
|
|
|
|
|
// Split the screen: board on top, log panel of `log_height` at the bottom.
|
|
|
|
|
let [board_area, log_area] =
|
|
|
|
|
Layout::vertical([Constraint::Min(3), Constraint::Length(ui.log_height)])
|
|
|
|
|
.spacing(Spacing::Overlap(1))
|
|
|
|
|
.areas(frame.area());
|
|
|
|
|
|
2026-06-04 23:52:33 -05:00
|
|
|
let block = Block::bordered()
|
|
|
|
|
.title(format!(" {} ", board.name))
|
|
|
|
|
.merge_borders(MergeStrategy::Exact);
|
2026-06-03 22:46:54 -05:00
|
|
|
let inner = block.inner(board_area);
|
|
|
|
|
frame.render_widget(block, board_area);
|
|
|
|
|
frame.render_widget(BoardWidget::new(board), inner);
|
2026-06-11 21:31:37 -05:00
|
|
|
frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner);
|
2026-06-03 22:46:54 -05:00
|
|
|
|
|
|
|
|
// Newest message on top: reverse the log into ratatui lines.
|
|
|
|
|
let log_block = Block::bordered()
|
2026-06-04 23:52:33 -05:00
|
|
|
.title(Span::styled(
|
|
|
|
|
" [l]og: ",
|
|
|
|
|
Style::default().fg(Color::DarkGray),
|
|
|
|
|
))
|
2026-06-03 22:46:54 -05:00
|
|
|
.merge_borders(MergeStrategy::Exact);
|
|
|
|
|
let lines: Vec<Line> = game.log.iter().rev().map(logline_to_line).collect();
|
|
|
|
|
let paragraph = Paragraph::new(lines)
|
|
|
|
|
.block(log_block)
|
|
|
|
|
.scroll((ui.scroll, 0))
|
|
|
|
|
.wrap(Wrap { trim: false });
|
|
|
|
|
frame.render_widget(paragraph, log_area);
|
|
|
|
|
} else {
|
|
|
|
|
// Panel closed: board fills the screen, latest message in the border.
|
2026-06-04 23:52:33 -05:00
|
|
|
let mut block = Block::bordered().title(format!(" {} ", board.name));
|
2026-06-03 22:46:54 -05:00
|
|
|
block = block.title_bottom(log_preview(game).left_aligned());
|
|
|
|
|
let inner = block.inner(frame.area());
|
|
|
|
|
frame.render_widget(block, frame.area());
|
|
|
|
|
frame.render_widget(BoardWidget::new(board), inner);
|
2026-06-11 21:31:37 -05:00
|
|
|
frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner);
|
2026-06-03 22:46:54 -05:00
|
|
|
}
|
2026-06-10 01:42:33 -05:00
|
|
|
|
|
|
|
|
// Scroll overlay: drawn last so it sits above all other content.
|
|
|
|
|
if let Some(scroll) = &game.active_scroll {
|
|
|
|
|
// Capture area before the mutable buffer borrow to satisfy the borrow checker.
|
|
|
|
|
let area = frame.area();
|
2026-06-11 21:31:37 -05:00
|
|
|
frame.render_stateful_widget(ScrollOverlayWidget::new(scroll), area, &mut ui.overlay);
|
2026-06-10 01:42:33 -05:00
|
|
|
}
|
2026-06-03 22:46:54 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Builds the `[l]og: <latest message>` preview shown in the board's bottom-left
|
|
|
|
|
/// border when the log panel is closed.
|
|
|
|
|
fn log_preview(game: &GameState) -> Line<'static> {
|
|
|
|
|
let mut spans = vec![Span::styled(
|
|
|
|
|
" [l]og: ",
|
|
|
|
|
Style::default().fg(Color::DarkGray),
|
|
|
|
|
)];
|
|
|
|
|
if let Some(latest) = game.log.last() {
|
|
|
|
|
spans.extend(logline_to_line(latest).spans);
|
|
|
|
|
}
|
|
|
|
|
spans.push(Span::raw(" "));
|
|
|
|
|
Line::from(spans)
|
2026-06-03 20:10:02 -05:00
|
|
|
}
|