Files
kiln/kiln-tui/src/scroll_overlay.rs
T
2026-06-11 21:31:37 -05:00

215 lines
9.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;
}
}