//! 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; use kiln_core::Board; use kiln_core::game::SpeechBubble; use kiln_core::log::LogLine; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Widget; /// 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) } /// 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::>(); Line::from(spans) } /// 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. pub fn axis(board_len: usize, view: usize, player: i32) -> (usize, u16, usize) { 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; let glyph = board.glyph_at(bx, by); // 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)); } } } } } /// 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 = 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 }); // 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); // Row 1: │ text │ (pad text to box_w - 2 inner width) let inner_w = (box_w - 2) as usize; let padded: String = format!(" {: 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); } }