Files
kiln/kiln-tui/src/main.rs
T

244 lines
9.9 KiB
Rust
Raw Normal View History

//! `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;
mod term;
use kiln_core::game::GameState;
2026-06-03 22:46:54 -05:00
use kiln_core::log::LogLine;
use kiln_core::map_file;
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};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Wrap};
use render::{BoardWidget, logline_to_line};
use std::io;
use std::process::ExitCode;
2026-06-03 23:21:05 -05:00
use std::time::{Duration, Instant};
2026-06-03 22:46:54 -05:00
use ratatui::symbols::merge::MergeStrategy;
use term::TerminalCaps;
2026-06-03 22:46:54 -05:00
/// View-only UI state for the log panel. This is presentation state, so it lives
/// in the front-end rather than on the engine's [`GameState`].
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,
}
impl Default for Ui {
fn default() -> Self {
Self {
log_open: false,
log_height: 10,
scroll: 0,
}
}
}
/// 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);
// 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()));
let mut ui = Ui::default();
let result = run(&mut terminal, &mut game, &mut ui);
if caps.keyboard_enhancement {
let _ = term::pop_kitty_flags();
}
2026-06-03 22:46:54 -05:00
let _ = execute!(io::stdout(), DisableMouseCapture);
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.
fn run(
terminal: &mut ratatui::DefaultTerminal,
game: &mut GameState,
2026-06-03 22:46:54 -05:00
ui: &mut Ui,
) -> std::io::Result<()> {
2026-06-03 23:21:05 -05:00
let mut last_tick = Instant::now();
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.
Event::Key(key) if key.kind != KeyEventKind::Release => match key.code {
2026-06-03 22:46:54 -05:00
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Up => game.try_move(0, -1),
KeyCode::Down => game.try_move(0, 1),
KeyCode::Left => game.try_move(-1, 0),
2026-06-03 23:21:05 -05:00
// Right arrow moves right; 'l' is the log panel.
2026-06-03 22:46:54 -05:00
KeyCode::Right => game.try_move(1, 0),
2026-06-03 23:21:05 -05:00
// 'l' toggles the log panel; reset scroll to newest.
2026-06-03 22:46:54 -05:00
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 23:21:05 -05:00
},
// Wheel scrolls the open log; dragging the divider resizes it.
Event::Mouse(m) 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
}
}
// 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();
game.tick(dt);
}
}
}
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.
fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) {
let board = &game.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());
let block = Block::bordered().title(format!(" {} ", board.name)).merge_borders(MergeStrategy::Exact);
let inner = block.inner(board_area);
frame.render_widget(block, board_area);
frame.render_widget(BoardWidget::new(board), inner);
// Newest message on top: reverse the log into ratatui lines.
let log_block = Block::bordered()
.title(Span::styled(" [l]og: ", Style::default().fg(Color::DarkGray)))
.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.
let mut block =
Block::bordered().title(format!(" {} ", board.name));
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);
}
}
/// 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)
}