2026-06-03 20:10:02 -05:00
|
|
|
//! Rendering a kiln [`Board`] into a ratatui buffer as colored text.
|
|
|
|
|
//!
|
|
|
|
|
//! Each board cell becomes one terminal cell: the glyph's tile index is mapped
|
|
|
|
|
//! to a character (see [`crate::cp437`]) and drawn with the glyph's foreground
|
|
|
|
|
//! and background colors. Objects are drawn over their floor cell, and the
|
|
|
|
|
//! player is drawn on top of everything.
|
|
|
|
|
|
|
|
|
|
use crate::cp437::tile_to_char;
|
|
|
|
|
use color::Rgba8;
|
2026-06-07 00:19:53 -05:00
|
|
|
use kiln_core::Board;
|
2026-06-10 01:42:33 -05:00
|
|
|
use kiln_core::game::{Scroll, ScrollLine, SpeechBubble};
|
2026-06-03 22:46:54 -05:00
|
|
|
use kiln_core::log::LogLine;
|
2026-06-03 20:10:02 -05:00
|
|
|
use ratatui::buffer::Buffer;
|
2026-06-11 18:53:52 -05:00
|
|
|
use ratatui::layout::{Constraint, Layout, Rect};
|
2026-06-03 22:46:54 -05:00
|
|
|
use ratatui::style::{Color, Style};
|
|
|
|
|
use ratatui::text::{Line, Span};
|
2026-06-11 18:53:52 -05:00
|
|
|
use ratatui::widgets::{Block, BorderType, Fill, Paragraph, Scrollbar, ScrollbarOrientation,
|
|
|
|
|
ScrollbarState, StatefulWidget, Widget, Wrap};
|
2026-06-03 20:10:02 -05:00
|
|
|
|
|
|
|
|
/// Converts a core [`Rgba8`] color into a ratatui truecolor [`Color`].
|
|
|
|
|
///
|
|
|
|
|
/// The alpha channel is dropped — terminals have no per-cell alpha. Truecolor
|
|
|
|
|
/// requires terminal support; terminals limited to 256 colors approximate it.
|
|
|
|
|
fn rgba8_to_color(c: Rgba8) -> Color {
|
|
|
|
|
Color::Rgb(c.r, c.g, c.b)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 22:46:54 -05:00
|
|
|
/// Converts a core [`LogLine`] into a styled ratatui [`Line`] for display.
|
|
|
|
|
///
|
|
|
|
|
/// Each [`LogSpan`](kiln_core::log::LogSpan) becomes a ratatui [`Span`]; a span's
|
|
|
|
|
/// foreground/background color is applied only when present, so `None` colors
|
|
|
|
|
/// inherit the surrounding (default) style.
|
|
|
|
|
pub fn logline_to_line(line: &LogLine) -> Line<'static> {
|
|
|
|
|
let spans = line
|
|
|
|
|
.spans
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|s| {
|
|
|
|
|
let mut style = Style::default();
|
|
|
|
|
if let Some(fg) = s.fg {
|
|
|
|
|
style = style.fg(rgba8_to_color(fg));
|
|
|
|
|
}
|
|
|
|
|
if let Some(bg) = s.bg {
|
|
|
|
|
style = style.bg(rgba8_to_color(bg));
|
|
|
|
|
}
|
|
|
|
|
Span::styled(s.text.clone(), style)
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
Line::from(spans)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 20:10:02 -05:00
|
|
|
/// A ratatui widget that draws a [`Board`] (with objects and the player) into a
|
|
|
|
|
/// rectangular area, scrolled so the player stays visible on larger boards.
|
|
|
|
|
pub struct BoardWidget<'a> {
|
|
|
|
|
/// The board to render. Borrowed for the duration of the draw.
|
|
|
|
|
pub board: &'a Board,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<'a> BoardWidget<'a> {
|
|
|
|
|
/// Creates a widget that renders `board`.
|
|
|
|
|
pub fn new(board: &'a Board) -> Self {
|
|
|
|
|
Self { board }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Lays out one axis of the board within a viewport `view` cells long.
|
|
|
|
|
///
|
|
|
|
|
/// Returns `(off, pad, count)` where `off` is the first board cell to draw,
|
|
|
|
|
/// `pad` is the blank margin (in screen cells) before the board starts, and
|
|
|
|
|
/// `count` is how many board cells are visible:
|
|
|
|
|
///
|
|
|
|
|
/// - When the board fits (`board_len <= view`) it is **centered**: `off` is
|
|
|
|
|
/// 0, `pad` splits the leftover space, and the whole board is drawn.
|
|
|
|
|
/// - When the board is larger it **scrolls** to follow the player: `pad` is
|
|
|
|
|
/// 0, `off` centers on the player clamped into `[0, board_len - view]`, and
|
|
|
|
|
/// exactly `view` cells are shown — never any empty space past the edge.
|
2026-06-08 22:15:44 -05:00
|
|
|
pub fn axis(board_len: usize, view: usize, player: i32) -> (usize, u16, usize) {
|
2026-06-03 20:10:02 -05:00
|
|
|
if board_len <= view {
|
|
|
|
|
let pad = (view - board_len) / 2;
|
|
|
|
|
(0, pad as u16, board_len)
|
|
|
|
|
} else {
|
|
|
|
|
let half = (view / 2) as i32;
|
|
|
|
|
let off = player
|
|
|
|
|
.saturating_sub(half)
|
|
|
|
|
.clamp(0, (board_len - view) as i32) as usize;
|
|
|
|
|
(off, 0, view)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Widget for BoardWidget<'_> {
|
|
|
|
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
|
|
|
|
let board = self.board;
|
|
|
|
|
// Resolve each axis independently: a small board is centered, a large
|
|
|
|
|
// one scrolls to keep the player on screen.
|
|
|
|
|
let (off_x, pad_x, cols) = Self::axis(board.width, area.width as usize, board.player.x);
|
|
|
|
|
let (off_y, pad_y, rows) = Self::axis(board.height, area.height as usize, board.player.y);
|
|
|
|
|
|
|
|
|
|
// Walk the visible board cells, placing each after the centering pad.
|
|
|
|
|
for row in 0..rows {
|
|
|
|
|
let by = off_y + row;
|
|
|
|
|
let sy = area.y + pad_y + row as u16;
|
|
|
|
|
for col in 0..cols {
|
|
|
|
|
let bx = off_x + col;
|
|
|
|
|
let sx = area.x + pad_x + col as u16;
|
|
|
|
|
|
2026-06-07 00:19:53 -05:00
|
|
|
let glyph = board.glyph_at(bx, by);
|
2026-06-03 20:10:02 -05:00
|
|
|
|
|
|
|
|
// Paint the resolved glyph into the terminal cell.
|
|
|
|
|
if let Some(cell) = buf.cell_mut((sx, sy)) {
|
|
|
|
|
cell.set_char(tile_to_char(glyph.tile))
|
|
|
|
|
.set_fg(rgba8_to_color(glyph.fg))
|
|
|
|
|
.set_bg(rgba8_to_color(glyph.bg));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-08 22:15:44 -05:00
|
|
|
|
|
|
|
|
/// Converts a board cell coordinate to terminal screen coordinates within `area`,
|
|
|
|
|
/// accounting for centering/scrolling. Returns `None` if the cell is off-screen.
|
|
|
|
|
pub fn board_screen_pos(area: Rect, board: &Board, bx: usize, by: usize) -> Option<(u16, u16)> {
|
|
|
|
|
let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x);
|
|
|
|
|
let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, board.player.y);
|
|
|
|
|
if bx < off_x || bx >= off_x + cols { return None; }
|
|
|
|
|
if by < off_y || by >= off_y + rows { return None; }
|
|
|
|
|
let sx = area.x + pad_x + (bx - off_x) as u16;
|
|
|
|
|
let sy = area.y + pad_y + (by - off_y) as u16;
|
|
|
|
|
Some((sx, sy))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Draws speech bubbles for all active [`SpeechBubble`]s over the board area.
|
|
|
|
|
///
|
|
|
|
|
/// Each bubble appears as a box-drawing frame with a tail pointing down at the
|
|
|
|
|
/// speaker's cell. Bubbles that would collide (same horizontal range, adjacent
|
|
|
|
|
/// vertically) are shifted upward one at a time until there is no overlap.
|
|
|
|
|
pub fn draw_speech_bubbles(buf: &mut Buffer, area: Rect, board: &Board, bubbles: &[SpeechBubble]) {
|
|
|
|
|
// Determine the screen position of each visible bubble's speaker.
|
|
|
|
|
let mut items: Vec<(u16, u16, &SpeechBubble)> = bubbles
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|b| {
|
|
|
|
|
let obj = board.objects.get(&b.object_id)?;
|
|
|
|
|
let (sx, sy) = board_screen_pos(area, board, obj.x, obj.y)?;
|
|
|
|
|
Some((sx, sy, b))
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
// Process bubbles lowest on screen first so upward-shift doesn't interfere
|
|
|
|
|
// with already-placed higher bubbles.
|
|
|
|
|
items.sort_by_key(|&(_, sy, _)| std::cmp::Reverse(sy));
|
|
|
|
|
|
|
|
|
|
// Track the rects of already-placed bubbles to detect collisions.
|
|
|
|
|
let mut placed: Vec<Rect> = Vec::new();
|
|
|
|
|
|
|
|
|
|
let bubble_style = Style::default()
|
|
|
|
|
.fg(Color::White)
|
|
|
|
|
.bg(Color::Rgb(20, 20, 40));
|
|
|
|
|
let border_style = Style::default()
|
|
|
|
|
.fg(Color::Rgb(160, 160, 220))
|
|
|
|
|
.bg(Color::Rgb(20, 20, 40));
|
|
|
|
|
|
|
|
|
|
for (obj_sx, obj_sy, bubble) in items {
|
|
|
|
|
let text_w = bubble.text.chars().count() as u16;
|
|
|
|
|
// Box: ╭ space text space ╮ → width = text + 4
|
|
|
|
|
let box_w = text_w + 4;
|
|
|
|
|
// 4 rows: top border, text, bottom border, tail
|
|
|
|
|
let box_h: u16 = 4;
|
|
|
|
|
|
|
|
|
|
// Center the box over the object; clamp to area bounds.
|
|
|
|
|
let raw_left = obj_sx.saturating_sub(box_w / 2);
|
|
|
|
|
let left = raw_left.min(area.right().saturating_sub(box_w));
|
|
|
|
|
let left = left.max(area.left());
|
|
|
|
|
|
|
|
|
|
// Desired top: 4 rows above the object (rows: top, text, bottom, tail → speaker).
|
|
|
|
|
let desired_top = obj_sy.saturating_sub(box_h);
|
|
|
|
|
|
|
|
|
|
// Shift upward until no overlap with already-placed bubbles.
|
|
|
|
|
let mut top = desired_top;
|
|
|
|
|
let mut iterations = 0u16;
|
|
|
|
|
'place: loop {
|
|
|
|
|
if iterations > 20 || top < area.top() { break; }
|
|
|
|
|
let candidate = Rect { x: left, y: top, width: box_w, height: box_h };
|
|
|
|
|
for p in &placed {
|
|
|
|
|
if rects_overlap(candidate, *p) {
|
|
|
|
|
top = top.saturating_sub(1);
|
|
|
|
|
iterations += 1;
|
|
|
|
|
continue 'place;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if top < area.top() { continue; } // no room; skip this bubble
|
|
|
|
|
|
|
|
|
|
placed.push(Rect { x: left, y: top, width: box_w, height: box_h });
|
|
|
|
|
|
2026-06-11 18:53:52 -05:00
|
|
|
// 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);
|
|
|
|
|
|
|
|
|
|
// 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.
|
2026-06-08 22:15:44 -05:00
|
|
|
let tail_col = obj_sx.clamp(left + 1, left + box_w - 2);
|
2026-06-11 18:53:52 -05:00
|
|
|
write_cell(buf, tail_col, top + 2, '╮', border_style);
|
2026-06-08 22:15:44 -05:00
|
|
|
write_cell(buf, tail_col, top + 3, '│', border_style);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns true if two `Rect`s share at least one cell.
|
|
|
|
|
fn rects_overlap(a: Rect, b: Rect) -> bool {
|
|
|
|
|
a.x < b.x + b.width
|
|
|
|
|
&& b.x < a.x + a.width
|
|
|
|
|
&& a.y < b.y + b.height
|
|
|
|
|
&& b.y < a.y + a.height
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Writes a single styled character into the buffer at `(x, y)`.
|
|
|
|
|
fn write_cell(buf: &mut Buffer, x: u16, y: u16, ch: char, style: Style) {
|
|
|
|
|
if let Some(cell) = buf.cell_mut((x, y)) {
|
|
|
|
|
cell.set_char(ch).set_style(style);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-10 01:42:33 -05:00
|
|
|
|
|
|
|
|
/// Draws a scrollable text/choice overlay centered on `area`, used for `scroll()` calls.
|
|
|
|
|
///
|
2026-06-11 18:53:52 -05:00
|
|
|
/// 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; }
|
2026-06-10 01:42:33 -05:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-06-11 18:53:52 -05:00
|
|
|
// 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.
|
2026-06-10 01:42:33 -05:00
|
|
|
let mut lines: Vec<Line> = Vec::new();
|
|
|
|
|
let mut choice_letter = b'a';
|
|
|
|
|
for item in &scroll.lines {
|
|
|
|
|
match item {
|
|
|
|
|
ScrollLine::Text(s) => {
|
2026-06-11 18:53:52 -05:00
|
|
|
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
|
|
|
|
|
});
|
2026-06-10 01:42:33 -05:00
|
|
|
}
|
|
|
|
|
ScrollLine::Choice { display, .. } => {
|
|
|
|
|
let key = choice_letter as char;
|
|
|
|
|
lines.push(Line::from(vec![
|
2026-06-11 18:53:52 -05:00
|
|
|
Span::styled(format!("[{key}] "), key_style),
|
2026-06-10 01:42:33 -05:00
|
|
|
Span::styled(display.clone(), choice_style),
|
|
|
|
|
]));
|
|
|
|
|
choice_letter += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 18:53:52 -05:00
|
|
|
// Dim the whole background, then use Fill to paint the overlay area with solid
|
|
|
|
|
// background-colored spaces so board glyphs can't show through.
|
2026-06-10 01:42:33 -05:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-11 18:53:52 -05:00
|
|
|
Fill::new(" ").style(Style::default().bg(bg)).render(overlay_rect, buf);
|
2026-06-10 01:42:33 -05:00
|
|
|
|
2026-06-11 18:53:52 -05:00
|
|
|
// Render the bordered box, then split inner: text on the left, scrollbar on the right.
|
2026-06-10 01:42:33 -05:00
|
|
|
let block = Block::bordered().border_style(border_style).style(Style::default().bg(bg));
|
|
|
|
|
let inner = block.inner(overlay_rect);
|
|
|
|
|
block.render(overlay_rect, buf);
|
|
|
|
|
|
2026-06-11 18:53:52 -05:00
|
|
|
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);
|
2026-06-10 01:42:33 -05:00
|
|
|
}
|
2026-06-11 18:53:52 -05:00
|
|
|
|
|
|
|
|
max_scroll
|
2026-06-10 01:42:33 -05:00
|
|
|
}
|