//! The scroll overlay widget and its open/close animations. //! //! [`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::ScrollLine; 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, 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 { /// The current scroll offset (input — read during render). pub offset: u16, /// The maximum valid scroll offset (output — written during render). pub max_scroll: u16, } /// A ratatui widget that draws the fully-open scroll overlay. /// /// 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 lines: &'a [ScrollLine], } impl<'a> ScrollOverlayWidget<'a> { /// Creates a widget for the given scroll lines. pub fn new(lines: &'a [ScrollLine]) -> Self { Self { lines } } } impl StatefulWidget for ScrollOverlayWidget<'_> { type State = ScrollOverlayState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut ScrollOverlayState) { 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, /// 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) -> 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, /// 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) -> 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 } } /// Renders the scroll overlay at a given animation progress using the shared /// overlay frame, then fills the content area with the scroll lines. /// /// 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, area: Rect, buf: &mut Buffer, state: Option<&mut ScrollOverlayState>, ) { 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 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); // 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 { 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; } } } 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; } }