//! `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 [`kiln_core::cp437`]) and drawn with each cell's RGB colors. mod animation; mod editor; mod input; mod log; mod menu; mod mode; mod overlay; mod render; mod scroll_overlay; mod speech; mod term; mod transition; mod ui; mod utils; mod editor_menu; use crate::animation::{AnimWidget, AnimationLayer}; use crate::editor::{ EditorState, draw_editor, handle_code_editor_input, handle_dialog_input, handle_glyph_dialog_input, }; use crate::input::{ InputMode, current_input_mode, handle_board_input, handle_editor_input, handle_menu_input, handle_scroll_input, }; use crate::log::{LogWidget, log_preview_line}; use crate::menu::MenuWidget; use crate::mode::{Mode, PendingMode}; use crate::scroll_overlay::ScrollOverlayWidget; use crate::speech::SpeechBubblesWidget; use crate::ui::Ui; use kiln_core::game::GameState; use kiln_core::log::LogLine; use kiln_core::world; use kiln_ui::render_overlay; use ratatui::Frame; use ratatui::crossterm::event::{ self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, Event, KeyEventKind, }; use ratatui::crossterm::execute; use ratatui::layout::{Constraint, Layout, Rect, 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; /// 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 mut game = match world::load(&path) { Ok(world) => GameState::from_world(world), Err(e) => { eprintln!("error loading {path}: {e}"); return ExitCode::FAILURE; } }; // `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); // Enable bracketed paste so the code editor receives terminal pastes as one event. let _ = execute!(io::stdout(), EnableBracketedPaste); // 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(); } game.log(LogLine::raw("[esc] for menu")); 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(); // Wrap the live game in the top-level Mode; editing later swaps this whole value. let mut mode = Mode::Play(game); // Remember the world path so the editor (and play-reload) can reload from it. let mut ui = Ui { world_path: path.clone(), ..Ui::default() }; let result = run(&mut terminal, &mut mode, &mut ui); if caps.keyboard_enhancement { let _ = term::pop_kitty_flags(); } let _ = execute!(io::stdout(), DisableBracketedPaste); 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, mode: &mut Mode, 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, mode, 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 size = terminal.size()?; // Each Mode only produces a subset of input modes (Editor only in Edit; // Board/Scroll/Frozen only in Play; Menu/Ignore in either). Match on Mode // first so each handler gets the right `game`/`ed` without re-checking, // and assert the impossible combinations away with `unreachable!`. let input_mode = current_input_mode(mode, ui); if matches!(input_mode, InputMode::Menu) { handle_menu_input(event, ui) } else if !matches!(input_mode, InputMode::Ignore) { // If input_mode is ignore, an animation is running and claiming no input mode — discard all events. match mode { Mode::Play(game) => match input_mode { InputMode::Board | InputMode::Frozen => { handle_board_input(event, ui.log.open, size.height, game, ui); } InputMode::Scroll => handle_scroll_input(event, game, ui), _ => unreachable!( "Menu and Ignore handled above; Editor input mode only occurs in Edit mode" ), }, Mode::Edit(ed) => match input_mode { InputMode::Editor => handle_editor_input(event, size.width, ed, ui), InputMode::Dialog => handle_dialog_input(event, ed), InputMode::GlyphDialog => handle_glyph_dialog_input(event, ed), InputMode::CodeEditor => handle_code_editor_input(event, ed), _ => unreachable!( "Menu and Ignore handled above; Board/Scroll/Frozen input modes only occur in Play mode" ), }, } } } // Advance the active mode 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, mode); } // Apply any Play↔Edit switch a menu action requested this frame. apply_pending_mode(mode, ui); // Enter/leave a playtest if the editor or a playtest Esc requested it. apply_playtest_transition(mode, ui); // A menu action may have set should_quit; exit once any close animation finishes. if ui.should_quit && ui.active_animation.is_none() { return Ok(()); } } } /// Applies a pending [`Mode`] switch, reloading the world from `ui.world_path`. /// /// Entering the editor drops the running game; returning to play rebuilds a fresh /// [`GameState`] (so it reflects any edits) and re-runs object `init()` hooks. A /// load failure is logged to the play game (if any) and leaves the mode unchanged. fn apply_pending_mode(mode: &mut Mode, ui: &mut Ui) { let Some(pending) = ui.pending_mode.take() else { return; }; match world::load(&ui.world_path) { Ok(world) => match pending { PendingMode::EnterEditor => *mode = Mode::Edit(EditorState::new(world)), PendingMode::PlayWorld => { let mut game = GameState::from_world(world); game.run_init(); *mode = Mode::Play(game); } }, Err(e) => { // Best-effort: surface the failure in the play log if we're still playing. if let Mode::Play(game) = mode { game.log(LogLine::error(format!("reload failed: {e}"))); } } } } /// Applies a pending **playtest** enter or exit, swapping the top-level [`Mode`]. /// /// Entering: the editor parked a built [`GameState`] in /// [`EditorState::pending_playtest`]; swap to [`Mode::Play`] and stash the editor in /// [`Ui::suspended_editor`] so `Esc` can restore it. Taking the option first drops the /// `&mut EditorState` borrow before [`std::mem::replace`], whose return value hands us /// the old `Mode::Edit` (so the editor is preserved with no placeholder variant). /// /// Exiting (`Esc` during a playtest set [`Ui::exit_playtest`]): drop the playtest game /// and restore the suspended editor verbatim. The deep-copied playtest world is simply /// dropped, so the editor's own boards were never touched. fn apply_playtest_transition(mode: &mut Mode, ui: &mut Ui) { // Enter: take the parked game (this ends the editor borrow before the swap). let new_game = if let Mode::Edit(ed) = mode { ed.pending_playtest.take() } else { None }; if let Some(game) = new_game { let old = std::mem::replace(mode, Mode::Play(game)); if let Mode::Edit(editor) = old { ui.suspended_editor = Some(editor); } } // Exit: restore the parked editor, dropping the playtest game. if ui.exit_playtest { ui.exit_playtest = false; if let Some(editor) = ui.suspended_editor.take() { *mode = Mode::Edit(editor); } } } /// Render a single frame for the active [`Mode`], then the overlay layer on top. /// /// The body of the frame is either the play view ([`draw_play`]) or the editor /// view ([`draw_editor`]); overlays (animation, menu, and — play-only — the scroll /// window) are drawn above whichever was rendered. fn draw(frame: &mut Frame, mode: &mut Mode, ui: &mut Ui) { match mode { Mode::Play(game) => draw_play(frame, game, ui), Mode::Edit(ed) => draw_editor(frame, ed, ui), } // Overlay layer: animation > menu > scroll (mutually exclusive at runtime). let is_overlay_anim = ui .active_animation .as_ref() .map(|a| matches!(a.layer(), AnimationLayer::Overlay)) .unwrap_or(false); let area = frame.area(); if is_overlay_anim { frame.render_widget( AnimWidget(ui.active_animation.as_ref().unwrap().as_ref()), area, ); } else if let Some(menu) = ui.menu.as_mut() { frame.render_stateful_widget(MenuWidget, area, menu); } else { match mode { // The scroll window only exists while playing. Mode::Play(game) => { if let Some(scroll) = &game.active_scroll { frame.render_stateful_widget( ScrollOverlayWidget::new(&scroll.lines), area, &mut ui.overlay, ); } } // The dialog / glyph-picker overlays only exist while editing. Mode::Edit(ed) => { if let Some(g) = &ed.glyph_dialog { render_overlay(frame, g); } else if let Some(d) = &ed.dialog { render_overlay(frame, d); } } } } } /// Renders the play view: 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_play(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(); // A playtest (launched from the editor) marks its title so it's distinct from play. let title = if ui.suspended_editor.is_some() { format!(" {} (playtest) ", board.name) } else { format!(" {} ", board.name) }; 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(title) .merge_borders(MergeStrategy::Exact); let inner = block.inner(board_area); frame.render_widget(block, board_area); draw_board_area(frame, inner, game, ui); 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(title); block = block.title_bottom(log_preview_line(&game.log).left_aligned()); let inner = block.inner(frame.area()); frame.render_widget(block, frame.area()); draw_board_area(frame, inner, game, ui); } } /// Renders the inner board area: either the active board-layer animation or the live board. /// /// If `ui.pending_transition` is set, pre-renders both boards into a new /// [`TransitionAnimation`](transition::TransitionAnimation) (now that we know the /// exact `inner` area) and activates it via [`Ui::start_transition`]. fn draw_board_area(frame: &mut Frame, inner: Rect, game: &GameState, ui: &mut Ui) { // Initialize any pending transition now that we know the exact area. if let Some((old_rc, new_rc)) = ui.pending_transition.take() { ui.start_transition(&old_rc.borrow(), &new_rc.borrow(), inner); } let is_board_anim = ui .active_animation .as_ref() .map(|a| matches!(a.layer(), AnimationLayer::Board)) .unwrap_or(false); if is_board_anim { // Board-layer animation (wipe): replaces the board entirely. frame.render_widget( AnimWidget(ui.active_animation.as_ref().unwrap().as_ref()), inner, ); } else { let board = &game.board(); frame.render_widget(BoardWidget::new(board), inner); frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner); } }