scrolls
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user