spinners and fmt

This commit is contained in:
2026-06-21 01:32:47 -05:00
parent d32914d99d
commit 8e90084b53
31 changed files with 1445 additions and 284 deletions
+131 -69
View File
@@ -1,11 +1,11 @@
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};
use kiln_core::game::SpeechBubble;
use kiln_core::{Board, Direction};
use crate::render::BoardWidget;
use crate::utils::rects_overlap;
/// A ratatui widget that draws speech bubbles for all active [`SpeechBubble`]s over the board.
///
@@ -57,7 +57,12 @@ impl<'a> SpeechBubblesWidget<'a> {
placed: &mut Vec<Rect>,
player: Option<(u16, u16)>,
) -> (Rect, Direction) {
let dirs = [Direction::North, Direction::South, Direction::East, Direction::West];
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
@@ -70,7 +75,17 @@ impl<'a> SpeechBubblesWidget<'a> {
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),
|| {
(
Rect {
x: area.x + 1,
y: area.y + 1,
width: box_w,
height: box_h,
},
Direction::North,
)
},
|(r, _, d)| (r, d),
);
@@ -81,9 +96,7 @@ impl<'a> SpeechBubblesWidget<'a> {
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 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));
@@ -98,7 +111,8 @@ impl Widget for SpeechBubblesWidget<'_> {
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
let mut items: Vec<(u16, u16, bool, &SpeechBubble)> = self
.bubbles
.iter()
.filter_map(|b| {
let obj = self.board.objects.get(&b.object_id)?;
@@ -120,7 +134,14 @@ impl Widget for SpeechBubblesWidget<'_> {
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 });
placements.push(Placement {
sx,
sy,
on_screen,
lines,
rect,
dir,
});
}
draw_tails(&placements, buf, border_style);
@@ -137,32 +158,42 @@ impl Widget for SpeechBubblesWidget<'_> {
/// Skipped for off-screen speakers.
fn draw_tails(placements: &[Placement<'_>], buf: &mut Buffer, style: Style) {
for p in placements {
if !p.on_screen { continue; }
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); }
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); }
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); }
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); }
if let Some(c) = buf.cell_mut((x, row)) {
c.set_char('─').set_style(style);
}
}
}
}
@@ -173,14 +204,23 @@ fn draw_tails(placements: &[Placement<'_>], buf: &mut Buffer, style: Style) {
///
/// 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) {
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 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<Line> = p.lines.iter()
let para_lines: Vec<Line> = p
.lines
.iter()
.map(|l| Line::styled(format!(" {l}"), bubble_style))
.collect();
Paragraph::new(para_lines).render(text_rect, buf);
@@ -193,7 +233,9 @@ fn draw_boxes(placements: &[Placement<'_>], buf: &mut Buffer, bubble_style: Styl
/// 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; }
if !p.on_screen {
continue;
}
let r = p.rect;
match p.dir {
Direction::North => {
@@ -249,32 +291,37 @@ fn try_place_direction(
// 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;
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),
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),
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
|| 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 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)| {
@@ -298,8 +345,8 @@ fn tail_length(dir: Direction, r: Rect, obj_sx: u16, obj_sy: u16) -> u32 {
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,
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,
}
}
@@ -376,46 +423,47 @@ mod tests {
use super::*;
fn big_area() -> Rect {
Rect { x: 0, y: 0, width: 100, height: 40 }
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
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
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
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
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);
}
@@ -425,12 +473,25 @@ mod tests {
fn north_shifts_up_when_initial_position_blocked() {
// Blocking rect touches only the bottom row of the initial box (rows 1418),
// 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 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 1819)
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 1819)
}
// ── Out of room ───────────────────────────────────────────────────────────
@@ -438,9 +499,7 @@ mod tests {
#[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,
);
let result = try_place_direction(Direction::North, big_area(), 50, 4, 10, 5, &[], None);
assert!(result.is_none());
}
@@ -450,9 +509,8 @@ mod tests {
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,
);
let (_, dir) =
SpeechBubblesWidget::place_bubble(big_area(), 50, 20, 10, 5, &mut placed, None);
assert_eq!(dir, Direction::North);
}
@@ -460,9 +518,8 @@ mod tests {
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,
);
let (_, dir) =
SpeechBubblesWidget::place_bubble(big_area(), 50, 4, 10, 5, &mut placed, None);
assert_eq!(dir, Direction::South);
}
@@ -471,10 +528,15 @@ mod tests {
#[test]
fn join_row_clamps_speaker_to_box_interior() {
// Box: y=10, height=5 → top border row 10, interior rows 1113, 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
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 ──────────────────────────────────────────────────