animations
This commit is contained in:
+195
-179
@@ -1,11 +1,11 @@
|
||||
//! The scroll overlay widget, shown when a script calls `scroll()`.
|
||||
//! The scroll overlay widget and its open/close animations.
|
||||
//!
|
||||
//! Uses [`StatefulWidget`] rather than [`Widget`] because rendering computes
|
||||
//! `max_scroll` (the highest valid scroll offset) as a side-effect that the
|
||||
//! caller needs to clamp future scroll input. [`ScrollOverlayState`] carries
|
||||
//! `offset` in and `max_scroll` out across the render call.
|
||||
//! [`ScrollOverlayWidget`] renders a fully-open overlay for [`InputMode::Scroll`].
|
||||
//! [`ScrollOpenAnimation`] and [`ScrollCloseAnimation`] implement [`Animation`]
|
||||
//! and handle the cosmetic open/close transitions; while either plays, all input
|
||||
//! is suppressed.
|
||||
|
||||
use kiln_core::game::{Scroll, ScrollLine};
|
||||
use kiln_core::game::ScrollLine;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
@@ -14,25 +14,12 @@ use ratatui::widgets::{
|
||||
Block, Fill, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget,
|
||||
Widget, Wrap,
|
||||
};
|
||||
use crate::animation::{Animation, AnimationLayer};
|
||||
use crate::input::InputMode;
|
||||
|
||||
/// Duration of the open and close animations, in seconds.
|
||||
const ANIM_SECS: f32 = 0.25;
|
||||
|
||||
/// Animation state for the scroll overlay window.
|
||||
#[derive(Default)]
|
||||
pub enum ScrollAnimState {
|
||||
/// Window is opening: progress goes from 0.0 → 1.0 over 0.5 s.
|
||||
Opening(f32),
|
||||
/// Window is fully open.
|
||||
#[default]
|
||||
Open,
|
||||
/// Window is closing: progress from 1.0 → 0.0 over 0.5 s.
|
||||
Closing(f32),
|
||||
/// Close animation finished; the caller should dispatch
|
||||
/// [`ScrollOverlayState::pending_choice`] and reset the state.
|
||||
Done,
|
||||
}
|
||||
|
||||
/// State threaded through a [`ScrollOverlayWidget`] render call.
|
||||
#[derive(Default)]
|
||||
pub struct ScrollOverlayState {
|
||||
@@ -40,64 +27,22 @@ pub struct ScrollOverlayState {
|
||||
pub offset: u16,
|
||||
/// The maximum valid scroll offset (output — written during render).
|
||||
pub max_scroll: u16,
|
||||
/// Open/close animation state (input — read during render).
|
||||
pub anim: Option<ScrollAnimState>,
|
||||
/// The player's choice to dispatch when [`close_finished`](Self::close_finished)
|
||||
/// is true. Set by the caller before starting a close animation.
|
||||
pub pending_choice: Option<String>,
|
||||
}
|
||||
|
||||
impl ScrollOverlayState {
|
||||
/// Advances the open/close animation by `dt` seconds.
|
||||
///
|
||||
/// When a closing animation completes, transitions `anim` to
|
||||
/// [`ScrollAnimState::Done`]. Check [`close_finished`](Self::close_finished)
|
||||
/// after calling this, then use [`take_choice`](Self::take_choice) to
|
||||
/// retrieve the pending choice before resetting the state.
|
||||
pub fn advance_anim(&mut self, dt: f32) {
|
||||
match &mut self.anim {
|
||||
Some(ScrollAnimState::Opening(p)) => {
|
||||
*p += dt / ANIM_SECS;
|
||||
if *p >= 1.0 { self.anim = Some(ScrollAnimState::Open); }
|
||||
}
|
||||
Some(ScrollAnimState::Closing(p)) => {
|
||||
*p -= dt / ANIM_SECS;
|
||||
if *p <= 0.0 { self.anim = Some(ScrollAnimState::Done); }
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when a closing animation has just finished and
|
||||
/// [`pending_choice`](Self::pending_choice) is ready to be dispatched.
|
||||
pub fn close_finished(&self) -> bool {
|
||||
matches!(self.anim, Some(ScrollAnimState::Done))
|
||||
}
|
||||
|
||||
/// Removes and returns the pending choice. Returns `None` if no choice
|
||||
/// was queued (e.g. the player dismissed without selecting).
|
||||
pub fn take_choice(&mut self) -> Option<String> {
|
||||
self.pending_choice.take()
|
||||
}
|
||||
}
|
||||
|
||||
/// A ratatui widget that draws the full-screen scroll overlay.
|
||||
/// A ratatui widget that draws the fully-open scroll overlay.
|
||||
///
|
||||
/// The overlay dims the background, then renders a `Block`-bordered window at
|
||||
/// half screen width × 3/4 screen height. Height animates via `state.anim`
|
||||
/// (0.0 → 1.0 = opening, 1.0 → 0.0 = closing), always staying even so each
|
||||
/// step grows the window by exactly 2 rows from the center. Text lines whose
|
||||
/// source starts with whitespace are stripped and centered; all others are
|
||||
/// left-aligned. Selectable choices show `[a]`/`[b]` prefixes.
|
||||
/// Used in [`InputMode::Scroll`] when no open/close animation is active.
|
||||
/// Uses [`StatefulWidget`] because rendering computes `max_scroll` as a
|
||||
/// side-effect that the caller needs to clamp future scroll input.
|
||||
pub struct ScrollOverlayWidget<'a> {
|
||||
/// The scroll content to display.
|
||||
pub scroll: &'a Scroll,
|
||||
pub lines: &'a [ScrollLine],
|
||||
}
|
||||
|
||||
impl<'a> ScrollOverlayWidget<'a> {
|
||||
/// Creates a widget for the given scroll content.
|
||||
pub fn new(scroll: &'a Scroll) -> Self {
|
||||
Self { scroll }
|
||||
/// Creates a widget for the given scroll lines.
|
||||
pub fn new(lines: &'a [ScrollLine]) -> Self {
|
||||
Self { lines }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,113 +50,184 @@ impl StatefulWidget for ScrollOverlayWidget<'_> {
|
||||
type State = ScrollOverlayState;
|
||||
|
||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut ScrollOverlayState) {
|
||||
let anim = match &state.anim {
|
||||
Some(ScrollAnimState::Opening(p)) => p.clamp(0.0, 1.0),
|
||||
Some(ScrollAnimState::Open) => 1.0,
|
||||
Some(ScrollAnimState::Closing(p)) => p.clamp(0.0, 1.0),
|
||||
Some(ScrollAnimState::Done) | None => return,
|
||||
};
|
||||
if anim <= 0.0 {
|
||||
state.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);
|
||||
|
||||
// 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 too.
|
||||
let actual_h = ((outer_h as f32 * anim).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 centered; others are left-aligned.
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let mut choice_letter = b'a';
|
||||
for item in &self.scroll.lines {
|
||||
match item {
|
||||
ScrollLine::Text(s) => {
|
||||
let text = s.trim();
|
||||
let line = Line::styled(text.to_string(), text_style);
|
||||
lines.push(if s.starts_with(|c: char| c.is_ascii_whitespace()) {
|
||||
line.centered()
|
||||
} else {
|
||||
line
|
||||
});
|
||||
}
|
||||
ScrollLine::Choice { display, .. } => {
|
||||
let key = choice_letter as char;
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("[{key}] "), key_style),
|
||||
Span::styled(display.clone(), choice_style),
|
||||
]));
|
||||
choice_letter += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dim the whole background, then use Fill to paint the overlay area with solid
|
||||
// background-colored spaces so board glyphs can'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);
|
||||
|
||||
// Render the bordered box, then split inner: text on the left, scrollbar on the right.
|
||||
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 using the narrower content_area width, clamp
|
||||
// scroll so content can't be pushed out of view.
|
||||
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
|
||||
let content_lines = para.line_count(content_area.width) as u16;
|
||||
let max_scroll = content_lines.saturating_sub(content_area.height);
|
||||
let clamped = state.offset.min(max_scroll);
|
||||
let para = para.scroll((clamped, 0));
|
||||
|
||||
// Center content vertically when it's shorter than the window.
|
||||
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);
|
||||
para.render(content_rect, buf);
|
||||
|
||||
// Scrollbar — only shown when content exceeds the visible height.
|
||||
// content_length is set to max_scroll + 1 so that position 0 maps to the top
|
||||
// of the track and position max_scroll maps exactly to the bottom. Using the
|
||||
// full content_lines as content_length would leave the thumb short of the
|
||||
// bottom at max scroll because ratatui's thumb formula is based on
|
||||
// (content_length - 1) as the maximum position.
|
||||
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);
|
||||
}
|
||||
|
||||
state.max_scroll = max_scroll;
|
||||
render_scroll_at(self.lines, 1.0, area, buf, Some(state));
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a scroll overlay with an animated reveal.
|
||||
///
|
||||
/// Progresses from 0.0 (invisible) to 1.0 (fully open). When complete,
|
||||
/// [`input_mode_after`](Animation::input_mode_after) returns [`InputMode::Scroll`]
|
||||
/// so the player can interact with the now-visible overlay.
|
||||
pub struct ScrollOpenAnimation {
|
||||
/// Snapshot of the scroll lines (for rendering during the animation).
|
||||
lines: Vec<ScrollLine>,
|
||||
/// Animation progress: 0.0 = hidden, 1.0 = fully open.
|
||||
progress: f32,
|
||||
}
|
||||
|
||||
impl ScrollOpenAnimation {
|
||||
/// Creates a new open animation from a snapshot of the scroll's lines.
|
||||
pub fn new(lines: Vec<ScrollLine>) -> Self {
|
||||
Self { lines, progress: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Animation for ScrollOpenAnimation {
|
||||
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_scroll_at(&self.lines, self.progress.clamp(0.0, 1.0), area, buf, None);
|
||||
}
|
||||
|
||||
fn layer(&self) -> AnimationLayer { AnimationLayer::Overlay }
|
||||
|
||||
fn input_mode_after(&self) -> InputMode { InputMode::Scroll }
|
||||
}
|
||||
|
||||
/// Closes a scroll overlay with an animated collapse.
|
||||
///
|
||||
/// Progresses from 1.0 (fully open) to 0.0 (invisible). When complete,
|
||||
/// [`input_mode_after`](Animation::input_mode_after) returns [`InputMode::Board`]
|
||||
/// and the engine's [`handle_scroll`](kiln_core::game::GameState::handle_scroll)
|
||||
/// will dispatch any pending choice on the next tick.
|
||||
pub struct ScrollCloseAnimation {
|
||||
/// Snapshot of the scroll lines (for rendering during the animation).
|
||||
lines: Vec<ScrollLine>,
|
||||
/// Animation progress: 1.0 = fully open, 0.0 = hidden.
|
||||
progress: f32,
|
||||
}
|
||||
|
||||
impl ScrollCloseAnimation {
|
||||
/// Creates a new close animation from a snapshot of the scroll's lines.
|
||||
pub fn new(lines: Vec<ScrollLine>) -> Self {
|
||||
Self { lines, progress: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Animation for ScrollCloseAnimation {
|
||||
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_scroll_at(&self.lines, self.progress.clamp(0.0, 1.0), area, buf, None);
|
||||
}
|
||||
|
||||
fn layer(&self) -> AnimationLayer { AnimationLayer::Overlay }
|
||||
|
||||
fn input_mode_after(&self) -> InputMode { InputMode::Board }
|
||||
}
|
||||
|
||||
/// Shared rendering for the scroll overlay at a given animation progress.
|
||||
///
|
||||
/// `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).
|
||||
fn render_scroll_at(
|
||||
lines: &[ScrollLine],
|
||||
progress: f32,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
state: Option<&mut ScrollOverlayState>,
|
||||
) {
|
||||
if progress <= 0.0 {
|
||||
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);
|
||||
|
||||
// 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.
|
||||
let mut ratatui_lines: Vec<Line> = Vec::new();
|
||||
let mut choice_letter = b'a';
|
||||
for item in lines {
|
||||
match item {
|
||||
ScrollLine::Text(s) => {
|
||||
let text = s.trim();
|
||||
let line = Line::styled(text.to_string(), text_style);
|
||||
ratatui_lines.push(if s.starts_with(|c: char| c.is_ascii_whitespace()) {
|
||||
line.centered()
|
||||
} else {
|
||||
line
|
||||
});
|
||||
}
|
||||
ScrollLine::Choice { display, .. } => {
|
||||
let key = choice_letter as char;
|
||||
ratatui_lines.push(Line::from(vec![
|
||||
Span::styled(format!("[{key}] "), key_style),
|
||||
Span::styled(display.clone(), choice_style),
|
||||
]));
|
||||
choice_letter += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
let max_scroll = content_lines.saturating_sub(content_area.height);
|
||||
let clamped = offset.min(max_scroll);
|
||||
let para = para.scroll((clamped, 0));
|
||||
|
||||
// Center content vertically when it's shorter than the window.
|
||||
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);
|
||||
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(s) = state { s.max_scroll = max_scroll; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user