diff --git a/kiln-tui/src/input.rs b/kiln-tui/src/input.rs new file mode 100644 index 0000000..8ce9851 --- /dev/null +++ b/kiln-tui/src/input.rs @@ -0,0 +1,79 @@ +use ratatui::crossterm::event::{Event, KeyCode, MouseEventKind}; +use kiln_core::Direction; +use kiln_core::game::{GameState, ScrollLine}; +use crate::resolve_choice; +use crate::scroll_overlay::ScrollAnimState; +use crate::ui::Ui; + +/// Which input mode the front-end is currently in. +pub enum InputMode { + /// Normal gameplay: arrow keys move the player, `l` toggles the log. + /// The `bool` is whether the log panel is open (affects PageUp/Down and mouse). + Board(bool), + /// A scroll overlay is active; all input is captured for scroll navigation. + Scroll, +} + +/// Determines the current input mode from game and UI state. +pub(crate) fn current_input_mode(game: &GameState, ui: &Ui) -> InputMode { + let scroll_active = game.active_scroll.is_some() + && !matches!(ui.overlay.anim, Some(ScrollAnimState::Closing(_) | ScrollAnimState::Done)); + if scroll_active { InputMode::Scroll } else { InputMode::Board(ui.log.open) } +} + +/// 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 { + KeyCode::Char('q') | KeyCode::Esc => return true, + KeyCode::Up => game.try_move(Direction::North), + KeyCode::Down => game.try_move(Direction::South), + KeyCode::Left => game.try_move(Direction::West), + KeyCode::Right => game.try_move(Direction::East), + KeyCode::Char('l') => ui.log.toggle(), + 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::Scroll`]. +pub(crate) fn handle_scroll_input(event: Event, game: &mut GameState, ui: &mut Ui) { + match event { + Event::Key(key) => { + 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 only when there are no choices; with choices the player must select. + 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)); + } + } + _ => {} + } + } + Event::Mouse(m) => match m.kind { + MouseEventKind::ScrollUp => ui.scroll_lines(-1), + MouseEventKind::ScrollDown => ui.scroll_lines(1), + _ => {} + }, + _ => {} + } +} diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index 5a7f326..06c1c88 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -15,6 +15,7 @@ mod term; mod utils; mod speech; mod ui; +mod input; use kiln_core::game::{GameState, ScrollLine}; use kiln_core::log::LogLine; @@ -33,6 +34,7 @@ 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::{ScrollAnimState, ScrollOverlayWidget}; use crate::speech::SpeechBubblesWidget; @@ -138,77 +140,20 @@ fn run( // 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()), - _ => {} - } + 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(()); } } - // 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); - } - _ => {} - } - } - } - _ => {} + InputMode::Scroll => handle_scroll_input(event, game, ui), } }