//! `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 log; mod render; mod scroll_overlay; mod term; mod utils; mod speech; mod ui; mod input; use kiln_core::game::GameState; use kiln_core::log::LogLine; use kiln_core::world; use ratatui::Frame; use ratatui::crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, }; use ratatui::crossterm::execute; use ratatui::layout::{Constraint, Layout, Spacing}; use ratatui::symbols::merge::MergeStrategy; use ratatui::widgets::Block; use render::BoardWidget; use std::io; use std::process::ExitCode; use std::time::{Duration, Instant}; use term::TerminalCaps; use crate::input::{current_input_mode, handle_board_input, handle_scroll_input, InputMode}; use crate::log::{log_preview_line, LogWidget}; use crate::scroll_overlay::ScrollOverlayWidget; use crate::speech::SpeechBubblesWidget; use crate::ui::Ui; /// 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 "); return ExitCode::from(2); }; // Load before touching the terminal so load errors print to a normal screen. let board = match world::load(&path) { Ok(mut w) => match w.boards.remove(&w.start) { Some(b) => b, None => { eprintln!("error: start board '{}' not found", w.start); return ExitCode::FAILURE; } }, 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(); // 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(); } // 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())); // Now that the board is fully loaded and the terminal is ready, run each // scripted object's `init()` hook. game.run_init(); let mut ui = Ui::default(); let result = run(&mut terminal, &mut game, &mut ui); if caps.keyboard_enhancement { let _ = term::pop_kitty_flags(); } let _ = execute!(io::stdout(), DisableMouseCapture); ratatui::restore(); if let Err(e) = result { eprintln!("error: {e}"); return ExitCode::FAILURE; } ExitCode::SUCCESS } /// 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, ui: &mut Ui, ) -> io::Result<()> { let mut last_tick = Instant::now(); loop { // 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))?; // 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)? { let event = event::read()?; // Ignore key-release events (the Kitty protocol emits these; acting on // them would fire a second move per keypress). if matches!(&event, Event::Key(k) if k.kind == KeyEventKind::Release) { continue; } let terminal_h = terminal.size()?.height; match current_input_mode(game, ui) { InputMode::Board(log_open) => { if handle_board_input(event, log_open, terminal_h, game, ui) { return Ok(()); } } InputMode::Scroll => handle_scroll_input(event, game, ui), } } // 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(); ui.tick(dt, game); } } } /// 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. /// A scroll overlay is drawn on top of everything when active. 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(); if ui.log.open { // Split the screen: board on top, log panel of `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); frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner); frame.render_stateful_widget(LogWidget::new(&game.log), log_area, &mut ui.log); } 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_line(&game.log).left_aligned()); let inner = block.inner(frame.area()); frame.render_widget(block, frame.area()); frame.render_widget(BoardWidget::new(board), inner); frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner); } // 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(); frame.render_stateful_widget(ScrollOverlayWidget::new(scroll), area, &mut ui.overlay); } }