This commit is contained in:
2026-06-11 18:53:52 -05:00
parent f73c02437d
commit a1cce10432
7 changed files with 327 additions and 126 deletions
+1 -1
View File
@@ -6,4 +6,4 @@ edition = "2024"
[dependencies]
kiln-core = { path = "../kiln-core" }
color = "0.3.3"
ratatui = "0.30"
ratatui = { version = "0.30.1", features = ["unstable-rendered-line-info"] }
+22 -6
View File
@@ -53,6 +53,8 @@ struct Ui {
scroll: u16,
/// Vertical scroll offset within an active scroll overlay.
scroll_offset: u16,
/// Maximum valid value for `scroll_offset`; updated each frame by the renderer.
scroll_max: u16,
/// Animation state for the scroll overlay; `None` when no overlay is active.
scroll_anim: Option<ScrollAnimState>,
}
@@ -64,11 +66,20 @@ impl Default for Ui {
log_height: 10,
scroll: 0,
scroll_offset: 0,
scroll_max: 0,
scroll_anim: None,
}
}
}
impl Ui {
/// Scrolls the overlay by `delta` lines, clamped to the valid range.
fn scroll_lines(&mut self, delta: i32) {
self.scroll_offset = (self.scroll_offset as i32 + delta)
.clamp(0, self.scroll_max as i32) as u16;
}
}
/// Starts the scroll-close animation with the optional player choice.
/// Uses the current opening progress if the overlay was still animating in.
fn begin_close(ui: &mut Ui, choice: Option<String>) {
@@ -197,8 +208,8 @@ fn run(
// Esc dismisses the scroll only when there are no choices;
// with choices present the player must select one.
KeyCode::Esc if !has_choices => begin_close(ui, None),
KeyCode::Up => ui.scroll_offset = ui.scroll_offset.saturating_sub(1),
KeyCode::Down => ui.scroll_offset = ui.scroll_offset.saturating_add(1),
KeyCode::Up => ui.scroll_lines(-1),
KeyCode::Down => ui.scroll_lines(1),
KeyCode::Char(c) => {
if let Some(ch) = resolve_choice(&scroll.lines, c) {
begin_close(ui, Some(ch));
@@ -233,8 +244,8 @@ fn run(
&& !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing { .. }));
if scroll_active {
match m.kind {
MouseEventKind::ScrollUp => ui.scroll_offset = ui.scroll_offset.saturating_sub(1),
MouseEventKind::ScrollDown => ui.scroll_offset = ui.scroll_offset.saturating_add(1),
MouseEventKind::ScrollUp => ui.scroll_lines(-1),
MouseEventKind::ScrollDown => ui.scroll_lines(1),
_ => {}
}
} else if ui.log_open {
@@ -278,6 +289,7 @@ fn run(
game.close_scroll(ch.as_deref());
ui.scroll_anim = None;
ui.scroll_offset = 0;
ui.scroll_max = 0;
}
}
_ => {}
@@ -306,7 +318,11 @@ fn scroll_log(ui: &mut Ui, game: &GameState, delta: i32) {
/// panel across the bottom. When the panel is closed the latest log message is
/// previewed in the board's bottom-left border after a `[l]og:` label.
/// A scroll overlay is drawn on top of everything when active.
fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) {
///
/// TODO: `draw` mutates `ui.scroll_max` as a side-effect of rendering. This is a wart —
/// ideally render functions are pure and all state lives in the game loop. The max scroll
/// should be computed independently of drawing and stored before the draw call.
fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) {
// Borrow the board for the duration of the frame (no script runs during draw).
let board = game.board();
let board = &*board;
@@ -360,7 +376,7 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) {
};
// Capture area before the mutable buffer borrow to satisfy the borrow checker.
let area = frame.area();
render::draw_scroll_overlay(frame.buffer_mut(), area, scroll, ui.scroll_offset, anim);
ui.scroll_max = render::draw_scroll_overlay(frame.buffer_mut(), area, scroll, ui.scroll_offset, anim);
}
}
+88 -78
View File
@@ -11,10 +11,11 @@ use kiln_core::Board;
use kiln_core::game::{Scroll, ScrollLine, SpeechBubble};
use kiln_core::log::LogLine;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use ratatui::widgets::{Block, BorderType, Fill, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, StatefulWidget, Widget, Wrap};
/// Converts a core [`Rgba8`] color into a ratatui truecolor [`Color`].
///
@@ -190,32 +191,21 @@ pub fn draw_speech_bubbles(buf: &mut Buffer, area: Rect, board: &Board, bubbles:
placed.push(Rect { x: left, y: top, width: box_w, height: box_h });
// Row 0: ╭──...──╮
write_cell(buf, left, top, '╭', border_style);
for cx in (left + 1)..(left + box_w - 1) {
write_cell(buf, cx, top, '─', border_style);
}
write_cell(buf, left + box_w - 1, top, '╮', border_style);
// Render the rounded box (3 rows: top border, text, bottom border) with ratatui.
let box_rect = Rect { x: left, y: top, width: box_w, height: 3 };
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(border_style)
.style(bubble_style);
let text_rect = block.inner(box_rect);
block.render(box_rect, buf);
Paragraph::new(Line::styled(format!(" {}", bubble.text), bubble_style))
.render(text_rect, buf);
// Row 1: │ text │ (pad text to box_w - 2 inner width)
let inner_w = (box_w - 2) as usize;
let padded: String = format!(" {:<width$} ", bubble.text, width = inner_w - 2);
write_cell(buf, left, top + 1, '│', border_style);
for (i, ch) in padded.chars().enumerate() {
write_cell(buf, left + 1 + i as u16, top + 1, ch, bubble_style);
}
write_cell(buf, left + box_w - 1, top + 1, '│', border_style);
// Row 2: ╰──...─╮─...──╯ with the tail join at obj_sx (clamped inside the box)
// Overwrite the tail-join cell on the bottom border, then draw the tail itself.
// These are the only two cells that ratatui's Block can't produce for us.
let tail_col = obj_sx.clamp(left + 1, left + box_w - 2);
write_cell(buf, left, top + 2, '', border_style);
for cx in (left + 1)..(left + box_w - 1) {
let ch = if cx == tail_col { '╮' } else { '─' };
write_cell(buf, cx, top + 2, ch, border_style);
}
write_cell(buf, left + box_w - 1, top + 2, '╯', border_style);
// Row 3: vertical tail descending to the object
write_cell(buf, tail_col, top + 2, '', border_style);
write_cell(buf, tail_col, top + 3, '│', border_style);
}
}
@@ -237,14 +227,17 @@ fn write_cell(buf: &mut Buffer, x: u16, y: u16, ch: char, style: Style) {
/// Draws a scrollable text/choice overlay centered on `area`, used for `scroll()` calls.
///
/// The overlay dims the entire background, then renders a `Block`-bordered window
/// whose height animates from 0 → full (opening) or full → 0 (closing) based on
/// `anim` (0.0 = invisible, 1.0 = fully open). Text is pre-wrapped so the exact
/// line count is known for the animation; a `Paragraph` renders the content.
/// Choice lines show an `[a]` / `[b]` key prefix in yellow. `offset` scrolls the
/// content vertically.
pub fn draw_scroll_overlay(buf: &mut Buffer, area: Rect, scroll: &Scroll, offset: u16, anim: f32) {
if anim <= 0.0 { return; }
/// Returns the maximum valid scroll offset (0 when all content fits). The caller should
/// store this and use it to clamp `offset` on future frames.
///
/// The overlay dims the entire background, then renders a `Block`-bordered window.
/// Width is half the screen; height is 3/4 the screen rounded down to an even number
/// so the open/close animation (0.0 → 1.0) grows by 2 rows at a time from the center.
/// If the content is shorter than the window, it is vertically centered via `Layout`.
/// Text lines whose source starts with whitespace are rendered horizontally centered
/// (whitespace stripped); all other lines are left-aligned. `offset` scrolls content.
pub fn draw_scroll_overlay(buf: &mut Buffer, area: Rect, scroll: &Scroll, offset: u16, anim: f32) -> u16 {
if anim <= 0.0 { return 0; }
let bg = Color::Rgb(30, 25, 10);
let text_style = Style::default().fg(Color::White).bg(bg);
@@ -252,23 +245,37 @@ pub fn draw_scroll_overlay(buf: &mut Buffer, area: Rect, scroll: &Scroll, offset
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);
// Pre-wrap text so we know the total line count for the animation height.
// max_text_w is the usable text width inside the block (border + 1 pad each side).
let max_text_w = (area.width as usize).saturating_sub(6).clamp(10, 60);
// 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 &scroll.lines {
match item {
ScrollLine::Text(s) => {
for wrapped in wrap_text(s, max_text_w) {
// Leading space provides left-padding inside the border.
lines.push(Line::styled(format!(" {wrapped}"), text_style));
}
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(format!("[{key}] "), key_style),
Span::styled(display.clone(), choice_style),
]));
choice_letter += 1;
@@ -276,20 +283,8 @@ pub fn draw_scroll_overlay(buf: &mut Buffer, area: Rect, scroll: &Scroll, offset
}
}
// Derive overlay dimensions. Width fits the longest line; height animates.
let content_w = lines.iter().map(|l| l.width()).max().unwrap_or(0).min(max_text_w);
let outer_w = (content_w + 4) as u16; // border (1) + pad (1) each side = +4 total
let full_outer_h = (lines.len() + 2) as u16; // +2 for top/bottom border rows
let max_outer_h = ((area.height as f32 * 0.85) as u16).max(4);
let actual_outer_h = ((full_outer_h as f32 * anim).ceil() as u16)
.min(max_outer_h)
.max(1);
let left = area.x + area.width.saturating_sub(outer_w) / 2;
let top = area.y + area.height.saturating_sub(actual_outer_h) / 2;
let overlay_rect = Rect { x: left, y: top, width: outer_w, height: actual_outer_h };
// Dim the background — no ratatui widget does this, so it stays manual.
// 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() {
@@ -298,32 +293,47 @@ pub fn draw_scroll_overlay(buf: &mut Buffer, area: Rect, scroll: &Scroll, offset
}
}
}
Fill::new(" ").style(Style::default().bg(bg)).render(overlay_rect, buf);
// Render the bordered box, then the scrollable content inside it.
// 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);
Paragraph::new(lines).scroll((offset, 0)).render(inner, buf);
}
/// Wraps `text` at word boundaries so no line exceeds `max_width` characters.
/// Used to pre-compute line count for the scroll overlay's animated height.
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
if max_width == 0 { return vec![text.to_string()]; }
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
if current.is_empty() {
current.push_str(word);
} else if current.len() + 1 + word.len() <= max_width {
current.push(' ');
current.push_str(word);
} else {
lines.push(std::mem::take(&mut current));
current.push_str(word);
}
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 = 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);
}
if !current.is_empty() { lines.push(current); }
if lines.is_empty() { lines.push(String::new()); }
lines
max_scroll
}