menus
This commit is contained in:
+38
-1
@@ -1,6 +1,7 @@
|
|||||||
use ratatui::crossterm::event::{Event, KeyCode, MouseEventKind};
|
use ratatui::crossterm::event::{Event, KeyCode, MouseEventKind};
|
||||||
use kiln_core::Direction;
|
use kiln_core::Direction;
|
||||||
use kiln_core::game::{GameState, ScrollLine};
|
use kiln_core::game::{GameState, ScrollLine};
|
||||||
|
use crate::menu::{MenuItem, MenuKey, pause_menu_items, ActionRc as MenuActionRc};
|
||||||
use crate::ui::Ui;
|
use crate::ui::Ui;
|
||||||
|
|
||||||
/// Moves the player and, if the move crosses a portal, captures both board Rcs
|
/// Moves the player and, if the move crosses a portal, captures both board Rcs
|
||||||
@@ -22,6 +23,8 @@ pub enum InputMode {
|
|||||||
Board,
|
Board,
|
||||||
/// A scroll overlay is active; all input is captured for scroll navigation.
|
/// A scroll overlay is active; all input is captured for scroll navigation.
|
||||||
Scroll,
|
Scroll,
|
||||||
|
/// A menu overlay is active; all input is captured for menu navigation.
|
||||||
|
Menu,
|
||||||
/// An animation is running; all input is discarded.
|
/// An animation is running; all input is discarded.
|
||||||
Animating,
|
Animating,
|
||||||
/// Reserved for a post-transition freeze; nothing currently produces this mode.
|
/// 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() {
|
if ui.active_animation.is_some() || ui.pending_transition.is_some() {
|
||||||
return InputMode::Animating;
|
return InputMode::Animating;
|
||||||
}
|
}
|
||||||
|
if ui.menu.is_some() { return InputMode::Menu; }
|
||||||
if game.active_scroll.is_some() { InputMode::Scroll } else { InputMode::Board }
|
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 {
|
pub(crate) fn handle_board_input(event: Event, log_open: bool, terminal_h: u16, game: &mut GameState, ui: &mut Ui) -> bool {
|
||||||
match event {
|
match event {
|
||||||
Event::Key(key) => match key.code {
|
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::Up => try_move_with_transition(game, ui, Direction::North),
|
||||||
KeyCode::Down => try_move_with_transition(game, ui, Direction::South),
|
KeyCode::Down => try_move_with_transition(game, ui, Direction::South),
|
||||||
KeyCode::Left => try_move_with_transition(game, ui, Direction::West),
|
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<MenuActionRc> = 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),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+17
-14
@@ -10,6 +10,8 @@
|
|||||||
mod animation;
|
mod animation;
|
||||||
mod cp437;
|
mod cp437;
|
||||||
mod log;
|
mod log;
|
||||||
|
mod menu;
|
||||||
|
mod overlay;
|
||||||
mod render;
|
mod render;
|
||||||
mod scroll_overlay;
|
mod scroll_overlay;
|
||||||
mod term;
|
mod term;
|
||||||
@@ -36,8 +38,9 @@ use std::process::ExitCode;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use term::TerminalCaps;
|
use term::TerminalCaps;
|
||||||
use crate::animation::{AnimationLayer, AnimWidget};
|
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::log::{log_preview_line, LogWidget};
|
||||||
|
use crate::menu::MenuWidget;
|
||||||
use crate::scroll_overlay::ScrollOverlayWidget;
|
use crate::scroll_overlay::ScrollOverlayWidget;
|
||||||
use crate::speech::SpeechBubblesWidget;
|
use crate::speech::SpeechBubblesWidget;
|
||||||
use crate::ui::Ui;
|
use crate::ui::Ui;
|
||||||
@@ -75,10 +78,7 @@ fn main() -> ExitCode {
|
|||||||
let _ = term::push_kitty_flags();
|
let _ = term::push_kitty_flags();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed the log: the terminal-capabilities badge plus a couple of test
|
game.log(LogLine::raw("[esc] for menu"));
|
||||||
// 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("Welcome to kiln - ").append(caps.status_logline()));
|
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
|
// 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;
|
let terminal_h = terminal.size()?.height;
|
||||||
match current_input_mode(game, ui) {
|
match current_input_mode(game, ui) {
|
||||||
InputMode::Board | InputMode::Frozen => {
|
InputMode::Board | InputMode::Frozen => {
|
||||||
if handle_board_input(event, ui.log.open, terminal_h, game, ui) {
|
handle_board_input(event, ui.log.open, terminal_h, game, ui);
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
InputMode::Menu => handle_menu_input(event, game, ui),
|
||||||
InputMode::Scroll => handle_scroll_input(event, game, ui),
|
InputMode::Scroll => handle_scroll_input(event, game, ui),
|
||||||
// Animation running — discard all events.
|
// Animation running — discard all events.
|
||||||
InputMode::Animating => {}
|
InputMode::Animating => {}
|
||||||
@@ -152,13 +151,18 @@ fn run(
|
|||||||
last_tick = Instant::now();
|
last_tick = Instant::now();
|
||||||
ui.tick(dt, game);
|
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
|
/// 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
|
/// 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.
|
/// 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) {
|
fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) {
|
||||||
// Borrow the board for the duration of the frame (no script runs during draw).
|
// Borrow the board for the duration of the frame (no script runs during draw).
|
||||||
let board = &game.board();
|
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_board_area(frame, inner, game, ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw overlay animations (scroll open/close) or the fully-open scroll overlay.
|
// Overlay layer: animation > menu > scroll (mutually exclusive at runtime).
|
||||||
// Suppressed during a board-layer animation (transition wipe); enter_board()
|
|
||||||
// already cleared active_scroll so there's nothing to show anyway.
|
|
||||||
let is_overlay_anim = ui.active_animation.as_ref()
|
let is_overlay_anim = ui.active_animation.as_ref()
|
||||||
.map(|a| matches!(a.layer(), AnimationLayer::Overlay))
|
.map(|a| matches!(a.layer(), AnimationLayer::Overlay))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let area = frame.area();
|
||||||
if is_overlay_anim {
|
if is_overlay_anim {
|
||||||
let area = frame.area();
|
|
||||||
frame.render_widget(AnimWidget(ui.active_animation.as_ref().unwrap().as_ref()), 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 {
|
} else if let Some(scroll) = &game.active_scroll {
|
||||||
let area = frame.area();
|
|
||||||
frame.render_stateful_widget(ScrollOverlayWidget::new(&scroll.lines), area, &mut ui.overlay);
|
frame.render_stateful_widget(ScrollOverlayWidget::new(&scroll.lines), area, &mut ui.overlay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<dyn Fn>` 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<dyn Fn(&mut GameState, &mut Ui)>;
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
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<MenuItem>,
|
||||||
|
/// 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<MenuItem>,
|
||||||
|
progress: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MenuOpenAnimation {
|
||||||
|
/// Creates a new open animation from a snapshot of the menu items.
|
||||||
|
pub fn new(items: Vec<MenuItem>) -> 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<MenuItem>,
|
||||||
|
progress: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MenuCloseAnimation {
|
||||||
|
/// Creates a new close animation from a snapshot of the menu items.
|
||||||
|
pub fn new(items: Vec<MenuItem>) -> 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<Line> = 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<MenuItem> {
|
||||||
|
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();
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -11,15 +11,23 @@ use ratatui::layout::{Constraint, Layout, Rect};
|
|||||||
use ratatui::style::{Color, Style};
|
use ratatui::style::{Color, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{
|
use ratatui::widgets::{
|
||||||
Block, Fill, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget,
|
Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget, Wrap,
|
||||||
Widget, Wrap,
|
|
||||||
};
|
};
|
||||||
use crate::animation::{Animation, AnimationLayer};
|
use crate::animation::{Animation, AnimationLayer};
|
||||||
use crate::input::InputMode;
|
use crate::input::InputMode;
|
||||||
|
use crate::overlay::{OverlayStyle, render_overlay_frame};
|
||||||
|
|
||||||
/// Duration of the open and close animations, in seconds.
|
/// Duration of the open and close animations, in seconds.
|
||||||
const ANIM_SECS: f32 = 0.25;
|
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.
|
/// State threaded through a [`ScrollOverlayWidget`] render call.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct ScrollOverlayState {
|
pub struct ScrollOverlayState {
|
||||||
@@ -123,12 +131,11 @@ impl Animation for ScrollCloseAnimation {
|
|||||||
fn input_mode_after(&self) -> InputMode { InputMode::Board }
|
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
|
/// When `state` is `Some`, `max_scroll` is written out so the caller can clamp
|
||||||
/// animated proportionally, always kept even so it expands symmetrically.
|
/// future scroll-input (only needed when fully open and interactive).
|
||||||
/// When `state` is `Some`, `max_scroll` is written out for scroll-input clamping
|
|
||||||
/// (only needed when fully open and interactive).
|
|
||||||
fn render_scroll_at(
|
fn render_scroll_at(
|
||||||
lines: &[ScrollLine],
|
lines: &[ScrollLine],
|
||||||
progress: f32,
|
progress: f32,
|
||||||
@@ -136,31 +143,18 @@ fn render_scroll_at(
|
|||||||
buf: &mut Buffer,
|
buf: &mut Buffer,
|
||||||
state: Option<&mut ScrollOverlayState>,
|
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; }
|
if let Some(s) = state { s.max_scroll = 0; }
|
||||||
return;
|
return;
|
||||||
}
|
};
|
||||||
|
|
||||||
let bg = Color::Rgb(30, 25, 10);
|
let text_style = Style::default().fg(Color::White).bg(SCROLL_STYLE.bg);
|
||||||
let text_style = Style::default().fg(Color::White).bg(bg);
|
let key_style = Style::default().fg(Color::Yellow).bg(SCROLL_STYLE.bg);
|
||||||
let key_style = Style::default().fg(Color::Yellow).bg(bg);
|
let choice_style = Style::default().fg(Color::Rgb(200, 200, 100)).bg(SCROLL_STYLE.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);
|
|
||||||
|
|
||||||
// Fixed proportional sizing. Height is forced even so each animation step
|
// Build Lines — whitespace-leading lines are centered, others left-aligned.
|
||||||
// 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.
|
|
||||||
let mut ratatui_lines: Vec<Line> = Vec::new();
|
let mut ratatui_lines: Vec<Line> = Vec::new();
|
||||||
let mut choice_letter = b'a';
|
let mut choice_letter = b'a';
|
||||||
for item in lines {
|
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 offset = state.as_ref().map(|s| s.offset).unwrap_or(0);
|
||||||
let para = Paragraph::new(ratatui_lines).wrap(Wrap { trim: false });
|
let para = Paragraph::new(ratatui_lines).wrap(Wrap { trim: false });
|
||||||
let content_lines = para.line_count(content_area.width) as u16;
|
let content_lines = para.line_count(content_area.width) as u16;
|
||||||
|
|||||||
+39
-10
@@ -5,13 +5,14 @@ use kiln_core::Board;
|
|||||||
use kiln_core::game::GameState;
|
use kiln_core::game::GameState;
|
||||||
use crate::animation::Animation;
|
use crate::animation::Animation;
|
||||||
use crate::log::LogState;
|
use crate::log::LogState;
|
||||||
|
use crate::menu::{MenuItem, MenuCloseAnimation, MenuOpenAnimation, MenuState};
|
||||||
use crate::scroll_overlay::{ScrollCloseAnimation, ScrollOpenAnimation, ScrollOverlayState};
|
use crate::scroll_overlay::{ScrollCloseAnimation, ScrollOpenAnimation, ScrollOverlayState};
|
||||||
use crate::transition::TransitionAnimation;
|
use crate::transition::TransitionAnimation;
|
||||||
|
|
||||||
/// A pair of board `Rc`s (old board, new board) queued for transition pre-rendering.
|
/// A pair of board `Rc`s (old board, new board) queued for transition pre-rendering.
|
||||||
type PendingTransition = Option<(Rc<RefCell<Board>>, Rc<RefCell<Board>>)>;
|
type PendingTransition = Option<(Rc<RefCell<Board>>, Rc<RefCell<Board>>)>;
|
||||||
|
|
||||||
/// 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`].
|
/// Presentation-only state; lives in the front-end, not on [`GameState`].
|
||||||
pub(crate) struct Ui {
|
pub(crate) struct Ui {
|
||||||
/// State for the log panel (open/closed, height, scroll position).
|
/// State for the log panel (open/closed, height, scroll position).
|
||||||
@@ -23,6 +24,11 @@ pub(crate) struct Ui {
|
|||||||
pub(crate) pending_transition: PendingTransition,
|
pub(crate) pending_transition: PendingTransition,
|
||||||
/// The currently running animation, if any. All input is suppressed while `Some`.
|
/// The currently running animation, if any. All input is suppressed while `Some`.
|
||||||
pub(crate) active_animation: Option<Box<dyn Animation>>,
|
pub(crate) active_animation: Option<Box<dyn Animation>>,
|
||||||
|
/// State for the active menu overlay. `Some` while the menu is open or closing.
|
||||||
|
pub(crate) menu: Option<MenuState>,
|
||||||
|
/// 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 {
|
impl Default for Ui {
|
||||||
@@ -32,38 +38,62 @@ impl Default for Ui {
|
|||||||
overlay: ScrollOverlayState::default(),
|
overlay: ScrollOverlayState::default(),
|
||||||
pending_transition: None,
|
pending_transition: None,
|
||||||
active_animation: None,
|
active_animation: None,
|
||||||
|
menu: None,
|
||||||
|
should_quit: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ui {
|
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) {
|
pub(crate) fn scroll_lines(&mut self, delta: i32) {
|
||||||
self.overlay.offset = (self.overlay.offset as i32 + delta)
|
self.overlay.offset = (self.overlay.offset as i32 + delta)
|
||||||
.clamp(0, self.overlay.max_scroll as i32) as u16;
|
.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<MenuItem>) {
|
||||||
|
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.
|
/// Advances all time-driven UI state by `dt` and drives the game tick.
|
||||||
///
|
///
|
||||||
/// While any animation is running, `game.tick` is suppressed. When an
|
/// While any animation is running, `game.tick` is suppressed. When an
|
||||||
/// animation finishes, [`input_mode_after`](Animation::input_mode_after) is
|
/// animation finishes, [`input_mode_after`](Animation::input_mode_after) is
|
||||||
/// consulted: returning to [`Board`](crate::input::InputMode::Board) mode
|
/// consulted: returning to [`Board`](crate::input::InputMode::Board) mode
|
||||||
/// immediately calls [`handle_scroll`](GameState::handle_scroll) so the
|
/// eagerly calls [`handle_scroll`](GameState::handle_scroll) to clear any
|
||||||
/// closed overlay is cleared before the next draw rather than flickering open
|
/// pending scroll, and clears [`menu`](Ui::menu) so the closed overlay is not
|
||||||
/// for one extra frame.
|
/// rendered on the next frame.
|
||||||
pub(crate) fn tick(&mut self, dt: Duration, game: &mut GameState) {
|
pub(crate) fn tick(&mut self, dt: Duration, game: &mut GameState) {
|
||||||
// Advance the active animation; on completion, dispatch any mode-change effect.
|
// Advance the active animation; on completion, dispatch any mode-change effect.
|
||||||
if let Some(ref mut anim) = self.active_animation {
|
if let Some(ref mut anim) = self.active_animation {
|
||||||
if anim.update(dt.as_secs_f32()) {
|
if anim.update(dt.as_secs_f32()) {
|
||||||
let mode = anim.input_mode_after();
|
let mode = anim.input_mode_after();
|
||||||
self.active_animation = None;
|
self.active_animation = None;
|
||||||
// Returning to Board mode: eagerly close any scroll so it isn't
|
// Returning to Board mode: eagerly clear scroll and menu state so
|
||||||
// rendered as open on the next frame before the game tick runs.
|
// neither renders as open for one extra frame before the game tick.
|
||||||
if matches!(mode, crate::input::InputMode::Board) {
|
if matches!(mode, crate::input::InputMode::Board) {
|
||||||
game.handle_scroll();
|
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.
|
// 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.
|
/// Sets the player's scroll choice and starts the close animation.
|
||||||
///
|
///
|
||||||
/// `choice` is `None` when the player dismisses without selecting; it is
|
/// `choice` is `None` when the player dismisses without selecting.
|
||||||
/// stored on the active scroll and dispatched by the engine on the next tick.
|
|
||||||
pub(crate) fn begin_close(&mut self, choice: Option<String>, game: &mut GameState) {
|
pub(crate) fn begin_close(&mut self, choice: Option<String>, game: &mut GameState) {
|
||||||
if let Some(scroll) = game.active_scroll.as_mut() {
|
if let Some(scroll) = game.active_scroll.as_mut() {
|
||||||
scroll.choice = choice;
|
scroll.choice = choice;
|
||||||
|
|||||||
Reference in New Issue
Block a user