diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index 6f5f5d3..6baf74d 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -9,7 +9,10 @@ mod cp437; mod render; +mod scroll_overlay; mod term; +mod utils; +mod speech; use kiln_core::game::{GameState, ScrollLine}; use kiln_core::log::LogLine; @@ -25,22 +28,14 @@ use ratatui::style::{Color, Style}; use ratatui::symbols::merge::MergeStrategy; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Wrap}; -use render::{BoardWidget, logline_to_line}; +use render::{BoardWidget}; use std::io; use std::process::ExitCode; use std::time::{Duration, Instant}; use term::TerminalCaps; - -/// Animation state for the scroll overlay window. -enum ScrollAnimState { - /// Window is opening: progress goes from 0.0 → 1.0 over 0.5 s. - Opening(f32), - /// Window is fully open. - Open, - /// Window is closing: progress from 1.0 → 0.0 over 0.5 s. - /// When it reaches 0 the `choice` (if any) is dispatched and the overlay clears. - Closing { progress: f32, choice: Option }, -} +use crate::scroll_overlay::{ScrollAnimState, ScrollOverlayState, ScrollOverlayWidget}; +use crate::speech::SpeechBubblesWidget; +use crate::utils::logline_to_line; /// View-only UI state for the log panel and scroll overlay. This is presentation /// state, so it lives in the front-end rather than on the engine's [`GameState`]. @@ -51,12 +46,8 @@ struct Ui { log_height: u16, /// Vertical scroll offset of the log panel, in lines from the top (newest). 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, + /// Scroll position, bounds, and animation state for an active scroll overlay. + overlay: ScrollOverlayState, } impl Default for Ui { @@ -65,9 +56,7 @@ impl Default for Ui { log_open: false, log_height: 10, scroll: 0, - scroll_offset: 0, - scroll_max: 0, - scroll_anim: None, + overlay: ScrollOverlayState::default(), } } } @@ -75,19 +64,20 @@ impl Default for Ui { 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; + self.overlay.offset = (self.overlay.offset as i32 + delta) + .clamp(0, self.overlay.max_scroll 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) { - let progress = match &ui.scroll_anim { + let progress = match &ui.overlay.anim { Some(ScrollAnimState::Opening(p)) => p.min(1.0), _ => 1.0, }; - ui.scroll_anim = Some(ScrollAnimState::Closing { progress, choice }); + ui.overlay.pending_choice = choice; + ui.overlay.anim = Some(ScrollAnimState::Closing(progress)); } /// Returns the choice string for the given key character, or `None` if it doesn't @@ -199,7 +189,7 @@ fn run( // When a scroll overlay is active (and not already animating // closed), intercept all keys for scroll navigation/choices. let scroll_active = game.active_scroll.is_some() - && !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing { .. })); + && !matches!(ui.overlay.anim, Some(ScrollAnimState::Closing(_) | ScrollAnimState::Done)); if scroll_active { let scroll = game.active_scroll.as_ref().unwrap(); let has_choices = scroll.lines.iter() @@ -241,7 +231,7 @@ fn run( // wheel scrolls the log and drag resizes it. Event::Mouse(m) => { let scroll_active = game.active_scroll.is_some() - && !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing { .. })); + && !matches!(ui.overlay.anim, Some(ScrollAnimState::Closing(_) | ScrollAnimState::Done)); if scroll_active { match m.kind { MouseEventKind::ScrollUp => ui.scroll_lines(-1), @@ -276,31 +266,19 @@ fn run( // Advance scroll animation regardless of game pause state. let dt_s = dt.as_secs_f32(); - match &mut ui.scroll_anim { - Some(ScrollAnimState::Opening(p)) => { - *p += dt_s / 0.5; - if *p >= 1.0 { ui.scroll_anim = Some(ScrollAnimState::Open); } - } - Some(ScrollAnimState::Closing { progress, choice }) => { - *progress -= dt_s / 0.5; - if *progress <= 0.0 { - // Animation done — dispatch choice and clear the scroll. - let ch = choice.take(); - game.close_scroll(ch.as_deref()); - ui.scroll_anim = None; - ui.scroll_offset = 0; - ui.scroll_max = 0; - } - } - _ => {} + ui.overlay.advance_anim(dt_s); + if ui.overlay.close_finished() { + // Animation done — dispatch choice and clear the scroll. + game.close_scroll(ui.overlay.take_choice().as_deref()); + ui.overlay = ScrollOverlayState::default(); } // Tick the game only when no scroll overlay is blocking. if game.active_scroll.is_none() { game.tick(dt); // Detect a scroll just opened by the tick; start its open animation. - if game.active_scroll.is_some() && ui.scroll_anim.is_none() { - ui.scroll_anim = Some(ScrollAnimState::Opening(0.0)); + if game.active_scroll.is_some() && ui.overlay.anim.is_none() { + ui.overlay.anim = Some(ScrollAnimState::Opening(0.0)); } } } @@ -319,9 +297,6 @@ fn scroll_log(ui: &mut Ui, game: &GameState, delta: i32) { /// previewed in the board's bottom-left border after a `[l]og:` label. /// A scroll overlay is drawn on top of everything when active. /// -/// 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(); @@ -340,7 +315,7 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) { let inner = block.inner(board_area); frame.render_widget(block, board_area); frame.render_widget(BoardWidget::new(board), inner); - render::draw_speech_bubbles(frame.buffer_mut(), inner, board, &game.speech_bubbles); + frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner); // Newest message on top: reverse the log into ratatui lines. let log_block = Block::bordered() @@ -362,21 +337,14 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &mut Ui) { let inner = block.inner(frame.area()); frame.render_widget(block, frame.area()); frame.render_widget(BoardWidget::new(board), inner); - render::draw_speech_bubbles(frame.buffer_mut(), inner, board, &game.speech_bubbles); + frame.render_widget(SpeechBubblesWidget::new(board, &game.speech_bubbles), inner); } // Scroll overlay: drawn last so it sits above all other content. if let Some(scroll) = &game.active_scroll { - let anim = match &ui.scroll_anim { - Some(ScrollAnimState::Opening(p)) => p.clamp(0.0, 1.0), - Some(ScrollAnimState::Open) => 1.0, - Some(ScrollAnimState::Closing { progress, .. }) => progress.clamp(0.0, 1.0), - // Fallback: overlay exists but animation not set yet — show fully open. - None => 1.0, - }; // Capture area before the mutable buffer borrow to satisfy the borrow checker. let area = frame.area(); - ui.scroll_max = render::draw_scroll_overlay(frame.buffer_mut(), area, scroll, ui.scroll_offset, anim); + frame.render_stateful_widget(ScrollOverlayWidget::new(scroll), area, &mut ui.overlay); } } diff --git a/kiln-tui/src/render.rs b/kiln-tui/src/render.rs index 6af35e1..52f2330 100644 --- a/kiln-tui/src/render.rs +++ b/kiln-tui/src/render.rs @@ -6,47 +6,11 @@ //! player is drawn on top of everything. use crate::cp437::tile_to_char; -use color::Rgba8; use kiln_core::Board; -use kiln_core::game::{Scroll, ScrollLine, SpeechBubble}; -use kiln_core::log::LogLine; use ratatui::buffer::Buffer; -use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Color, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, BorderType, Fill, Paragraph, Scrollbar, ScrollbarOrientation, - ScrollbarState, StatefulWidget, Widget, Wrap}; - -/// 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) -} +use ratatui::layout::Rect; +use ratatui::widgets::Widget; +use crate::utils::rgba8_to_color; /// 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. @@ -115,225 +79,3 @@ impl Widget for BoardWidget<'_> { } } -/// 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 }); - - // 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. - let tail_col = obj_sx.clamp(left + 1, left + box_w - 2); - write_cell(buf, tail_col, top + 2, '╮', border_style); - 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); - } -} - -/// Draws a scrollable text/choice overlay centered on `area`, used for `scroll()` calls. -/// -/// 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); - 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); - - // 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 = Vec::new(); - let mut choice_letter = b'a'; - for item in &scroll.lines { - match item { - ScrollLine::Text(s) => { - 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(display.clone(), choice_style), - ])); - choice_letter += 1; - } - } - } - - // 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() { - if let Some(cell) = buf.cell_mut((x, y)) { - cell.set_style(dim_style); - } - } - } - Fill::new(" ").style(Style::default().bg(bg)).render(overlay_rect, buf); - - // 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); - - 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); - } - - max_scroll -} diff --git a/kiln-tui/src/scroll_overlay.rs b/kiln-tui/src/scroll_overlay.rs new file mode 100644 index 0000000..b66b591 --- /dev/null +++ b/kiln-tui/src/scroll_overlay.rs @@ -0,0 +1,214 @@ +//! The scroll overlay widget, shown when a script calls `scroll()`. +//! +//! Uses [`StatefulWidget`] rather than [`Widget`] because rendering computes +//! `max_scroll` (the highest valid scroll offset) as a side-effect that the +//! caller needs to clamp future scroll input. [`ScrollOverlayState`] carries +//! `offset` in and `max_scroll` out across the render call. + +use kiln_core::game::{Scroll, ScrollLine}; +use ratatui::buffer::Buffer; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{ + Block, Fill, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, + Widget, Wrap, +}; + +/// Animation state for the scroll overlay window. +#[derive(Default)] +pub enum ScrollAnimState { + /// Window is opening: progress goes from 0.0 → 1.0 over 0.5 s. + Opening(f32), + /// Window is fully open. + #[default] + Open, + /// Window is closing: progress from 1.0 → 0.0 over 0.5 s. + Closing(f32), + /// Close animation finished; the caller should dispatch + /// [`ScrollOverlayState::pending_choice`] and reset the state. + Done, +} + +/// 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, + /// Open/close animation state (input — read during render). + pub anim: Option, + /// The player's choice to dispatch when [`close_finished`](Self::close_finished) + /// is true. Set by the caller before starting a close animation. + pub pending_choice: Option, +} + +impl ScrollOverlayState { + /// Advances the open/close animation by `dt` seconds. + /// + /// When a closing animation completes, transitions `anim` to + /// [`ScrollAnimState::Done`]. Check [`close_finished`](Self::close_finished) + /// after calling this, then use [`take_choice`](Self::take_choice) to + /// retrieve the pending choice before resetting the state. + pub fn advance_anim(&mut self, dt: f32) { + match &mut self.anim { + Some(ScrollAnimState::Opening(p)) => { + *p += dt / 0.5; + if *p >= 1.0 { self.anim = Some(ScrollAnimState::Open); } + } + Some(ScrollAnimState::Closing(p)) => { + *p -= dt / 0.5; + if *p <= 0.0 { self.anim = Some(ScrollAnimState::Done); } + } + _ => {} + } + } + + /// Returns `true` when a closing animation has just finished and + /// [`pending_choice`](Self::pending_choice) is ready to be dispatched. + pub fn close_finished(&self) -> bool { + matches!(self.anim, Some(ScrollAnimState::Done)) + } + + /// Removes and returns the pending choice. Returns `None` if no choice + /// was queued (e.g. the player dismissed without selecting). + pub fn take_choice(&mut self) -> Option { + self.pending_choice.take() + } +} + +/// A ratatui widget that draws the full-screen scroll overlay. +/// +/// The overlay dims the background, then renders a `Block`-bordered window at +/// half screen width × 3/4 screen height. Height animates via `state.anim` +/// (0.0 → 1.0 = opening, 1.0 → 0.0 = closing), always staying even so each +/// step grows the window by exactly 2 rows from the center. Text lines whose +/// source starts with whitespace are stripped and centered; all others are +/// left-aligned. Selectable choices show `[a]`/`[b]` prefixes. +pub struct ScrollOverlayWidget<'a> { + /// The scroll content to display. + pub scroll: &'a Scroll, +} + +impl<'a> ScrollOverlayWidget<'a> { + /// Creates a widget for the given scroll content. + pub fn new(scroll: &'a Scroll) -> Self { + Self { scroll } + } +} + +impl StatefulWidget for ScrollOverlayWidget<'_> { + type State = ScrollOverlayState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut ScrollOverlayState) { + let anim = match &state.anim { + Some(ScrollAnimState::Opening(p)) => p.clamp(0.0, 1.0), + Some(ScrollAnimState::Open) => 1.0, + Some(ScrollAnimState::Closing(p)) => p.clamp(0.0, 1.0), + Some(ScrollAnimState::Done) | None => return, + }; + if anim <= 0.0 { + state.max_scroll = 0; + return; + } + + 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); + + // 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 = Vec::new(); + let mut choice_letter = b'a'; + for item in &self.scroll.lines { + match item { + ScrollLine::Text(s) => { + 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(display.clone(), choice_style), + ])); + choice_letter += 1; + } + } + } + + // 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() { + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_style(dim_style); + } + } + } + Fill::new(" ").style(Style::default().bg(bg)).render(overlay_rect, buf); + + // 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); + + 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 = state.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); + } + + state.max_scroll = max_scroll; + } +} diff --git a/kiln-tui/src/speech.rs b/kiln-tui/src/speech.rs new file mode 100644 index 0000000..b429c1c --- /dev/null +++ b/kiln-tui/src/speech.rs @@ -0,0 +1,141 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::{Color, Line, Style, Widget}; +use ratatui::widgets::{Block, Fill, Paragraph}; +use kiln_core::Board; +use kiln_core::game::SpeechBubble; +use crate::render::BoardWidget; +use crate::utils::rects_overlap; + +/// A ratatui widget that draws speech bubbles for all active [`SpeechBubble`]s over the board. +/// +/// 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 struct SpeechBubblesWidget<'a> { + /// The board, used to look up object positions and viewport layout. + pub board: &'a Board, + /// The active speech bubbles to render. + pub bubbles: &'a [SpeechBubble], +} + +impl<'a> SpeechBubblesWidget<'a> { + /// Creates a widget for the given board and bubble slice. + pub fn new(board: &'a Board, bubbles: &'a [SpeechBubble]) -> Self { + Self { board, bubbles } + } + + /// Finds a non-overlapping screen position for a bubble and records it in `placed`. + /// + /// Sizes the bubble from `lines`, centers it horizontally over `(obj_sx, obj_sy)`, + /// then shifts upward until it clears all already-placed rects (or runs out of room). + /// Returns the full bubble rect (including the tail row) on success, or `None` if + /// the bubble can't fit within `area`. Appends to `placed` on success. + fn place_bubble( + area: Rect, + obj_sx: u16, + obj_sy: u16, + lines: &[&str], + placed: &mut Vec, + ) -> Option { + let text_w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as u16; + let box_w = text_w + 4; + let box_h = lines.len() as u16 + 3; + + // Center horizontally over the speaker, clamped inside the viewport. + let left = obj_sx.saturating_sub(box_w / 2) + .min(area.right().saturating_sub(box_w)) + .max(area.left()); + + // Start directly above the speaker and shift upward until no overlap. + let mut top = obj_sy.saturating_sub(box_h); + let mut iterations = 0u16; + 'place: loop { + if iterations > 20 || top < area.top() { return None; } + let candidate = Rect { x: left, y: top, width: box_w, height: box_h }; + for p in placed.iter() { + if rects_overlap(candidate, *p) { + top = top.saturating_sub(1); + iterations += 1; + continue 'place; + } + } + break; + } + + let rect = Rect { x: left, y: top, width: box_w, height: box_h }; + placed.push(rect); + Some(rect) + } +} + +impl Widget for SpeechBubblesWidget<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + // Determine the screen position of each visible bubble's speaker. + let mut items: Vec<(u16, u16, &SpeechBubble)> = self.bubbles + .iter() + .filter_map(|b| { + let obj = self.board.objects.get(&b.object_id)?; + let (sx, sy) = board_screen_pos(area, self.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 lines: Vec<&str> = bubble.text.split('\n').collect(); + let Some(r) = Self::place_bubble(area, obj_sx, obj_sy, &lines, &mut placed) else { continue }; + + // Render the bordered box (top border + one row per line + bottom border). + let box_rect = Rect { height: r.height - 1, ..r }; + let block = Block::bordered() + .border_style(border_style) + .style(bubble_style); + let text_rect = block.inner(box_rect); + block.render(box_rect, buf); + + Fill::new(" ").style(bubble_style).render(text_rect, buf); + + let para_lines: Vec = lines.iter() + .map(|l| Line::styled(format!(" {l}"), bubble_style)) + .collect(); + Paragraph::new(para_lines).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. + let tail_col = obj_sx.clamp(r.x + 1, r.x + r.width - 2); + let border_bottom = r.y + r.height - 2; + if let Some(cell) = buf.cell_mut((tail_col, border_bottom)) { + cell.set_char('┬').set_style(border_style); + } + if let Some(cell) = buf.cell_mut((tail_col, border_bottom + 1)) { + cell.set_char('│').set_style(border_style); + } + } + } +} + +/// Converts a board cell coordinate to terminal screen coordinates within `area`, +/// accounting for centering/scrolling. Returns `None` if the cell is off-screen. +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)) +} diff --git a/kiln-tui/src/utils.rs b/kiln-tui/src/utils.rs new file mode 100644 index 0000000..b148efd --- /dev/null +++ b/kiln-tui/src/utils.rs @@ -0,0 +1,43 @@ +use color::Rgba8; +use ratatui::layout::Rect; +use ratatui::prelude::{Color, Line, Span, Style}; +use kiln_core::log::LogLine; + +/// 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. +pub 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) +} + +/// Returns true if two `Rect`s share at least one cell. +pub 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 +} \ No newline at end of file diff --git a/maps/start.toml b/maps/start.toml index 0ec2247..66d51dc 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -126,7 +126,7 @@ greeter = """ fn init() { log(`hello from object — player at ${Board.player_x}, ${Board.player_y}`); set_tile(2); // change my glyph to ☻ — proves the write path - say("Hello there, traveller!"); + say("Hello there,\\ntraveller!"); } fn tick(dt) {