113 lines
4.2 KiB
Rust
113 lines
4.2 KiB
Rust
|
|
//! `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;
|
||
|
|
use kiln_core::map_file;
|
||
|
|
use ratatui::Frame;
|
||
|
|
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||
|
|
use ratatui::widgets::Block;
|
||
|
|
use render::BoardWidget;
|
||
|
|
use std::process::ExitCode;
|
||
|
|
use term::TerminalCaps;
|
||
|
|
|
||
|
|
/// 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();
|
||
|
|
|
||
|
|
// 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();
|
||
|
|
}
|
||
|
|
|
||
|
|
let result = run(&mut terminal, &mut game, &caps);
|
||
|
|
|
||
|
|
if caps.keyboard_enhancement {
|
||
|
|
let _ = term::pop_kitty_flags();
|
||
|
|
}
|
||
|
|
ratatui::restore();
|
||
|
|
|
||
|
|
if let Err(e) = result {
|
||
|
|
eprintln!("error: {e}");
|
||
|
|
return ExitCode::FAILURE;
|
||
|
|
}
|
||
|
|
ExitCode::SUCCESS
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Draw-then-handle-input loop. Returns once the user asks to quit.
|
||
|
|
fn run(
|
||
|
|
terminal: &mut ratatui::DefaultTerminal,
|
||
|
|
game: &mut GameState,
|
||
|
|
caps: &TerminalCaps,
|
||
|
|
) -> std::io::Result<()> {
|
||
|
|
loop {
|
||
|
|
// Redraw the board every iteration; ratatui diffs against the previous
|
||
|
|
// frame so only changed cells are actually written to the terminal.
|
||
|
|
terminal.draw(|frame| draw(frame, game, caps))?;
|
||
|
|
|
||
|
|
// Block until the next input event.
|
||
|
|
if let Event::Key(key) = event::read()? {
|
||
|
|
// Act on Press and Repeat so holding a key moves the player
|
||
|
|
// 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.
|
||
|
|
if key.kind == KeyEventKind::Release {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
match key.code {
|
||
|
|
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
||
|
|
KeyCode::Up | KeyCode::Char('k') => game.try_move(0, -1),
|
||
|
|
KeyCode::Down | KeyCode::Char('j') => game.try_move(0, 1),
|
||
|
|
KeyCode::Left | KeyCode::Char('h') => game.try_move(-1, 0),
|
||
|
|
KeyCode::Right | KeyCode::Char('l') => game.try_move(1, 0),
|
||
|
|
_ => {}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Render a single frame: a bordered block titled with the board name and
|
||
|
|
/// controls, containing the board itself.
|
||
|
|
fn draw(frame: &mut Frame, game: &GameState, caps: &TerminalCaps) {
|
||
|
|
let board = &game.board;
|
||
|
|
let block = Block::bordered()
|
||
|
|
.title(format!(" {} — arrows move · q quit ", board.name))
|
||
|
|
// Surface detected terminal capabilities in the bottom-right border.
|
||
|
|
.title_bottom(caps.summary_line().right_aligned());
|
||
|
|
// Draw the board inside the border, not over it.
|
||
|
|
let inner = block.inner(frame.area());
|
||
|
|
frame.render_widget(block, frame.area());
|
||
|
|
frame.render_widget(BoardWidget::new(board), inner);
|
||
|
|
}
|