use crate::editor::EditorState; use crate::menu::{ActionRc as MenuActionRc, MenuItem, MenuKey, pause_menu_items}; use crate::mode::Mode; use crate::ui::Ui; use kiln_core::Direction; use kiln_core::game::{GameState, ScrollLine}; use ratatui::crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind}; /// Moves the player and, if the move crosses a portal, captures both board Rcs /// and stores them in [`Ui::pending_transition`] for the next draw call. fn try_move_with_transition(game: &mut GameState, ui: &mut Ui, dir: Direction) { let old_rc = game.board_rc(); let old_name = game.current_board_name().to_string(); game.try_move(dir); if game.current_board_name() != old_name { // Portal crossed: queue transition; consume the engine-side signal. ui.pending_transition = Some((old_rc, game.board_rc())); game.board_transition = None; } } /// Which input mode the front-end is currently in. pub enum InputMode { /// Normal gameplay: arrow keys move the player, `l` toggles the log. Board, /// A scroll overlay is active; all input is captured for scroll navigation. 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, /// A dialog overlay is open over the editor; all input drives the dialog. Dialog, /// The script code editor is open; all input drives the editor. CodeEditor, /// The glyph picker is open over the editor; all input drives the picker. GlyphDialog, /// 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. #[allow(dead_code)] Frozen, } /// 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; } match mode { Mode::Edit(ed) => { if ed.glyph_dialog.is_some() { InputMode::GlyphDialog } else if ed.dialog.is_some() { InputMode::Dialog } else if ed.code_editor.is_some() { InputMode::CodeEditor } else { 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. pub(crate) fn handle_board_input( event: Event, log_open: bool, terminal_h: u16, game: &mut GameState, ui: &mut Ui, ) -> bool { match event { Event::Key(key) => match key.code { // During a playtest, Esc returns to the editor instead of opening the menu. KeyCode::Esc if ui.suspended_editor.is_some() => ui.exit_playtest = true, KeyCode::Esc => ui.open_menu(pause_menu_items()), KeyCode::Up => try_move_with_transition(game, ui, Direction::North), KeyCode::Down => try_move_with_transition(game, ui, Direction::South), KeyCode::Left => try_move_with_transition(game, ui, Direction::West), KeyCode::Right => try_move_with_transition(game, ui, Direction::East), KeyCode::Char('l') => ui.log.toggle(), // Toggle the status sidebar (play-only: this handler is InputMode::Board). KeyCode::Tab => ui.show_status = !ui.show_status, KeyCode::PageUp if log_open => ui.log.scroll_by(-5, game.log.len()), KeyCode::PageDown if log_open => ui.log.scroll_by(5, game.log.len()), _ => {} }, Event::Mouse(m) if 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 up grows the panel. let target = terminal_h.saturating_sub(m.row); ui.log.resize(target, terminal_h); } _ => {} }, _ => {} } 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 => ed.menu_escape(ui), 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(), // `g` opens the glyph picker to edit the current drawing glyph. KeyCode::Char('G') => ed.open_glyph_picker(), // Space stamps the current thing; Tab toggles draw mode. KeyCode::Char(' ') => ed.place_current(), KeyCode::Tab => ed.toggle_draw_mode(), // A menu letter opens its dialog; unmatched letters are ignored. KeyCode::Char(c) => { ed.menu_key(c); } _ => {} }, Event::Mouse(m) => match m.kind { // Right-click moves the cursor to the clicked cell (if on the board). MouseEventKind::Down(MouseButton::Left) => 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'; for line in lines { if let ScrollLine::Choice { choice, .. } = line { if c == letter as char { return Some(choice.clone()); } letter += 1; } } None } /// Handles an input event in [`InputMode::Scroll`]. pub(crate) fn handle_scroll_input(event: Event, game: &mut GameState, ui: &mut Ui) { let lines: Vec = game .active_scroll .as_ref() .map(|s| s.lines.clone()) .unwrap_or_default(); let has_choices = lines.iter().any(|l| matches!(l, ScrollLine::Choice { .. })); match event { Event::Key(key) => match key.code { // Esc dismisses only when there are no choices. KeyCode::Esc if !has_choices => ui.begin_close(None, game), KeyCode::Up => ui.scroll_lines(-1), KeyCode::Down => ui.scroll_lines(1), KeyCode::Char(c) => { if let Some(ch) = resolve_choice(&lines, c) { ui.begin_close(Some(ch), game); } } _ => {} }, Event::Mouse(m) => match m.kind { MouseEventKind::ScrollUp => ui.scroll_lines(-1), MouseEventKind::ScrollDown => ui.scroll_lines(1), _ => {} }, _ => {} } } /// Handles an input event in [`InputMode::Menu`]. /// /// 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, ui: &mut Ui) { match event { Event::Key(key) => { let pressed = match key.code { KeyCode::Esc => Some(MenuKey::Esc), KeyCode::Char(c) => Some(MenuKey::Char(c)), KeyCode::Up => { ui.scroll_menu(-1); None } KeyCode::Down => { ui.scroll_menu(1); None } _ => None, }; if let Some(mk) = pressed { // Clone the Rc to release the borrow on ui.menu before calling the action. let action: Option = ui .menu .as_ref() .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, ui); } } } Event::Mouse(m) => match m.kind { MouseEventKind::ScrollUp => ui.scroll_menu(-1), MouseEventKind::ScrollDown => ui.scroll_menu(1), _ => {} }, _ => {} } }