diff --git a/kiln-tui/src/editor.rs b/kiln-tui/src/editor.rs new file mode 100644 index 0000000..1948d6b --- /dev/null +++ b/kiln-tui/src/editor.rs @@ -0,0 +1,216 @@ +//! World editor mode: state, rendering, and the editor pause menu. +//! +//! The editor is a separate top-level [`Mode`](crate::mode::Mode): it holds its +//! own freshly reloaded [`World`] and never ticks the game. The board is shown +//! with a resizable right-hand sidebar (just a "coming soon" placeholder for now) +//! and a blinking cursor moved with the arrow keys or a right-click. + +use std::cell::Ref; +use kiln_core::Board; +use kiln_core::log::LogLine; +use kiln_core::world::World; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect, Spacing}; +use ratatui::style::{Color, Style}; +use ratatui::symbols::merge::MergeStrategy; +use ratatui::text::Line; +use ratatui::widgets::{Block, Paragraph}; +use crate::log::{log_preview_line, LogWidget}; +use crate::menu::{MenuItem, MenuKey}; +use crate::mode::PendingMode; +use crate::render::{board_to_screen, screen_to_board, BoardWidget}; +use crate::ui::Ui; + +/// How long each half of the cursor blink lasts, in seconds. +const BLINK_SECS: f32 = 0.5; +/// Default sidebar width in terminal columns (borders included). +const DEFAULT_SIDEBAR_WIDTH: u16 = 24; +/// Smallest width either the sidebar or the board may shrink to when resizing. +const MIN_PANEL_WIDTH: u16 = 8; +/// The cursor glyph: a solid full block (CP437 219) drawn in light gray. +const CURSOR_CHAR: char = '█'; + +/// All state for an active editing session. +/// +/// Built by reloading the world file, so the running game has no bearing on what +/// the editor shows. Holds no [`GameState`](kiln_core::game::GameState) — editing +/// never ticks the game. +pub(crate) struct EditorState { + /// The freshly reloaded world being edited. + world: World, + /// Key (in [`World::boards`]) of the board currently being edited. + board_name: String, + /// Cursor position in board cells, clamped to the board bounds. + cursor: (i32, i32), + /// Width of the right-hand sidebar in terminal columns (mouse-resizable). + sidebar_width: u16, + /// Whether the blinking cursor is currently in its visible phase. + blink_on: bool, + /// Time accumulated toward the next blink toggle. + blink_timer: f32, + /// Inner board rect from the last [`draw_editor`] call, used to convert a + /// right-click's screen position back to a board cell (written during render, + /// read during input — the same pattern as `ScrollOverlayState::max_scroll`). + last_board_area: Rect, + /// Editor-side log lines (the editor has no game log to draw from). + log: Vec, +} + +impl EditorState { + /// Builds an editor session for `world`, starting on its designated start board. + pub(crate) fn new(world: World) -> Self { + let board_name = world.start.clone(); + Self { + world, + board_name, + cursor: (0, 0), + sidebar_width: DEFAULT_SIDEBAR_WIDTH, + blink_on: true, + blink_timer: 0.0, + last_board_area: Rect::default(), + log: vec![LogLine::raw("editing — press [esc] for the menu")], + } + } + + /// Borrows the board currently being edited. + pub(crate) fn board(&self) -> Ref<'_, Board> { + self.world.boards[&self.board_name].borrow() + } + + /// Advances the cursor blink by `dt` seconds (the editor's only animation). + pub(crate) fn tick(&mut self, dt: f32) { + self.blink_timer += dt; + if self.blink_timer >= BLINK_SECS { + self.blink_timer -= BLINK_SECS; + self.blink_on = !self.blink_on; + } + } + + /// Moves the cursor by `(dx, dy)`, clamped to the board bounds. + pub(crate) fn move_cursor(&mut self, dx: i32, dy: i32) { + let (w, h) = { let b = self.board(); (b.width as i32, b.height as i32) }; + self.cursor.0 = (self.cursor.0 + dx).clamp(0, w - 1); + self.cursor.1 = (self.cursor.1 + dy).clamp(0, h - 1); + } + + /// Moves the cursor to the cell under a screen position, if it lands on the + /// board (right-click handling). No-op when the click is off the board. + pub(crate) fn set_cursor_at_screen(&mut self, sx: u16, sy: u16) { + let cell = screen_to_board(self.last_board_area, &self.board(), sx, sy); + if let Some((bx, by)) = cell { + self.cursor = (bx as i32, by as i32); + } + } + + /// Resizes the sidebar to `target` columns, clamped so neither the sidebar nor + /// the board can shrink below [`MIN_PANEL_WIDTH`] (mirrors [`LogState::resize`]). + pub(crate) fn resize_sidebar(&mut self, target: u16, total_w: u16) { + let max = total_w.saturating_sub(MIN_PANEL_WIDTH).max(MIN_PANEL_WIDTH); + self.sidebar_width = target.clamp(MIN_PANEL_WIDTH, max); + } +} + +/// Returns the items for the editor's pause menu (opened with `Esc` while editing). +/// +/// `[p]` reloads the world and returns to play; `[q]` quits; `Esc` resumes editing. +/// Like all menu actions these take only `&mut Ui` — the mode swap is deferred to +/// the run loop via [`Ui::pending_mode`](crate::ui::Ui::pending_mode). +pub(crate) fn editor_menu_items() -> Vec { + vec![ + MenuItem::new(MenuKey::Char('p'), "Play world", |ui| { + ui.pending_mode = Some(PendingMode::PlayWorld); + ui.begin_close_menu(); + }), + MenuItem::new(MenuKey::Char('q'), "Quit game", |ui| { + ui.should_quit = true; + ui.begin_close_menu(); + }), + MenuItem::new(MenuKey::Esc, "Resume editing", |ui| { + ui.begin_close_menu(); + }), + ] +} + +/// Renders the editor: the board (with a blinking cursor and coordinate readout), +/// the right-hand sidebar, and — when open — the bottom log panel spanning the +/// full width (board and sidebar sit side-by-side above it). +pub(crate) fn draw_editor(frame: &mut Frame, ed: &mut EditorState, ui: &mut Ui) { + let (board_area, sidebar_area, log_area) = editor_layout(frame.area(), ui, ed.sidebar_width); + + // ── Board, cursor, coordinate readout ──────────────────────────────────── + let inner = { + let board = ed.board(); + // Title carries the board name (top-left) and live cursor coords (top-right). + let mut block = Block::bordered() + .title(format!(" {} ", board.name)) + .title_top(Line::from(format!(" ({}, {}) ", ed.cursor.0, ed.cursor.1)).right_aligned()) + .merge_borders(MergeStrategy::Exact); + // When the log is closed, preview the latest line in the bottom border. + if log_area.is_none() { + block = block.title_bottom(log_preview_line(&ed.log).left_aligned()); + } + let inner = block.inner(board_area); + frame.render_widget(block, board_area); + frame.render_widget(BoardWidget::new(&board), inner); + + // Overlay the cursor during its visible phase (a light-gray solid block). + if ed.blink_on { + let (cx, cy) = (ed.cursor.0 as usize, ed.cursor.1 as usize); + if let Some((sx, sy)) = board_to_screen(inner, &board, cx, cy) + && let Some(cell) = frame.buffer_mut().cell_mut((sx, sy)) + { + cell.set_char(CURSOR_CHAR) + .set_fg(Color::Rgb(180, 180, 180)) + .set_bg(Color::Black); + } + } + inner + }; + // Record the board rect for right-click hit-testing (board borrow now dropped). + ed.last_board_area = inner; + + // ── Sidebar: a placeholder "coming soon" label centered in the panel ────── + let sb_block = Block::bordered().title(" Editor ").merge_borders(MergeStrategy::Exact); + let sb_inner = sb_block.inner(sidebar_area); + frame.render_widget(sb_block, sidebar_area); + // Center one line vertically, then horizontally via Paragraph::centered. + let [_, mid, _] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(1), + Constraint::Fill(1), + ]).areas(sb_inner); + frame.render_widget( + Paragraph::new("coming soon").style(Style::default().fg(Color::DarkGray)).centered(), + mid, + ); + + // ── Log panel (full width, below board + sidebar) ───────────────────────── + if let Some(log_area) = log_area { + frame.render_stateful_widget(LogWidget::new(&ed.log), log_area, &mut ui.log); + } +} + +/// Computes the board / sidebar / optional-log rects for the editor. +/// +/// When the log is open the screen splits vertically first (so the log spans the +/// full width), then the top region splits horizontally into board + sidebar. +/// When closed, only the horizontal board/sidebar split applies. `Overlap(1)` +/// makes adjacent panels share their dividing border. +fn editor_layout(area: Rect, ui: &Ui, sidebar_width: u16) -> (Rect, Rect, Option) { + let split_h = |top: Rect| { + Layout::horizontal([Constraint::Min(3), Constraint::Length(sidebar_width)]) + .spacing(Spacing::Overlap(1)) + .areas::<2>(top) + }; + if ui.log.open { + let [top, log_area] = + Layout::vertical([Constraint::Min(3), Constraint::Length(ui.log.height)]) + .spacing(Spacing::Overlap(1)) + .areas(area); + let [board_area, sidebar_area] = split_h(top); + (board_area, sidebar_area, Some(log_area)) + } else { + let [board_area, sidebar_area] = split_h(area); + (board_area, sidebar_area, None) + } +} diff --git a/kiln-tui/src/input.rs b/kiln-tui/src/input.rs index a9ddd2b..f995d8d 100644 --- a/kiln-tui/src/input.rs +++ b/kiln-tui/src/input.rs @@ -1,7 +1,9 @@ -use ratatui::crossterm::event::{Event, KeyCode, MouseEventKind}; +use ratatui::crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind}; use kiln_core::Direction; use kiln_core::game::{GameState, ScrollLine}; +use crate::editor::{editor_menu_items, EditorState}; use crate::menu::{MenuItem, MenuKey, pause_menu_items, ActionRc as MenuActionRc}; +use crate::mode::Mode; use crate::ui::Ui; /// Moves the player and, if the move crosses a portal, captures both board Rcs @@ -25,6 +27,8 @@ pub enum InputMode { Scroll, /// A menu overlay is active; all input is captured for menu navigation. Menu, + /// Editing a world: arrow keys move the cursor, the sidebar is shown. + Editor, /// An animation is running and has not claimed any input mode; all input is discarded. Ignore, /// Reserved for a post-transition freeze; nothing currently produces this mode. @@ -32,15 +36,20 @@ pub enum InputMode { Frozen, } -/// Determines the current input mode from game and UI state. -pub(crate) fn current_input_mode(game: &GameState, ui: &Ui) -> InputMode { +/// Determines the current input mode from the active [`Mode`] and UI state. +pub(crate) fn current_input_mode(mode: &Mode, ui: &Ui) -> InputMode { // An active animation declares its own input mode; pending transitions ignore input. if let Some(anim) = &ui.active_animation { return anim.input_mode_during(); } if ui.pending_transition.is_some() { return InputMode::Ignore; } if ui.menu.is_some() { return InputMode::Menu; } - if game.active_scroll.is_some() { InputMode::Scroll } else { InputMode::Board } + match mode { + Mode::Edit(_) => InputMode::Editor, + Mode::Play(game) => { + if game.active_scroll.is_some() { InputMode::Scroll } else { InputMode::Board } + } + } } /// Handles an input event in [`InputMode::Board`]. Returns `true` if the player quit. @@ -72,6 +81,39 @@ pub(crate) fn handle_board_input(event: Event, log_open: bool, terminal_h: u16, false } +/// Handles an input event in [`InputMode::Editor`]. +/// +/// Arrow keys move the cursor; `l` toggles the log; `Esc` opens the editor menu. +/// A right-click jumps the cursor to the clicked board cell, and dragging the +/// board/sidebar divider resizes the sidebar (the divider sits at `term_w - +/// sidebar_width`, mirroring the log's bottom divider). +pub(crate) fn handle_editor_input( + event: Event, + term_w: u16, + ed: &mut EditorState, + ui: &mut Ui, +) { + match event { + Event::Key(key) => match key.code { + KeyCode::Esc => ui.open_menu(editor_menu_items()), + KeyCode::Up => ed.move_cursor(0, -1), + KeyCode::Down => ed.move_cursor(0, 1), + KeyCode::Left => ed.move_cursor(-1, 0), + KeyCode::Right => ed.move_cursor(1, 0), + KeyCode::Char('l') => ui.log.toggle(), + _ => {} + }, + Event::Mouse(m) => match m.kind { + // Right-click moves the cursor to the clicked cell (if on the board). + MouseEventKind::Down(MouseButton::Right) => ed.set_cursor_at_screen(m.column, m.row), + // Dragging the divider resizes the sidebar. + MouseEventKind::Drag(_) => ed.resize_sidebar(term_w.saturating_sub(m.column), term_w), + _ => {} + }, + _ => {} + } +} + /// Returns the choice string for the given key character, scanning the scroll lines. fn resolve_choice(lines: &[ScrollLine], c: char) -> Option { let mut letter = b'a'; @@ -117,7 +159,7 @@ pub(crate) fn handle_scroll_input(event: Event, game: &mut GameState, ui: &mut U /// /// Uses the clone-then-dispatch pattern: the action `Rc` is cloned to release /// the immutable borrow on `ui.menu`, then the action is called with full `&mut Ui`. -pub(crate) fn handle_menu_input(event: Event, game: &mut GameState, ui: &mut Ui) { +pub(crate) fn handle_menu_input(event: Event, ui: &mut Ui) { match event { Event::Key(key) => { let pressed = match key.code { @@ -133,7 +175,7 @@ pub(crate) fn handle_menu_input(event: Event, game: &mut GameState, ui: &mut Ui) .and_then(|m| m.items.iter().find(|i| i.key == mk)) .map(|i| i.action_rc()); if let Some(action) = action { - MenuItem::call_action_rc(action, game, ui); + MenuItem::call_action_rc(action, ui); } } } diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index c8d7676..aa70406 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -9,8 +9,10 @@ mod animation; mod cp437; +mod editor; mod log; mod menu; +mod mode; mod overlay; mod render; mod scroll_overlay; @@ -38,9 +40,14 @@ use std::process::ExitCode; use std::time::{Duration, Instant}; use term::TerminalCaps; use crate::animation::{AnimationLayer, AnimWidget}; -use crate::input::{current_input_mode, handle_board_input, handle_menu_input, handle_scroll_input, InputMode}; +use crate::editor::{draw_editor, EditorState}; +use crate::input::{ + current_input_mode, handle_board_input, handle_editor_input, handle_menu_input, + handle_scroll_input, InputMode, +}; use crate::log::{log_preview_line, LogWidget}; use crate::menu::MenuWidget; +use crate::mode::{Mode, PendingMode}; use crate::scroll_overlay::ScrollOverlayWidget; use crate::speech::SpeechBubblesWidget; use crate::ui::Ui; @@ -85,8 +92,12 @@ fn main() -> ExitCode { // scripted object's `init()` hook. game.run_init(); - let mut ui = Ui::default(); - let result = run(&mut terminal, &mut game, &mut ui); + // 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(); @@ -112,14 +123,14 @@ const FRAME: Duration = Duration::from_nanos(1_000_000_000 / 30); /// arrives, so input stays responsive while the game keeps ticking on its own. fn run( terminal: &mut ratatui::DefaultTerminal, - game: &mut GameState, + 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, game, ui))?; + 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. @@ -131,27 +142,37 @@ fn run( if matches!(&event, Event::Key(k) if k.kind == KeyEventKind::Release) { continue; } - let terminal_h = terminal.size()?.height; - match current_input_mode(game, ui) { + let size = terminal.size()?; + match current_input_mode(mode, ui) { InputMode::Board | InputMode::Frozen => { - handle_board_input(event, ui.log.open, terminal_h, game, ui); + if let Mode::Play(game) = mode { + handle_board_input(event, ui.log.open, size.height, game, ui); + } + } + InputMode::Menu => handle_menu_input(event, ui), + InputMode::Scroll => { + if let Mode::Play(game) = mode { handle_scroll_input(event, game, ui); } + } + InputMode::Editor => { + if let Mode::Edit(ed) = mode { handle_editor_input(event, size.width, ed, ui); } } - InputMode::Menu => handle_menu_input(event, game, ui), - InputMode::Scroll => handle_scroll_input(event, game, ui), // Animation running and claiming no input mode — discard all events. InputMode::Ignore => {} } } - // 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`. + // 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, game); + ui.tick(dt, mode); } + // Apply any Play↔Edit switch a menu action requested this frame. + apply_pending_mode(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(()); @@ -159,11 +180,64 @@ fn run( } } -/// Render a single frame: the board in a bordered block, and — when open — a log +/// 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}"))); + } + } + } +} + +/// 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 if let Mode::Play(game) = mode { + // The scroll window only exists while playing. + if let Some(scroll) = &game.active_scroll { + frame.render_stateful_widget(ScrollOverlayWidget::new(&scroll.lines), area, &mut ui.overlay); + } + } +} + +/// 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. -/// Overlays (menu, scroll, animations) are drawn on top of everything. -fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) { +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(); @@ -189,20 +263,6 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) { frame.render_widget(block, frame.area()); draw_board_area(frame, inner, game, 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 if let Some(scroll) = &game.active_scroll { - frame.render_stateful_widget(ScrollOverlayWidget::new(&scroll.lines), area, &mut ui.overlay); - } } /// Renders the inner board area: either the active board-layer animation or the live board. diff --git a/kiln-tui/src/menu.rs b/kiln-tui/src/menu.rs index a3cae48..f32c991 100644 --- a/kiln-tui/src/menu.rs +++ b/kiln-tui/src/menu.rs @@ -7,7 +7,6 @@ //! the action reference before releasing the borrow on [`Ui`](crate::ui::Ui). use std::rc::Rc; -use kiln_core::game::GameState; use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Style}; @@ -17,11 +16,17 @@ use ratatui::widgets::{ }; use crate::animation::{Animation, AnimationLayer}; use crate::input::InputMode; +use crate::mode::PendingMode; use crate::overlay::{OverlayStyle, render_overlay_frame}; use crate::ui::Ui; /// Shared-ownership reference to a menu item action closure. -pub(crate) type ActionRc = Rc; +/// +/// Actions receive only `&mut Ui`: they signal intent (close the menu, quit, or +/// request a [`PendingMode`] switch) and the run loop applies the effect. None of +/// them need the game state, which is why entering/leaving the editor — a full +/// mode swap — fits the same closure shape. +pub(crate) type ActionRc = Rc; /// Duration of the menu open/close animations, in seconds. const ANIM_SECS: f32 = 0.25; @@ -63,7 +68,7 @@ impl MenuItem { pub fn new( key: MenuKey, label: impl Into, - action: impl Fn(&mut GameState, &mut Ui) + 'static, + action: impl Fn(&mut Ui) + 'static, ) -> Self { Self { key, label: label.into(), action: Rc::new(action) } } @@ -76,8 +81,8 @@ impl MenuItem { /// Invokes an already-cloned action `Rc`. The caller must have released any /// borrow of [`Ui`] before calling this. - pub fn call_action_rc(action: ActionRc, game: &mut GameState, ui: &mut Ui) { - action(game, ui); + pub fn call_action_rc(action: ActionRc, ui: &mut Ui) { + action(ui); } } @@ -242,17 +247,22 @@ fn render_menu_at( if let Some(m) = out_max_scroll { *m = max_scroll; } } -/// Returns the items for the standard pause menu. +/// Returns the items for the standard (play-mode) pause menu. /// -/// Pressing `q` marks [`Ui::should_quit`] and starts the close animation. -/// Pressing `Esc` closes the menu without quitting. +/// Pressing `e` requests entering the editor; `q` marks [`Ui::should_quit`]; +/// `Esc` closes the menu. The editor switch is deferred to the run loop via +/// [`Ui::pending_mode`], so the action only touches [`Ui`]. pub fn pause_menu_items() -> Vec { vec![ - MenuItem::new(MenuKey::Char('q'), "Quit game", |_g, ui| { + MenuItem::new(MenuKey::Char('e'), "Edit world", |ui| { + ui.pending_mode = Some(PendingMode::EnterEditor); + ui.begin_close_menu(); + }), + MenuItem::new(MenuKey::Char('q'), "Quit game", |ui| { ui.should_quit = true; ui.begin_close_menu(); }), - MenuItem::new(MenuKey::Esc, "Close menu", |_g, ui| { + MenuItem::new(MenuKey::Esc, "Close menu", |ui| { ui.begin_close_menu(); }), ] diff --git a/kiln-tui/src/mode.rs b/kiln-tui/src/mode.rs new file mode 100644 index 0000000..09b0a4c --- /dev/null +++ b/kiln-tui/src/mode.rs @@ -0,0 +1,37 @@ +//! The application's top-level mode: either playing a world or editing one. +//! +//! Play and Edit are mutually exclusive — exactly one of [`GameState`] or +//! [`EditorState`] exists at a time — so they are modeled as an enum rather than +//! two `Option`s. This matches the design rule that there is *no* game state +//! while editing: entering the editor drops the running game, and returning to +//! play rebuilds it fresh from the world file. + +use kiln_core::game::GameState; +use crate::editor::EditorState; + +/// The top-level application state: exactly one variant is live at any moment. +/// +/// `Play` is much larger than `Edit`, but only a single `Mode` value ever exists +/// (it lives on the stack in `run`), so the size disparity costs nothing and +/// boxing would only add a deref to the common play path. +#[allow(clippy::large_enum_variant)] +pub(crate) enum Mode { + /// Playing a world: the live, ticking game. + Play(GameState), + /// Editing a world: a freshly reloaded, non-ticking copy of the world. + Edit(EditorState), +} + +/// A deferred Play↔Edit switch requested by a menu action. +/// +/// Menu closures only receive `&mut Ui`, so they cannot reach `world_path` or +/// run [`world::load`](kiln_core::world::load). Instead they set +/// [`Ui::pending_mode`](crate::ui::Ui::pending_mode) and the run loop performs +/// the actual reload-and-swap (mirroring the [`should_quit`](crate::ui::Ui::should_quit) +/// pattern). +pub(crate) enum PendingMode { + /// Leave play, reload the world, and enter the editor. + EnterEditor, + /// Leave the editor, reload the world, and start playing it fresh. + PlayWorld, +} diff --git a/kiln-tui/src/render.rs b/kiln-tui/src/render.rs index 52f2330..71dfc75 100644 --- a/kiln-tui/src/render.rs +++ b/kiln-tui/src/render.rs @@ -50,6 +50,68 @@ impl<'a> BoardWidget<'a> { } } +/// Maps a board cell `(bx, by)` to its screen position within `area`, using the +/// same centering/scrolling layout as [`BoardWidget`]. Returns `None` when the +/// cell is scrolled out of view. +/// +/// The inverse of [`screen_to_board`]; both reuse [`BoardWidget::axis`] so they +/// stay consistent with how the board is actually drawn. (Unlike +/// `speech::board_screen_pos`, which clamps off-screen cells to the edge, this +/// reports off-screen cells as `None` — needed for exact cursor placement.) +pub fn board_to_screen(area: Rect, board: &Board, bx: usize, by: usize) -> Option<(u16, u16)> { + let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x); + let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, board.player.y); + // Cell must fall within the visible window on both axes. + if bx < off_x || bx >= off_x + cols || by < off_y || by >= off_y + rows { + return None; + } + let sx = area.x + pad_x + (bx - off_x) as u16; + let sy = area.y + pad_y + (by - off_y) as u16; + Some((sx, sy)) +} + +/// Maps a screen position `(sx, sy)` to the board cell drawn there within `area`, +/// the inverse of [`board_to_screen`]. Returns `None` when the point lies outside +/// the drawn board (in the centering pad or past the visible cells). +pub fn screen_to_board(area: Rect, board: &Board, sx: u16, sy: u16) -> Option<(usize, usize)> { + let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x); + let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, board.player.y); + // Screen-space offset from the first drawn cell; reject anything before it. + let col = (sx as i32) - (area.x as i32 + pad_x as i32); + let row = (sy as i32) - (area.y as i32 + pad_y as i32); + if col < 0 || row < 0 || col as usize >= cols || row as usize >= rows { + return None; + } + Some((off_x + col as usize, off_y + row as usize)) +} + +#[cfg(test)] +mod tests { + use super::BoardWidget; + + #[test] + fn axis_centers_a_small_board() { + // 10-cell board in a 20-cell view: no scroll, 5 cells of pad each side. + let (off, pad, count) = BoardWidget::axis(10, 20, 3); + assert_eq!((off, pad, count), (0, 5, 10)); + } + + #[test] + fn axis_scrolls_a_large_board_to_follow_the_player() { + // 100-cell board in a 20-cell view, player at 50: centered, no pad. + let (off, pad, count) = BoardWidget::axis(100, 20, 50); + assert_eq!((off, pad, count), (40, 0, 20)); + } + + #[test] + fn axis_clamps_scroll_at_the_edges() { + // Player near the left edge can't scroll past 0. + assert_eq!(BoardWidget::axis(100, 20, 2), (0, 0, 20)); + // Player near the right edge clamps to board_len - view. + assert_eq!(BoardWidget::axis(100, 20, 99), (80, 0, 20)); + } +} + impl Widget for BoardWidget<'_> { fn render(self, area: Rect, buf: &mut Buffer) { let board = self.board; diff --git a/kiln-tui/src/ui.rs b/kiln-tui/src/ui.rs index 1677da1..265f8c7 100644 --- a/kiln-tui/src/ui.rs +++ b/kiln-tui/src/ui.rs @@ -6,6 +6,7 @@ use kiln_core::game::GameState; use crate::animation::Animation; use crate::log::LogState; use crate::menu::{MenuItem, MenuCloseAnimation, MenuOpenAnimation, MenuState}; +use crate::mode::{Mode, PendingMode}; use crate::scroll_overlay::{ScrollCloseAnimation, ScrollOpenAnimation, ScrollOverlayState}; use crate::transition::TransitionAnimation; @@ -29,6 +30,11 @@ pub(crate) struct Ui { /// Set by a menu action to signal that the game loop should exit after the /// close animation finishes. pub(crate) should_quit: bool, + /// Path to the world file, kept so the editor / play-reload can reload it. + pub(crate) world_path: String, + /// Set by a menu action to request a Play↔Edit switch; applied by the run loop + /// (which owns [`world_path`](Ui::world_path) and can run the world reload). + pub(crate) pending_mode: Option, } impl Default for Ui { @@ -40,6 +46,8 @@ impl Default for Ui { active_animation: None, menu: None, should_quit: false, + world_path: String::new(), + pending_mode: None, } } } @@ -72,35 +80,44 @@ impl Ui { self.active_animation = Some(Box::new(MenuCloseAnimation::new(items))); } - /// Advances all time-driven UI state by `dt` and drives the game tick. + /// Advances all time-driven UI state by `dt` and drives the active mode. /// - /// While any animation is running, `game.tick` is suppressed. When an + /// While any animation is running, the mode's own tick is suppressed. When an /// animation finishes, [`input_mode_after`](Animation::input_mode_after) is /// consulted: returning to [`Board`](crate::input::InputMode::Board) mode - /// eagerly calls [`handle_scroll`](GameState::handle_scroll) to clear any - /// pending scroll, and clears [`menu`](Ui::menu) so the closed overlay is not - /// rendered on the next frame. - pub(crate) fn tick(&mut self, dt: Duration, game: &mut GameState) { + /// eagerly calls [`handle_scroll`](GameState::handle_scroll) on the play game to + /// clear any pending scroll, and clears [`menu`](Ui::menu) so the closed overlay + /// is not rendered for an extra frame. + /// + /// In [`Mode::Edit`] only the editor's cursor blink advances — the game never + /// ticks while editing. + pub(crate) fn tick(&mut self, dt: Duration, mode: &mut Mode) { // Advance the active animation; on completion, dispatch any mode-change effect. if let Some(ref mut anim) = self.active_animation { if anim.update(dt.as_secs_f32()) { - let mode = anim.input_mode_after(); + let after = anim.input_mode_after(); self.active_animation = None; // Returning to Board mode: eagerly clear scroll and menu state so - // neither renders as open for one extra frame before the game tick. - if matches!(mode, crate::input::InputMode::Board) { - game.handle_scroll(); + // neither renders as open for one extra frame before the next tick. + if matches!(after, crate::input::InputMode::Board) { + if let Mode::Play(game) = mode { game.handle_scroll(); } self.menu = None; } } - return; // no game tick during any animation (even when just ended) + return; // no mode tick during any animation (even when just ended) } - // Normal path: tick the game, then check if a scroll appeared. - game.tick(dt); - if let Some(scroll) = &game.active_scroll { - let lines = scroll.lines.clone(); - self.active_animation = Some(Box::new(ScrollOpenAnimation::new(lines))); + match mode { + // Play: tick the game, then check whether a scroll overlay appeared. + Mode::Play(game) => { + game.tick(dt); + if let Some(scroll) = &game.active_scroll { + let lines = scroll.lines.clone(); + self.active_animation = Some(Box::new(ScrollOpenAnimation::new(lines))); + } + } + // Edit: only the cursor blink advances; the game does not exist here. + Mode::Edit(editor) => editor.tick(dt.as_secs_f32()), } }