diff --git a/kiln-tui/src/input.rs b/kiln-tui/src/input.rs index 353e377..d3af627 100644 --- a/kiln-tui/src/input.rs +++ b/kiln-tui/src/input.rs @@ -1,6 +1,7 @@ use ratatui::crossterm::event::{Event, KeyCode, MouseEventKind}; use kiln_core::Direction; use kiln_core::game::{GameState, ScrollLine}; +use crate::menu::{MenuItem, MenuKey, pause_menu_items, ActionRc as MenuActionRc}; use crate::ui::Ui; /// Moves the player and, if the move crosses a portal, captures both board Rcs @@ -22,6 +23,8 @@ pub enum InputMode { 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, /// An animation is running; all input is discarded. Animating, /// Reserved for a post-transition freeze; nothing currently produces this mode. @@ -35,6 +38,7 @@ pub(crate) fn current_input_mode(game: &GameState, ui: &Ui) -> InputMode { if ui.active_animation.is_some() || ui.pending_transition.is_some() { return InputMode::Animating; } + if ui.menu.is_some() { return InputMode::Menu; } if game.active_scroll.is_some() { InputMode::Scroll } else { InputMode::Board } } @@ -42,7 +46,7 @@ pub(crate) fn current_input_mode(game: &GameState, ui: &Ui) -> InputMode { 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::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), @@ -107,3 +111,36 @@ pub(crate) fn handle_scroll_input(event: Event, game: &mut GameState, ui: &mut U _ => {} } } + +/// 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, game: &mut GameState, 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, game, ui); + } + } + } + Event::Mouse(m) => match m.kind { + MouseEventKind::ScrollUp => ui.scroll_menu(-1), + MouseEventKind::ScrollDown => ui.scroll_menu(1), + _ => {} + }, + _ => {} + } +} diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index c24ddb4..c5a63d7 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -10,6 +10,8 @@ mod animation; mod cp437; mod log; +mod menu; +mod overlay; mod render; mod scroll_overlay; mod term; @@ -36,8 +38,9 @@ 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_scroll_input, InputMode}; +use crate::input::{current_input_mode, handle_board_input, handle_menu_input, handle_scroll_input, InputMode}; use crate::log::{log_preview_line, LogWidget}; +use crate::menu::MenuWidget; use crate::scroll_overlay::ScrollOverlayWidget; use crate::speech::SpeechBubblesWidget; use crate::ui::Ui; @@ -75,10 +78,7 @@ fn main() -> ExitCode { 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("[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 @@ -134,10 +134,9 @@ fn run( let terminal_h = terminal.size()?.height; match current_input_mode(game, ui) { InputMode::Board | InputMode::Frozen => { - if handle_board_input(event, ui.log.open, terminal_h, game, ui) { - return Ok(()); - } + handle_board_input(event, ui.log.open, terminal_h, game, ui); } + InputMode::Menu => handle_menu_input(event, game, ui), InputMode::Scroll => handle_scroll_input(event, game, ui), // Animation running — discard all events. InputMode::Animating => {} @@ -152,13 +151,18 @@ fn run( last_tick = Instant::now(); ui.tick(dt, game); } + + // 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(()); + } } } /// 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 or overlay animation is drawn on top of everything when active. +/// Overlays (menu, scroll, animations) are drawn on top of everything. 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(); @@ -186,18 +190,17 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) { draw_board_area(frame, inner, game, ui); } - // Draw overlay animations (scroll open/close) or the fully-open scroll overlay. - // Suppressed during a board-layer animation (transition wipe); enter_board() - // already cleared active_scroll so there's nothing to show anyway. + // 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 { - let area = frame.area(); 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 { - let area = frame.area(); frame.render_stateful_widget(ScrollOverlayWidget::new(&scroll.lines), area, &mut ui.overlay); } } diff --git a/kiln-tui/src/menu.rs b/kiln-tui/src/menu.rs new file mode 100644 index 0000000..2eca7e9 --- /dev/null +++ b/kiln-tui/src/menu.rs @@ -0,0 +1,256 @@ +//! In-game menu overlay: [`MenuItem`], [`MenuState`], animations, and the pause menu definition. +//! +//! Menus share the same [`Animation`](crate::animation::Animation) pipeline and overlay-frame +//! rendering as scroll overlays, but use a blue color scheme and a shorter fixed height. +//! Item actions are stored as `Rc` so they can be cheaply cloned without duplicating +//! the closure body — this lets [`handle_menu_input`](crate::input::handle_menu_input) clone +//! 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}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{ + Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, +}; +use crate::animation::{Animation, AnimationLayer}; +use crate::input::InputMode; +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; + +/// Duration of the menu open/close animations, in seconds. +const ANIM_SECS: f32 = 0.25; + +/// Visual style for menus: cool blue on dark navy. +const MENU_STYLE: OverlayStyle = OverlayStyle { + bg: Color::Rgb(10, 15, 35), + border_fg: Color::Rgb(80, 130, 210), + width_div: 2, + height_fraction: 1.0 / 3.0, +}; + +/// A key binding that activates a [`MenuItem`]. +#[derive(Clone, PartialEq, Debug)] +pub enum MenuKey { + /// A single character key. + Char(char), + /// The Escape key. + Esc, +} + +/// A single entry in a menu overlay. +/// +/// The `action` closure is stored behind `Rc` so cloning a `MenuItem` shares the +/// closure rather than copying it, keeping [`MenuState`] cheap to snapshot for +/// the close animation. +pub struct MenuItem { + /// The key the player presses to activate this item. + pub key: MenuKey, + /// Display label shown in the menu. + pub label: String, + /// Called when the player presses `key`. Typically calls + /// [`Ui::begin_close_menu`] and/or sets [`Ui::should_quit`]. + action: ActionRc, +} + +impl MenuItem { + /// Creates a menu item with the given key, label, and action closure. + pub fn new( + key: MenuKey, + label: impl Into, + action: impl Fn(&mut GameState, &mut Ui) + 'static, + ) -> Self { + Self { key, label: label.into(), action: Rc::new(action) } + } + + /// Returns a cloned `Rc` to the action closure so the caller can release any + /// immutable borrow on the item collection before invoking the action. + pub fn action_rc(&self) -> ActionRc { + Rc::clone(&self.action) + } + + /// 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); + } +} + +impl Clone for MenuItem { + fn clone(&self) -> Self { + Self { + key: self.key.clone(), + label: self.label.clone(), + action: Rc::clone(&self.action), + } + } +} + +/// Persistent state for an active menu overlay. +#[derive(Default)] +pub struct MenuState { + /// The items shown in the menu. + pub items: Vec, + /// Current scroll offset (input during render). + pub offset: u16, + /// Maximum valid scroll offset (output from render). + pub max_scroll: u16, +} + +/// A ratatui [`StatefulWidget`] that draws the fully-open menu overlay. +/// +/// Used in [`InputMode::Menu`](crate::input::InputMode::Menu) when no +/// open/close animation is active. All data is read from [`MenuState`]. +pub struct MenuWidget; + +impl StatefulWidget for MenuWidget { + type State = MenuState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut MenuState) { + render_menu_at(&state.items, 1.0, state.offset, area, buf, Some(&mut state.max_scroll)); + } +} + +/// Opens a menu overlay with an animated reveal. +/// +/// When complete, [`input_mode_after`](Animation::input_mode_after) returns +/// [`InputMode::Menu`] so the player can interact with the overlay. +pub struct MenuOpenAnimation { + items: Vec, + progress: f32, +} + +impl MenuOpenAnimation { + /// Creates a new open animation from a snapshot of the menu items. + pub fn new(items: Vec) -> Self { + Self { items, progress: 0.0 } + } +} + +impl Animation for MenuOpenAnimation { + fn update(&mut self, dt: f32) -> bool { + self.progress += dt / ANIM_SECS; + self.progress >= 1.0 + } + + fn draw(&self, area: Rect, buf: &mut Buffer) { + render_menu_at(&self.items, self.progress.clamp(0.0, 1.0), 0, area, buf, None); + } + + fn layer(&self) -> AnimationLayer { AnimationLayer::Overlay } + + fn input_mode_after(&self) -> InputMode { InputMode::Menu } +} + +/// Closes a menu overlay with an animated collapse. +/// +/// When complete, [`input_mode_after`](Animation::input_mode_after) returns +/// [`InputMode::Board`] and [`MenuState`] is cleared by [`Ui::tick`]. +pub struct MenuCloseAnimation { + items: Vec, + progress: f32, +} + +impl MenuCloseAnimation { + /// Creates a new close animation from a snapshot of the menu items. + pub fn new(items: Vec) -> Self { + Self { items, progress: 1.0 } + } +} + +impl Animation for MenuCloseAnimation { + fn update(&mut self, dt: f32) -> bool { + self.progress -= dt / ANIM_SECS; + self.progress <= 0.0 + } + + fn draw(&self, area: Rect, buf: &mut Buffer) { + render_menu_at(&self.items, self.progress.clamp(0.0, 1.0), 0, area, buf, None); + } + + fn layer(&self) -> AnimationLayer { AnimationLayer::Overlay } + + fn input_mode_after(&self) -> InputMode { InputMode::Board } +} + +/// Renders the menu overlay at a given animation progress. +/// +/// Uses [`render_overlay_frame`] for the window/dimming/border, then fills +/// the content area with one line per item (`[key] label`). When `out_max_scroll` +/// is `Some`, writes the maximum valid scroll offset for input clamping. +fn render_menu_at( + items: &[MenuItem], + progress: f32, + offset: u16, + area: Rect, + buf: &mut Buffer, + out_max_scroll: Option<&mut u16>, +) { + let Some((content_area, scrollbar_area)) = + render_overlay_frame(&MENU_STYLE, progress, area, buf) + else { + if let Some(m) = out_max_scroll { *m = 0; } + return; + }; + + let key_style = Style::default().fg(Color::Rgb(150, 200, 255)).bg(MENU_STYLE.bg); + let label_style = Style::default().fg(Color::White).bg(MENU_STYLE.bg); + + let lines: Vec = items.iter().map(|item| { + let key_text = match &item.key { + MenuKey::Char(c) => format!("[{c}] "), + MenuKey::Esc => "[esc] ".to_string(), + }; + Line::from(vec![ + Span::styled(key_text, key_style), + Span::styled(item.label.clone(), label_style), + ]) + }).collect(); + + let content_lines = lines.len() as u16; + let max_scroll = content_lines.saturating_sub(content_area.height); + let clamped = offset.min(max_scroll); + + // Each menu item is exactly one line — no word-wrap needed. + let para = Paragraph::new(lines).scroll((clamped, 0)); + + // Center items vertically when there are fewer items than the window height. + let pad = content_area.height.saturating_sub(content_lines) / 2; + let [_, content_rect, _] = Layout::vertical([ + Constraint::Length(pad), + Constraint::Length(content_lines.min(content_area.height)), + Constraint::Fill(1), + ]).areas(content_area); + use ratatui::widgets::Widget; + para.render(content_rect, buf); + + if content_lines > content_area.height { + let mut sb_state = ScrollbarState::new((max_scroll + 1) as usize) + .position(clamped as usize); + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .render(scrollbar_area, buf, &mut sb_state); + } + + if let Some(m) = out_max_scroll { *m = max_scroll; } +} + +/// Returns the items for the standard pause menu. +/// +/// Pressing `q` marks [`Ui::should_quit`] and starts the close animation. +/// Pressing `Esc` closes the menu without quitting. +pub fn pause_menu_items() -> Vec { + vec![ + MenuItem::new(MenuKey::Char('q'), "Quit game", |_g, ui| { + ui.should_quit = true; + ui.begin_close_menu(); + }), + MenuItem::new(MenuKey::Esc, "Close menu", |_g, ui| { + ui.begin_close_menu(); + }), + ] +} diff --git a/kiln-tui/src/overlay.rs b/kiln-tui/src/overlay.rs new file mode 100644 index 0000000..8d620f4 --- /dev/null +++ b/kiln-tui/src/overlay.rs @@ -0,0 +1,73 @@ +//! Shared window-frame rendering for full-screen overlays (scrolls, menus). +//! +//! [`render_overlay_frame`] handles background dimming, window sizing and +//! centering, animated height, and the bordered block. Callers fill the +//! returned content area with their own content. + +use ratatui::buffer::Buffer; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::widgets::{Block, Fill, Widget}; + +/// Visual parameters for an animated overlay window. +/// +/// All fields are `Copy` types so this can be a `const`. +pub(crate) struct OverlayStyle { + /// Solid background color filling the window interior. + pub bg: Color, + /// Foreground (border line) color. + pub border_fg: Color, + /// `outer_w = screen_width / width_div`. Use `2` for half-screen. + pub width_div: u16, + /// `outer_h = (screen_height * height_fraction) as u16`, forced even. + pub height_fraction: f32, +} + +/// Dims the background, sizes and positions the animated window, fills it, +/// renders the bordered block, and returns `(content_rect, scrollbar_rect)`. +/// +/// `progress` is 0.0 (invisible) → 1.0 (fully open). Height is animated +/// proportionally, always kept even so it expands by exactly 2 rows at a time +/// from the center. Returns `None` when `progress ≤ 0`. +pub(crate) fn render_overlay_frame( + style: &OverlayStyle, + progress: f32, + area: Rect, + buf: &mut Buffer, +) -> Option<(Rect, Rect)> { + if progress <= 0.0 { return None; } + + let outer_w = area.width / style.width_div; + let outer_h = (area.height as f32 * style.height_fraction) as u16 & !1; + // Animate height: keep it even at every intermediate frame. + let actual_h = ((outer_h as f32 * progress).round() as u16 & !1).max(2); + + let left = area.x + area.width.saturating_sub(outer_w) / 2; + let top = area.y + area.height.saturating_sub(actual_h) / 2; + let overlay_rect = Rect { x: left, y: top, width: outer_w, height: actual_h }; + + // Dim all background cells so board/game content doesn't show through. + let dim_style = Style::default().fg(Color::Rgb(60, 60, 60)).bg(Color::Black); + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_style(dim_style); + } + } + } + Fill::new(" ").style(Style::default().bg(style.bg)).render(overlay_rect, buf); + + let block = Block::bordered() + .border_style(Style::default().fg(style.border_fg).bg(style.bg)) + .style(Style::default().bg(style.bg)); + let inner = block.inner(overlay_rect); + block.render(overlay_rect, buf); + + // Split inner into content (left) and scrollbar column (right). + let [content_rect, scrollbar_rect] = Layout::horizontal([ + Constraint::Min(0), + Constraint::Length(1), + ]).areas(inner); + + Some((content_rect, scrollbar_rect)) +} diff --git a/kiln-tui/src/scroll_overlay.rs b/kiln-tui/src/scroll_overlay.rs index a0736a7..07edf62 100644 --- a/kiln-tui/src/scroll_overlay.rs +++ b/kiln-tui/src/scroll_overlay.rs @@ -11,15 +11,23 @@ use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{ - Block, Fill, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, - Widget, Wrap, + Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget, Wrap, }; use crate::animation::{Animation, AnimationLayer}; use crate::input::InputMode; +use crate::overlay::{OverlayStyle, render_overlay_frame}; /// Duration of the open and close animations, in seconds. const ANIM_SECS: f32 = 0.25; +/// Visual style for scroll overlays: warm amber on dark background. +const SCROLL_STYLE: OverlayStyle = OverlayStyle { + bg: Color::Rgb(30, 25, 10), + border_fg: Color::Rgb(180, 160, 100), + width_div: 2, + height_fraction: 0.75, +}; + /// State threaded through a [`ScrollOverlayWidget`] render call. #[derive(Default)] pub struct ScrollOverlayState { @@ -123,12 +131,11 @@ impl Animation for ScrollCloseAnimation { fn input_mode_after(&self) -> InputMode { InputMode::Board } } -/// Shared rendering for the scroll overlay at a given animation progress. +/// Renders the scroll overlay at a given animation progress using the shared +/// overlay frame, then fills the content area with the scroll lines. /// -/// `progress` is 0.0 (invisible) → 1.0 (fully open); the window height is -/// animated proportionally, always kept even so it expands symmetrically. -/// When `state` is `Some`, `max_scroll` is written out for scroll-input clamping -/// (only needed when fully open and interactive). +/// When `state` is `Some`, `max_scroll` is written out so the caller can clamp +/// future scroll-input (only needed when fully open and interactive). fn render_scroll_at( lines: &[ScrollLine], progress: f32, @@ -136,31 +143,18 @@ fn render_scroll_at( buf: &mut Buffer, state: Option<&mut ScrollOverlayState>, ) { - if progress <= 0.0 { + let Some((content_area, scrollbar_area)) = + render_overlay_frame(&SCROLL_STYLE, progress, area, buf) + else { if let Some(s) = state { s.max_scroll = 0; } return; - } + }; - let bg = Color::Rgb(30, 25, 10); - let text_style = Style::default().fg(Color::White).bg(bg); - let key_style = Style::default().fg(Color::Yellow).bg(bg); - let choice_style = Style::default().fg(Color::Rgb(200, 200, 100)).bg(bg); - let border_style = Style::default().fg(Color::Rgb(180, 160, 100)).bg(bg); + let text_style = Style::default().fg(Color::White).bg(SCROLL_STYLE.bg); + let key_style = Style::default().fg(Color::Yellow).bg(SCROLL_STYLE.bg); + let choice_style = Style::default().fg(Color::Rgb(200, 200, 100)).bg(SCROLL_STYLE.bg); - // Fixed proportional sizing. Height is forced even so each animation step - // expands the window by exactly 2 rows (one on each side from the center). - let outer_w = area.width / 2; - let outer_h = (area.height * 3 / 4) & !1; - - // Animate height: keep it even at every intermediate frame. - let actual_h = ((outer_h as f32 * progress).round() as u16 & !1).max(2); - - let left = area.x + area.width.saturating_sub(outer_w) / 2; - let top = area.y + area.height.saturating_sub(actual_h) / 2; - let overlay_rect = Rect { x: left, y: top, width: outer_w, height: actual_h }; - - // Build Lines without pre-wrapping — Paragraph handles wrapping at render time. - // Lines whose source starts with whitespace are stripped and centered; others are left-aligned. + // Build Lines — whitespace-leading lines are centered, others left-aligned. let mut ratatui_lines: Vec = Vec::new(); let mut choice_letter = b'a'; for item in lines { @@ -185,27 +179,6 @@ fn render_scroll_at( } } - // Dim the whole background so board glyphs don't show through. - let dim_style = Style::default().fg(Color::Rgb(60, 60, 60)).bg(Color::Black); - for y in area.top()..area.bottom() { - for x in area.left()..area.right() { - if let Some(cell) = buf.cell_mut((x, y)) { - cell.set_style(dim_style); - } - } - } - Fill::new(" ").style(Style::default().bg(bg)).render(overlay_rect, buf); - - let block = Block::bordered().border_style(border_style).style(Style::default().bg(bg)); - let inner = block.inner(overlay_rect); - block.render(overlay_rect, buf); - - let [content_area, scrollbar_area] = Layout::horizontal([ - Constraint::Min(0), - Constraint::Length(1), - ]).areas(inner); - - // Measure wrapped content height, clamp scroll offset. let offset = state.as_ref().map(|s| s.offset).unwrap_or(0); let para = Paragraph::new(ratatui_lines).wrap(Wrap { trim: false }); let content_lines = para.line_count(content_area.width) as u16; diff --git a/kiln-tui/src/ui.rs b/kiln-tui/src/ui.rs index f0d3348..1677da1 100644 --- a/kiln-tui/src/ui.rs +++ b/kiln-tui/src/ui.rs @@ -5,13 +5,14 @@ use kiln_core::Board; use kiln_core::game::GameState; use crate::animation::Animation; use crate::log::LogState; +use crate::menu::{MenuItem, MenuCloseAnimation, MenuOpenAnimation, MenuState}; use crate::scroll_overlay::{ScrollCloseAnimation, ScrollOpenAnimation, ScrollOverlayState}; use crate::transition::TransitionAnimation; /// A pair of board `Rc`s (old board, new board) queued for transition pre-rendering. type PendingTransition = Option<(Rc>, Rc>)>; -/// View-only UI state for the log panel, scroll overlay, and board animations. +/// View-only UI state for the log panel, scroll overlay, menu, and board animations. /// Presentation-only state; lives in the front-end, not on [`GameState`]. pub(crate) struct Ui { /// State for the log panel (open/closed, height, scroll position). @@ -23,6 +24,11 @@ pub(crate) struct Ui { pub(crate) pending_transition: PendingTransition, /// The currently running animation, if any. All input is suppressed while `Some`. pub(crate) active_animation: Option>, + /// State for the active menu overlay. `Some` while the menu is open or closing. + pub(crate) menu: Option, + /// Set by a menu action to signal that the game loop should exit after the + /// close animation finishes. + pub(crate) should_quit: bool, } impl Default for Ui { @@ -32,38 +38,62 @@ impl Default for Ui { overlay: ScrollOverlayState::default(), pending_transition: None, active_animation: None, + menu: None, + should_quit: false, } } } impl Ui { - /// Scrolls the overlay by `delta` lines, clamped to the valid range. + /// Scrolls the scroll overlay by `delta` lines, clamped to the valid range. pub(crate) fn scroll_lines(&mut self, delta: i32) { self.overlay.offset = (self.overlay.offset as i32 + delta) .clamp(0, self.overlay.max_scroll as i32) as u16; } + /// Scrolls the menu overlay by `delta` lines, clamped to the valid range. + pub(crate) fn scroll_menu(&mut self, delta: i32) { + if let Some(m) = self.menu.as_mut() { + m.offset = (m.offset as i32 + delta).clamp(0, m.max_scroll as i32) as u16; + } + } + + /// Opens a menu: stores the items as [`MenuState`] and starts the open animation. + pub(crate) fn open_menu(&mut self, items: Vec) { + self.active_animation = Some(Box::new(MenuOpenAnimation::new(items.clone()))); + self.menu = Some(MenuState { items, ..Default::default() }); + } + + /// Starts the close animation using a snapshot of the current menu items. + /// + /// Called from within a menu item's action closure (e.g. `ui.begin_close_menu()`). + pub(crate) fn begin_close_menu(&mut self) { + let items = self.menu.as_ref().map(|m| m.items.clone()).unwrap_or_default(); + self.active_animation = Some(Box::new(MenuCloseAnimation::new(items))); + } + /// Advances all time-driven UI state by `dt` and drives the game tick. /// /// While any animation is running, `game.tick` is suppressed. When an /// animation finishes, [`input_mode_after`](Animation::input_mode_after) is /// consulted: returning to [`Board`](crate::input::InputMode::Board) mode - /// immediately calls [`handle_scroll`](GameState::handle_scroll) so the - /// closed overlay is cleared before the next draw rather than flickering open - /// for one extra frame. + /// 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) { // 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(); self.active_animation = None; - // Returning to Board mode: eagerly close any scroll so it isn't - // rendered as open on the next frame before the game tick runs. + // 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(); + self.menu = None; } } - return; // no game tick during any animation (even when animation just ended) + return; // no game tick during any animation (even when just ended) } // Normal path: tick the game, then check if a scroll appeared. @@ -76,8 +106,7 @@ impl Ui { /// Sets the player's scroll choice and starts the close animation. /// - /// `choice` is `None` when the player dismisses without selecting; it is - /// stored on the active scroll and dispatched by the engine on the next tick. + /// `choice` is `None` when the player dismisses without selecting. pub(crate) fn begin_close(&mut self, choice: Option, game: &mut GameState) { if let Some(scroll) = game.active_scroll.as_mut() { scroll.choice = choice;