use crate::render::BoardWidget; use crate::utils::rects_overlap; use kiln_core::game::SpeechBubble; use kiln_core::{Board, Direction}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::{Color, Line, Style, Widget}; use ratatui::widgets::{Block, Fill, Paragraph}; /// A ratatui widget that draws speech bubbles for all active [`SpeechBubble`]s over the board. /// /// Each bubble is placed in the direction (North/South/East/West) that minimises tail length; /// ties break in that preference order. Bubbles that would cover the player or an /// already-placed bubble are shifted until they clear. If no clear position exists in any /// direction the bubble is forced to the top-left of the viewport (overlap tolerated, tail /// suppressed). Off-screen speakers still get a bubble; their tails are suppressed. 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], } /// A fully-resolved bubble placement ready for the three draw passes. struct Placement<'a> { /// Speaker's screen column. sx: u16, /// Speaker's screen row. sy: u16, /// False when the speaker is off-screen; tail is suppressed in that case. on_screen: bool, /// Wrapped text lines to display inside the box. lines: Vec<&'a str>, /// Collision rect — equal to the visual box (no extra padding). rect: Rect, /// Which side the box is on relative to the speaker. dir: Direction, } 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 } } /// Places a bubble for a speaker at `(obj_sx, obj_sy)`. /// /// Tries all four directions and picks the one whose non-overlapping, player-safe /// placement has the shortest tail. Falls back to a forced top-left placement if all /// four directions fail. Appends the chosen rect to `placed`. fn place_bubble( area: Rect, obj_sx: u16, obj_sy: u16, box_w: u16, box_h: u16, placed: &mut Vec, player: Option<(u16, u16)>, ) -> (Rect, Direction) { let dirs = [ Direction::North, Direction::South, Direction::East, Direction::West, ]; // Collect (rect, tail_length, dir) for every direction that can be placed. // stable min_by_key preserves the first element on ties, so N > S > E > W wins. let best = dirs .iter() .filter_map(|&dir| { try_place_direction(dir, area, obj_sx, obj_sy, box_w, box_h, placed, player) .map(|(r, len)| (r, len, dir)) }) .min_by_key(|&(_, len, _)| len); let (rect, dir) = best.map_or_else( // Forced fallback: place at top-left, caller suppresses tail via `on_screen`. || { ( Rect { x: area.x + 1, y: area.y + 1, width: box_w, height: box_h, }, Direction::North, ) }, |(r, _, d)| (r, d), ); placed.push(rect); (rect, dir) } } impl Widget for SpeechBubblesWidget<'_> { fn render(self, area: Rect, buf: &mut Buffer) { 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)); // Determine the player's screen position for bubble avoidance. let (px, py, player_vis) = board_screen_pos( area, self.board, self.board.player.x.max(0) as usize, self.board.player.y.max(0) as usize, ); let player = player_vis.then_some((px, py)); // Map each bubble to its speaker's (clamped) screen position. let mut items: Vec<(u16, u16, bool, &SpeechBubble)> = self .bubbles .iter() .filter_map(|b| { let obj = self.board.objects.get(&b.object_id)?; let (sx, sy, on_screen) = board_screen_pos(area, self.board, obj.x, obj.y); Some((sx, sy, on_screen, b)) }) .collect(); // Process bottom-most speakers first so upward shifts don't displace higher bubbles. items.sort_by_key(|&(_, sy, _, _)| std::cmp::Reverse(sy)); let mut placed: Vec = Vec::new(); let mut placements: Vec = Vec::new(); for (sx, sy, on_screen, bubble) in items { let lines: Vec<&str> = bubble.text.split('\n').collect(); 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 + 2; let (rect, dir) = SpeechBubblesWidget::place_bubble(area, sx, sy, box_w, box_h, &mut placed, player); placements.push(Placement { sx, sy, on_screen, lines, rect, dir, }); } draw_tails(&placements, buf, border_style); draw_boxes(&placements, buf, bubble_style, border_style); draw_join_chars(&placements, buf, border_style); } } // ─── Draw passes ───────────────────────────────────────────────────────────── /// Pass 1: `│`/`─` tail stem characters. /// /// Drawn first so box borders (pass 2) overwrite any stem that crosses another box. /// Skipped for off-screen speakers. fn draw_tails(placements: &[Placement<'_>], buf: &mut Buffer, style: Style) { for p in placements { if !p.on_screen { continue; } let r = p.rect; match p.dir { Direction::North => { let col = tail_col(p.sx, r); for y in (r.y + r.height)..p.sy { if let Some(c) = buf.cell_mut((col, y)) { c.set_char('│').set_style(style); } } } Direction::South => { let col = tail_col(p.sx, r); for y in (p.sy + 1)..r.y { if let Some(c) = buf.cell_mut((col, y)) { c.set_char('│').set_style(style); } } } Direction::East => { let row = join_row(p.sy, r); for x in (p.sx + 1)..r.x { if let Some(c) = buf.cell_mut((x, row)) { c.set_char('─').set_style(style); } } } Direction::West => { // Box right border is at r.x + r.width - 1; first tail col is r.x + r.width. let row = join_row(p.sy, r); for x in (r.x + r.width)..p.sx { if let Some(c) = buf.cell_mut((x, row)) { c.set_char('─').set_style(style); } } } } } } /// Pass 2: bordered boxes with text. /// /// Overwrites any stray tail chars that passed through another box's area. /// Order within this pass doesn't matter since placed rects are non-overlapping. fn draw_boxes( placements: &[Placement<'_>], buf: &mut Buffer, bubble_style: Style, border_style: Style, ) { for p in placements { let box_rect = p.rect; 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 = p .lines .iter() .map(|l| Line::styled(format!(" {l}"), bubble_style)) .collect(); Paragraph::new(para_lines).render(text_rect, buf); } } /// Pass 3: single join characters where the tail meets the box border. /// /// Applied last so they are never hidden by the box border drawn in pass 2. /// Skipped for off-screen speakers. fn draw_join_chars(placements: &[Placement<'_>], buf: &mut Buffer, style: Style) { for p in placements { if !p.on_screen { continue; } let r = p.rect; match p.dir { Direction::North => { let col = tail_col(p.sx, r); let bottom_border = r.y + r.height - 1; if let Some(c) = buf.cell_mut((col, bottom_border)) { c.set_char('┬').set_style(style); } } Direction::South => { let col = tail_col(p.sx, r); if let Some(c) = buf.cell_mut((col, r.y)) { c.set_char('┴').set_style(style); } } Direction::East => { // Tail arrives from the left; ┤ (branch facing left) on the left border. let row = join_row(p.sy, r); if let Some(c) = buf.cell_mut((r.x, row)) { c.set_char('┤').set_style(style); } } Direction::West => { // Tail departs to the right; ├ (branch facing right) on the right border. let row = join_row(p.sy, r); if let Some(c) = buf.cell_mut((r.x + r.width - 1, row)) { c.set_char('├').set_style(style); } } } } } // ─── Placement helpers ──────────────────────────────────────────────────────── /// Tries to find a non-overlapping, player-safe position for the box in direction `dir`. /// /// Returns `(rect, tail_length)` on success, or `None` if no clear spot found within /// 20 shift iterations. #[allow(clippy::too_many_arguments)] fn try_place_direction( dir: Direction, area: Rect, obj_sx: u16, obj_sy: u16, box_w: u16, box_h: u16, placed: &[Rect], player: Option<(u16, u16)>, ) -> Option<(Rect, u32)> { let bw = box_w as i32; let bh = box_h as i32; // Fixed (non-shifting) dimension: center on the speaker along the perpendicular axis. let fixed_left = center_left(obj_sx, box_w, area) as i32; let fixed_top = center_top(obj_sy, box_h, area) as i32; // Starting position on the shifting axis and the per-iteration step. let (mut pos, delta): (i32, i32) = match dir { Direction::North => (obj_sy as i32 - bh - 1, -1), Direction::South => (obj_sy as i32 + 2, 1), Direction::East => (obj_sx as i32 + 2, 1), Direction::West => (obj_sx as i32 - bw - 1, -1), }; for _ in 0..=20 { let (left, top) = match dir { Direction::North | Direction::South => (fixed_left, pos), Direction::East | Direction::West => (pos, fixed_top), }; // Out-of-viewport: shifting further in this direction only makes it worse. if left < area.left() as i32 || top < area.top() as i32 || left + bw > area.right() as i32 || top + bh > area.bottom() as i32 { break; } let r = Rect { x: left as u16, y: top as u16, width: box_w, height: box_h, }; let overlaps_placed = placed.iter().any(|p| rects_overlap(r, *p)); let blocks_player = player.is_some_and(|(px, py)| { (px >= r.x && px < r.x + r.width && py >= r.y && py < r.y + r.height) || tail_covers(dir, r, obj_sx, obj_sy, px, py) }); if !overlaps_placed && !blocks_player { return Some((r, tail_length(dir, r, obj_sx, obj_sy))); } pos += delta; } None } /// Returns the number of stem characters the tail would have for a given placement. fn tail_length(dir: Direction, r: Rect, obj_sx: u16, obj_sy: u16) -> u32 { // Each value is the count of │ or ─ chars between the join char and the speaker. match dir { Direction::North => (obj_sy as i32 - r.y as i32 - r.height as i32).max(0) as u32, Direction::South => (r.y as i32 - obj_sy as i32 - 1).max(0) as u32, Direction::East => (r.x as i32 - obj_sx as i32 - 1).max(0) as u32, Direction::West => (obj_sx as i32 - (r.x as i32 + r.width as i32)).max(0) as u32, } } /// Returns `true` if the tail for `(dir, r)` would pass through the cell `(px, py)`. fn tail_covers(dir: Direction, r: Rect, obj_sx: u16, obj_sy: u16, px: u16, py: u16) -> bool { match dir { Direction::North => { let col = tail_col(obj_sx, r); px == col && py >= r.y + r.height && py < obj_sy } Direction::South => { let col = tail_col(obj_sx, r); px == col && py > obj_sy && py < r.y } Direction::East => { let row = join_row(obj_sy, r); py == row && px > obj_sx && px < r.x } Direction::West => { let row = join_row(obj_sy, r); // Tail runs from r.x + r.width (first col after right border) to obj_sx - 1. py == row && px >= r.x + r.width && px < obj_sx } } } /// The column of the vertical tail, clamped to the box interior (not on a corner). fn tail_col(obj_sx: u16, r: Rect) -> u16 { obj_sx.clamp(r.x + 1, r.x + r.width - 2) } /// The row of the horizontal tail, clamped to the box interior (not on a corner). fn join_row(obj_sy: u16, r: Rect) -> u16 { let lo = r.y + 1; let hi = (r.y + r.height).saturating_sub(2).max(lo); obj_sy.clamp(lo, hi) } /// Centers `box_w` horizontally over `obj_sx`, clamped inside `area`. fn center_left(obj_sx: u16, box_w: u16, area: Rect) -> u16 { obj_sx .saturating_sub(box_w / 2) .min(area.right().saturating_sub(box_w)) .max(area.left()) } /// Centers `box_h` vertically at `obj_sy`, clamped inside `area`. /// /// Uses `(box_h - 1) / 2` as the offset so the visual centre of the box aligns /// with the speaker row for both odd and even heights. fn center_top(obj_sy: u16, box_h: u16, area: Rect) -> u16 { obj_sy .saturating_sub((box_h - 1) / 2) .min(area.bottom().saturating_sub(box_h)) .max(area.top()) } /// Converts a board cell coordinate to terminal screen coordinates within `area`. /// /// Returns `(sx, sy, on_screen)`. Off-screen coordinates are clamped to the viewport edge; /// `on_screen` is `false` when clamping occurred (tail should be suppressed for that bubble). fn board_screen_pos(area: Rect, board: &Board, bx: usize, by: usize) -> (u16, u16, bool) { let (off_x, pad_x, _) = BoardWidget::axis(board.width, area.width as usize, board.player.x as i32); let (off_y, pad_y, _) = BoardWidget::axis(board.height, area.height as usize, board.player.y as i32); let raw_sx = area.x as i32 + pad_x as i32 + (bx as i32 - off_x as i32); let raw_sy = area.y as i32 + pad_y as i32 + (by as i32 - off_y as i32); let sx = raw_sx.clamp(area.left() as i32, area.right() as i32 - 1) as u16; let sy = raw_sy.clamp(area.top() as i32, area.bottom() as i32 - 1) as u16; (sx, sy, raw_sx == sx as i32 && raw_sy == sy as i32) } #[cfg(test)] mod tests { use super::*; fn big_area() -> Rect { Rect { x: 0, y: 0, width: 100, height: 40, } } // ── Initial positions: each direction leaves exactly 1 tail cell ────────── #[test] fn north_places_box_above_with_one_tail_row() { let (r, len) = try_place_direction(Direction::North, big_area(), 50, 20, 10, 5, &[], None).unwrap(); assert_eq!(r.y, 14); // 20 - 5 - 1 assert_eq!(r.y + r.height, 19); // tail range 19..20 = 1 row assert_eq!(len, 1); } #[test] fn south_places_box_below_with_one_tail_row() { let (r, len) = try_place_direction(Direction::South, big_area(), 50, 20, 10, 5, &[], None).unwrap(); assert_eq!(r.y, 22); // 20 + 2; tail range 21..22 = 1 row assert_eq!(len, 1); } #[test] fn east_places_box_right_with_one_tail_col() { let (r, len) = try_place_direction(Direction::East, big_area(), 50, 20, 10, 5, &[], None).unwrap(); assert_eq!(r.x, 52); // 50 + 2; tail range 51..52 = 1 col assert_eq!(len, 1); } #[test] fn west_places_box_left_with_one_tail_col() { let (r, len) = try_place_direction(Direction::West, big_area(), 50, 20, 10, 5, &[], None).unwrap(); assert_eq!(r.x, 39); // 50 - 10 - 1 assert_eq!(r.x + r.width, 49); // box right at 49; tail at col 49; speaker at 50 assert_eq!(len, 1); } // ── Obstacle shifting ───────────────────────────────────────────────────── #[test] fn north_shifts_up_when_initial_position_blocked() { // Blocking rect touches only the bottom row of the initial box (rows 14–18), // so the box needs to shift up by exactly 1 to clear it. let blocking = Rect { x: 0, y: 18, width: 100, height: 1, }; let (r, len) = try_place_direction( Direction::North, big_area(), 50, 20, 10, 5, &[blocking], None, ) .unwrap(); assert_eq!(r.y, 13); // shifted up one row from initial 14 assert_eq!(len, 2); // tail is now 2 rows (rows 18–19) } // ── Out of room ─────────────────────────────────────────────────────────── #[test] fn north_returns_none_when_no_room_above() { // Speaker at row 4; North needs box_h(5) + 1(tail gap) = 6 rows, only 4 available. let result = try_place_direction(Direction::North, big_area(), 50, 4, 10, 5, &[], None); assert!(result.is_none()); } // ── Direction preference via place_bubble ───────────────────────────────── #[test] fn place_bubble_prefers_north_when_all_equal() { // All four directions have tail_length = 1; North wins by preference order. let mut placed = vec![]; let (_, dir) = SpeechBubblesWidget::place_bubble(big_area(), 50, 20, 10, 5, &mut placed, None); assert_eq!(dir, Direction::North); } #[test] fn place_bubble_falls_back_to_south_when_north_blocked() { // Speaker at row 4: North can't fit (same condition as the None test above). let mut placed = vec![]; let (_, dir) = SpeechBubblesWidget::place_bubble(big_area(), 50, 4, 10, 5, &mut placed, None); assert_eq!(dir, Direction::South); } // ── join_row clamping ───────────────────────────────────────────────────── #[test] fn join_row_clamps_speaker_to_box_interior() { // Box: y=10, height=5 → top border row 10, interior rows 11–13, bottom border row 14. let r = Rect { x: 0, y: 10, width: 10, height: 5, }; assert_eq!(join_row(8, r), 11); // speaker above box → first interior row assert_eq!(join_row(12, r), 12); // speaker inside box → unchanged assert_eq!(join_row(20, r), 13); // speaker below box → last interior row } // ── center_top centering ────────────────────────────────────────────────── #[test] fn center_top_aligns_box_centre_with_speaker() { let area = big_area(); // box_h=3 (1 line): offset = (3-1)/2 = 1 → top = 19, centre row = 20. ✓ assert_eq!(center_top(20, 3, area), 19); // box_h=5 (3 lines): offset = (5-1)/2 = 2 → top = 18, centre row = 20. ✓ assert_eq!(center_top(20, 5, area), 18); } }