Files
kiln/kiln-tui/src/scroll_overlay.rs
T

207 lines
7.3 KiB
Rust
Raw Normal View History

2026-06-15 09:15:30 -05:00
//! The scroll overlay widget and its open/close animations.
2026-06-11 21:31:37 -05:00
//!
2026-06-15 09:15:30 -05:00
//! [`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.
2026-06-11 21:31:37 -05:00
2026-06-15 09:15:30 -05:00
use kiln_core::game::ScrollLine;
2026-06-11 21:31:37 -05:00
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
2026-06-15 10:42:21 -05:00
Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget, Wrap,
2026-06-11 21:31:37 -05:00
};
2026-06-15 09:15:30 -05:00
use crate::animation::{Animation, AnimationLayer};
use crate::input::InputMode;
2026-06-15 10:42:21 -05:00
use crate::overlay::{OverlayStyle, render_overlay_frame};
2026-06-11 21:31:37 -05:00
2026-06-11 22:17:46 -05:00
/// Duration of the open and close animations, in seconds.
const ANIM_SECS: f32 = 0.25;
2026-06-15 10:42:21 -05:00
/// 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,
};
2026-06-11 21:31:37 -05:00
/// 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,
}
2026-06-15 09:15:30 -05:00
/// 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 }
2026-06-11 21:31:37 -05:00
}
2026-06-15 09:15:30 -05:00
}
2026-06-11 21:31:37 -05:00
2026-06-15 09:15:30 -05:00
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));
2026-06-11 21:31:37 -05:00
}
2026-06-15 09:15:30 -05:00
}
/// 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,
}
2026-06-11 21:31:37 -05:00
2026-06-15 09:15:30 -05:00
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 }
2026-06-11 21:31:37 -05:00
}
}
2026-06-15 09:15:30 -05:00
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.
2026-06-11 21:31:37 -05:00
///
2026-06-15 09:15:30 -05:00
/// 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,
2026-06-11 21:31:37 -05:00
}
2026-06-15 09:15:30 -05:00
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 }
2026-06-11 21:31:37 -05:00
}
}
2026-06-15 09:15:30 -05:00
impl Animation for ScrollCloseAnimation {
fn update(&mut self, dt: f32) -> bool {
self.progress -= dt / ANIM_SECS;
self.progress <= 0.0
}
2026-06-11 21:31:37 -05:00
2026-06-15 09:15:30 -05:00
fn draw(&self, area: Rect, buf: &mut Buffer) {
render_scroll_at(&self.lines, self.progress.clamp(0.0, 1.0), area, buf, None);
}
2026-06-11 21:31:37 -05:00
2026-06-15 09:15:30 -05:00
fn layer(&self) -> AnimationLayer { AnimationLayer::Overlay }
fn input_mode_after(&self) -> InputMode { InputMode::Board }
}
2026-06-15 10:42:21 -05:00
/// Renders the scroll overlay at a given animation progress using the shared
/// overlay frame, then fills the content area with the scroll lines.
2026-06-15 09:15:30 -05:00
///
2026-06-15 10:42:21 -05:00
/// When `state` is `Some`, `max_scroll` is written out so the caller can clamp
/// future scroll-input (only needed when fully open and interactive).
2026-06-15 09:15:30 -05:00
fn render_scroll_at(
lines: &[ScrollLine],
progress: f32,
area: Rect,
buf: &mut Buffer,
state: Option<&mut ScrollOverlayState>,
) {
2026-06-15 10:42:21 -05:00
let Some((content_area, scrollbar_area)) =
render_overlay_frame(&SCROLL_STYLE, progress, area, buf)
else {
2026-06-15 09:15:30 -05:00
if let Some(s) = state { s.max_scroll = 0; }
return;
2026-06-15 10:42:21 -05:00
};
2026-06-15 09:15:30 -05:00
2026-06-15 10:42:21 -05:00
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);
2026-06-15 09:15:30 -05:00
2026-06-15 10:42:21 -05:00
// Build Lines — whitespace-leading lines are centered, others left-aligned.
2026-06-15 09:15:30 -05:00
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;
2026-06-11 21:31:37 -05:00
}
}
2026-06-15 09:15:30 -05:00
}
2026-06-11 21:31:37 -05:00
2026-06-15 09:15:30 -05:00
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; }
2026-06-11 21:31:37 -05:00
}