215 lines
9.1 KiB
Rust
215 lines
9.1 KiB
Rust
//! 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<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 / 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<String> {
|
||
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<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;
|
||
}
|
||
}
|