use crate::animation::Animation; use crate::editor::EditorState; use crate::log::LogState; use crate::menu::{MenuCloseAnimation, MenuItem, MenuOpenAnimation, MenuState}; use crate::mode::{Mode, PendingMode}; use crate::scroll_overlay::{ScrollCloseAnimation, ScrollOpenAnimation, ScrollOverlayState}; use crate::transition::TransitionAnimation; use kiln_core::Board; use kiln_core::game::GameState; use std::cell::RefCell; use std::rc::Rc; use std::time::Duration; /// 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, 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). pub(crate) log: LogState, /// Scroll offset and bounds for the fully-open scroll overlay. pub(crate) overlay: ScrollOverlayState, /// Old and new board Rcs waiting to be pre-rendered on the next `draw()` call. /// Populated when a portal transition is detected; consumed in `draw()`. 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, /// 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, /// The editor parked while a **playtest** runs. `Some` exactly while the current /// [`Mode::Play`](crate::mode::Mode::Play) is a playtest launched from the editor /// (see [`EditorState::playtest`]); the run loop restores it on exit. Doubles as /// the "am I playtesting?" flag for `Esc` handling and the title cue. pub(crate) suspended_editor: Option, /// Set when `Esc` is pressed during a playtest, signalling the run loop to drop the /// playtest game and restore [`suspended_editor`](Ui::suspended_editor). pub(crate) exit_playtest: bool, } impl Default for Ui { fn default() -> Self { Self { log: LogState::new(), overlay: ScrollOverlayState::default(), pending_transition: None, active_animation: None, menu: None, should_quit: false, world_path: String::new(), pending_mode: None, suspended_editor: None, exit_playtest: false, } } } impl Ui { /// 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 active mode. /// /// 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) 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 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 next tick. if matches!(after, crate::input::InputMode::Board) { if let Mode::Play(game) = mode { game.handle_scroll(); } self.menu = None; } } return; // no mode tick during any animation (even when just ended) } // The game only advances during normal play (InputMode::Board). While a // scroll or menu overlay is open the input mode is Scroll/Menu, so the game // is paused — otherwise `game.tick` would call `handle_scroll`, clearing the // scroll the instant its open animation finished. let input_mode = crate::input::current_input_mode(mode, self); match mode { // Play: tick the game (only in Board mode), then check whether a scroll // overlay appeared as a result of this tick. Mode::Play(game) => { if matches!(input_mode, crate::input::InputMode::Board) { 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()), } } /// Sets the player's scroll choice and starts the close animation. /// /// `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; } let lines = game .active_scroll .as_ref() .map(|s| s.lines.clone()) .unwrap_or_default(); self.active_animation = Some(Box::new(ScrollCloseAnimation::new(lines))); } /// Initialises the board-wipe transition animation once the draw area is known. /// /// Called from `draw_board_area` when [`pending_transition`](Ui::pending_transition) /// is set, so the [`TransitionAnimation`] is sized to the exact inner board area. pub(crate) fn start_transition( &mut self, old: &Board, new: &Board, area: ratatui::layout::Rect, ) { self.active_animation = Some(Box::new(TransitionAnimation::new(old, new, area))); } }