//! The scroll overlay widget, shown when a script calls `scroll()`. //! //! 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. use kiln_core::game::{Scroll, ScrollLine}; use ratatui::buffer::Buffer; 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, }; /// 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 { /// The current scroll offset (input — read during render). 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, /// 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, } 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 / 0.5; if *p >= 1.0 { self.anim = Some(ScrollAnimState::Open); } } Some(ScrollAnimState::Closing(p)) => { *p -= dt / 0.5; 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 { self.pending_choice.take() } } /// A ratatui widget that draws the full-screen 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. pub struct ScrollOverlayWidget<'a> { /// The scroll content to display. pub scroll: &'a Scroll, } impl<'a> ScrollOverlayWidget<'a> { /// Creates a widget for the given scroll content. pub fn new(scroll: &'a Scroll) -> Self { Self { scroll } } } 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 = 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; } }