//! `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; use kiln_core::game::{GameState, ScrollLine}; use kiln_core::log::LogLine; use kiln_core::map_file; use kiln_core::Direction; use ratatui::Frame; use ratatui::crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind, }; 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::log::{log_preview_line, LogWidget}; use crate::scroll_overlay::{ScrollAnimState, ScrollOverlayWidget}; use crate::speech::SpeechBubblesWidget; use crate::ui::Ui; /// 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 { 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; } } None } /// 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 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(); // 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)? { match 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. 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() && !matches!(ui.overlay.anim, Some(ScrollAnimState::Closing(_) | ScrollAnimState::Done)); 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 => ui.begin_close(None), KeyCode::Up => ui.scroll_lines(-1), KeyCode::Down => ui.scroll_lines(1), KeyCode::Char(c) => { if let Some(ch) = resolve_choice(&scroll.lines, c) { ui.begin_close(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.toggle(), // PageUp/PageDown scroll the log when it's open. KeyCode::PageUp if ui.log.open => ui.log.scroll_by(-5, game.log.len()), KeyCode::PageDown if ui.log.open => ui.log.scroll_by(5, game.log.len()), _ => {} } } } // 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() && !matches!(ui.overlay.anim, Some(ScrollAnimState::Closing(_) | ScrollAnimState::Done)); if scroll_active { match m.kind { MouseEventKind::ScrollUp => ui.scroll_lines(-1), MouseEventKind::ScrollDown => ui.scroll_lines(1), _ => {} } } else if ui.log.open { match m.kind { MouseEventKind::ScrollUp => ui.log.scroll_by(-1, game.log.len()), MouseEventKind::ScrollDown => ui.log.scroll_by(1, game.log.len()), MouseEventKind::Drag(_) => { // The divider sits at row `total - 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.resize(target, total); } _ => {} } } } _ => {} } } // 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(); let board = &*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); } }