diff options
| author | Dylan <boss@tehbox.org> | 2026-10-22 17:17:02 +1300 |
|---|---|---|
| committer | Dylan <boss@tehbox.org> | 2026-10-22 17:17:02 +1300 |
| commit | 7dff6572462bdc59011f8ff8d1c6dc3a0494e44a (patch) | |
| tree | 0e9e9bd39e9ee68fd5fd0c94afffb8204c637fc2 /src/board-view.rs | |
| parent | 15bdaa5d550115d97be3f89126c924b49c10a70f (diff) | |
| download | tigers-and-sheep-7dff6572462bdc59011f8ff8d1c6dc3a0494e44a.tar.gz tigers-and-sheep-7dff6572462bdc59011f8ff8d1c6dc3a0494e44a.zip | |
feat: Improvements to UI
Now uses ratatui
Diffstat (limited to 'src/board-view.rs')
| -rw-r--r-- | src/board-view.rs | 412 |
1 files changed, 412 insertions, 0 deletions
diff --git a/src/board-view.rs b/src/board-view.rs new file mode 100644 index 0000000..d6eba71 --- /dev/null +++ b/src/board-view.rs @@ -0,0 +1,412 @@ +use ratatui::{buffer::Buffer, layout::Rect, style::{Color, Modifier, Style}, widgets::Widget}; + +use crate::{board::{GameBoard, Move, PieceType, Pos}, player::Highlights}; + +/// Columns between neighbouring points at scale 1. Twice `ROW_STEP`, since cells are about twice as tall as wide. +const COL_STEP: u16 = 4; +/// Rows between neighbouring points at scale 1. +const ROW_STEP: u16 = 2; +/// Columns taken by the rank labels on the left. +const LEFT: u16 = 3; + +/// The spacing between points, in (columns, rows), at `scale`. +const fn steps(scale: u16) -> (u16, u16) +{ + (COL_STEP * scale, ROW_STEP * scale) +} + +/// How far a drawn piece reaches past its point, in (columns, rows), at `scale`: +/// `T` at scale 1, `[T]` at scale 2, and a 3x3 box from scale 3 up. +const fn reach(scale: u16) -> (u16, u16) +{ + match scale + { + 1 => (0, 0), + 2 => (1, 0), + _ => (1, 1), + } +} + +/// The board with its lines, rank labels down the left and file labels underneath. +/// It is drawn at the largest whole-number scale that fits its area; at scale 1 it looks like: +/// +/// ```text +/// 5 T───·───·───·───T +/// │ ╲ │ ╱ │ ╲ │ ╱ │ +/// 4 ·───·───·───·───· +/// … +/// 1 T───·───·───·───T +/// +/// a b c d e +/// ``` +pub struct BoardView +{ + pub board: GameBoard, + pub highlights: Highlights, + pub last_move: Option<Move>, +} + +impl BoardView +{ + /// The (width, height) the board needs at `scale` (1 or more): the grid, the pieces sticking + /// out past its edge, then a blank row and the file labels. + pub const fn size(scale: u16) -> (u16, u16) + { + let (cs, rs) = steps(scale); + let (rx, ry) = reach(scale); + (LEFT + 4 * cs + 1 + rx, ry + 4 * rs + 1 + ry + 2) + } + + /// The largest scale whose board fits in `width` by `height`; 1 if even that doesn't fit. + pub fn scale_to_fit(width: u16, height: u16) -> u16 + { + let fits = |scale| { let (w, h) = BoardView::size(scale); w <= width && h <= height }; + let mut scale = 1; + while fits(scale + 1) + { + scale += 1; + } + scale + } + + /// The screen cell a point is drawn at at `scale`, relative to the widget's top-left. + /// The grid sits below a row for the top pieces' boxes once they are boxed. + fn cell((x, y): Pos, scale: u16) -> (u16, u16) + { + let (cs, rs) = steps(scale); + (LEFT + x as u16 * cs, reach(scale).1 + y as u16 * rs) + } + + fn piece_style(&self, pos: Pos, piece: &PieceType) -> Style + { + let mut style = match piece + { + PieceType::Tiger => Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD), + PieceType::Sheep => Style::new().fg(Color::White).add_modifier(Modifier::BOLD), + PieceType::Empty => Style::new().fg(Color::DarkGray), + }; + let (from, to) = match self.last_move + { + Some(Move::PlaceSheep { pos }) => (None, Some(pos)), + Some(Move::MoveSheep { from, to } | Move::MoveTiger { from, to }) => (Some(from), Some(to)), + None => (None, None), + }; + if from == Some(pos) || to == Some(pos) + { + style = style.add_modifier(Modifier::UNDERLINED); + } + if self.highlights.targets.contains(&pos) + { + style = style.fg(Color::Green).add_modifier(Modifier::BOLD); + } + if self.highlights.selected == Some(pos) + { + style = style.add_modifier(Modifier::REVERSED); + } + style + } +} + +impl Widget for &BoardView +{ + fn render(self, area: Rect, buf: &mut Buffer) + { + let scale = BoardView::scale_to_fit(area.width, area.height); + let (cs, rs) = steps(scale); + let lines = Style::new().fg(Color::Gray); + let labels = Style::new().fg(Color::Cyan); + // Clip to the area so a small terminal doesn't index outside the buffer + let mut put = |cx: u16, cy: u16, s: &str, style: Style| + { + if cx < area.width && cy < area.height + { + buf.set_string(area.x + cx, area.y + cy, s, style); + } + }; + + // Lines first, so the pieces are drawn over them + for y in 0..5u8 + { + for x in 0..5u8 + { + let (cx, cy) = BoardView::cell((x, y), scale); + if x < 4 + { + put(cx + 1, cy, &"─".repeat(cs as usize - 1), lines); + } + if y < 4 + { + for k in 1..rs + { + put(cx, cy + k, "│", lines); + } + if x < 4 + { + // Diagonals run through points where x + y is even (see `GameBoard::on_line`). + // Each row down the diagonal moves cs / rs = 2 columns across. + let down_right = (x + y) % 2 == 0; + for k in 1..rs + { + let col = if down_right { cx + 2 * k } else { cx + cs - 2 * k }; + put(col, cy + k, if down_right { "╲" } else { "╱" }, lines); + } + } + } + } + } + + for y in 0..5u8 + { + let (_, cy) = BoardView::cell((0, y), scale); + put(0, cy, &(5 - y).to_string(), labels); + for x in 0..5u8 + { + let (cx, cy) = BoardView::cell((x, y), scale); + let piece = self.board.get((x, y)); + let targeted = self.highlights.targets.contains(&(x, y)); + let symbol = match piece + { + PieceType::Tiger => "T", + PieceType::Sheep => "S", + PieceType::Empty if targeted => "●", + PieceType::Empty => "·", + }; + let style = self.piece_style((x, y), &piece); + // The last move's underline is only for the symbol, not the box around it + let frame = style.remove_modifier(Modifier::UNDERLINED); + let boxed = targeted || !matches!(piece, PieceType::Empty); + match scale + { + _ if !boxed => put(cx, cy, symbol, style), + 1 => put(cx, cy, symbol, style), + 2 => + { + put(cx - 1, cy, "[", frame); + put(cx, cy, symbol, style); + put(cx + 1, cy, "]", frame); + } + _ => + { + // Where a line meets the box, join it on + put(cx - 1, cy - 1, if y > 0 { "┌┴┐" } else { "┌─┐" }, frame); + put(cx - 1, cy, if x > 0 { "┤" } else { "│" }, frame); + put(cx, cy, symbol, style); + put(cx + 1, cy, if x < 4 { "├" } else { "│" }, frame); + put(cx - 1, cy + 1, if y < 4 { "└┬┘" } else { "└─┘" }, frame); + } + } + } + } + let files_row = BoardView::size(scale).1 - 1; + for x in 0..5u8 + { + let (cx, _) = BoardView::cell((x, 0), scale); + put(cx, files_row, &((b'a' + x) as char).to_string(), labels); + } + } +} + +/// The board point nearest a click at screen cell (`column`, `row`), for a `BoardView` drawn in `area` +/// (at the same scale `render` picks for it). Clicks on the lines between points go to the nearest +/// point; clicks off the grid give None. +pub fn pos_at(area: Rect, column: u16, row: u16) -> Option<Pos> +{ + let scale = BoardView::scale_to_fit(area.width, area.height); + let (cs, rs) = steps(scale); + let (rx, ry) = reach(scale); + // Offsets from the top-left point, letting clicks land on the edge pieces' boxes + let cx = column.checked_sub(area.x + LEFT - rx)?; + let cy = row.checked_sub(area.y)?; + if cx > 4 * cs + 1 + rx || cy > 4 * rs + 2 * ry + { + return None; + } + // Shifted by the reach, so (cx, cy) are offsets from one column/row before the first point + let x = ((cx + cs / 2 - rx) / cs).min(4); + let y = ((cy + rs / 2 - ry) / rs).min(4); + Some((x as u8, y as u8)) +} + +#[cfg(test)] +mod tests +{ + use super::*; + + fn rendered(view: &BoardView, area: Rect) -> Buffer + { + let mut buf = Buffer::empty(Rect::new(0, 0, area.right() + 2, area.bottom() + 2)); + view.render(area, &mut buf); + buf + } + + fn symbol(buf: &Buffer, x: u16, y: u16) -> &str + { + buf[(x, y)].symbol() + } + + #[test] + fn renders_default_board_with_labels() + { + let area = Rect::new(1, 1, BoardView::size(1).0, BoardView::size(1).1); + let view = BoardView { board: GameBoard::default(), highlights: Highlights::default(), last_move: None }; + let buf = rendered(&view, area); + for (pos, s) in [((0, 0), "T"), ((4, 0), "T"), ((0, 4), "T"), ((4, 4), "T"), ((2, 2), "·")] + { + let (cx, cy) = BoardView::cell(pos, 1); + assert_eq!(symbol(&buf, area.x + cx, area.y + cy), s, "{pos:?}"); + } + assert_eq!(symbol(&buf, area.x, area.y), "5"); + assert_eq!(symbol(&buf, area.x, area.y + 8), "1"); + assert_eq!(symbol(&buf, area.x + LEFT, area.y + BoardView::size(1).1 - 1), "a"); + assert_eq!(symbol(&buf, area.x + LEFT + 16, area.y + BoardView::size(1).1 - 1), "e"); + // (0, 0) has an even sum, so its diagonal runs down-right + assert_eq!(symbol(&buf, area.x + LEFT + 2, area.y + 1), "╲"); + assert_eq!(symbol(&buf, area.x + LEFT + 6, area.y + 1), "╱"); + } + + #[test] + fn renders_target_highlights() + { + let area = Rect::new(0, 0, BoardView::size(1).0, BoardView::size(1).1); + let highlights = Highlights { selected: Some((0, 0)), targets: vec![(1, 1)] }; + let view = BoardView { board: GameBoard::default(), highlights, last_move: None }; + let buf = rendered(&view, area); + let (cx, cy) = BoardView::cell((1, 1), 1); + assert_eq!(symbol(&buf, cx, cy), "●"); + let (cx, cy) = BoardView::cell((0, 0), 1); + assert!(buf[(cx, cy)].modifier.contains(Modifier::REVERSED)); + } + + #[test] + fn renders_into_a_small_area_without_panicking() + { + let view = BoardView { board: GameBoard::default(), highlights: Highlights::default(), last_move: None }; + rendered(&view, Rect::new(0, 0, 5, 3)); + } + + #[test] + fn scale_to_fit_picks_the_largest_board_that_fits() + { + for scale in 1..5 + { + let (w, h) = BoardView::size(scale); + assert_eq!(BoardView::scale_to_fit(w, h), scale); + assert_eq!(BoardView::scale_to_fit(w + 5, h + 5), scale); + assert_eq!(BoardView::scale_to_fit(w - 1, h), (scale - 1).max(1)); + assert_eq!(BoardView::scale_to_fit(w, h - 1), (scale - 1).max(1)); + } + assert_eq!(BoardView::scale_to_fit(0, 0), 1); + } + + #[test] + fn renders_bigger_when_there_is_room() + { + let (w, h) = BoardView::size(2); + let area = Rect::new(0, 0, w, h); + let view = BoardView { board: GameBoard::default(), highlights: Highlights::default(), last_move: None }; + let buf = rendered(&view, area); + let (cx, cy) = BoardView::cell((4, 4), 2); + assert_eq!((cx, cy), (LEFT + 32, 16)); + assert_eq!(symbol(&buf, cx, cy), "T"); + assert_eq!(symbol(&buf, 0, 16), "1"); + assert_eq!(symbol(&buf, cx, h - 1), "e"); + // Lines fill the gaps: three rows of │ between points, a diagonal two columns per row + for k in 1..4 + { + assert_eq!(symbol(&buf, LEFT, k), "│"); + assert_eq!(symbol(&buf, LEFT + 2 * k, k), "╲"); + } + assert_eq!(symbol(&buf, LEFT + 7, 0), "─"); + } + + fn row(buf: &Buffer, y: u16) -> String + { + (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect() + } + + #[test] + fn pieces_get_brackets_at_scale_2() + { + let (w, h) = BoardView::size(2); + let highlights = Highlights { selected: None, targets: vec![(1, 1)] }; + let view = BoardView { board: GameBoard::default(), highlights, last_move: None }; + let buf = rendered(&view, Rect::new(0, 0, w, h)); + assert!(row(&buf, 0).starts_with("5 [T]──────·"), "{}", row(&buf, 0)); + assert!(row(&buf, 4).starts_with("4 ·──────[●]──────·"), "{}", row(&buf, 4)); + } + + #[test] + fn pieces_get_boxes_from_scale_3() + { + let (w, h) = BoardView::size(3); + let mut board = GameBoard::default(); + board.make_move(Move::PlaceSheep { pos: (2, 2) }).unwrap(); + let view = BoardView { board, highlights: Highlights::default(), last_move: None }; + let buf = rendered(&view, Rect::new(0, 0, w, h)); + // Corner tiger: lines only join on the sides that have them + assert!(row(&buf, 0).starts_with(" ┌─┐"), "{}", row(&buf, 0)); + assert!(row(&buf, 1).starts_with("5 │T├───"), "{}", row(&buf, 1)); + assert!(row(&buf, 2).starts_with(" └┬┘"), "{}", row(&buf, 2)); + // Centre sheep: lines join on all four sides + let (cx, cy) = BoardView::cell((2, 2), 3); + let around = |dy: u16| (cx - 1..=cx + 1).map(|x| buf[(x, cy + dy - 1)].symbol()).collect::<String>(); + assert_eq!([around(0), around(1), around(2)], ["┌┴┐", "┤S├", "└┬┘"]); + assert_eq!(symbol(&buf, cx, h - 1), "c"); + } + + #[test] + fn pos_at_takes_clicks_on_a_piece_box() + { + let (w, h) = BoardView::size(3); + let area = Rect::new(4, 3, w, h); + for pos in [(0, 0), (4, 4), (2, 2), (4, 0)] + { + let (cx, cy) = BoardView::cell(pos, 3); + for (dx, dy) in [(-1, -1), (1, 1), (-1, 1), (1, -1)] + { + let column = (area.x + cx).checked_add_signed(dx).unwrap(); + let row = (area.y + cy).checked_add_signed(dy).unwrap(); + assert_eq!(pos_at(area, column, row), Some(pos), "{pos:?} {dx},{dy}"); + } + } + assert_eq!(pos_at(area, area.x + 1, area.y), None); + assert_eq!(pos_at(area, area.x + LEFT, area.y + h - 1), None); // file labels + } + + #[test] + fn pos_at_finds_every_drawn_point() + { + for scale in 1..6 + { + let (w, h) = BoardView::size(scale); + let area = Rect::new(3, 2, w, h); + for y in 0..5 + { + for x in 0..5 + { + let (cx, cy) = BoardView::cell((x, y), scale); + assert_eq!(pos_at(area, area.x + cx, area.y + cy), Some((x, y)), "scale {scale}"); + } + } + } + } + + #[test] + fn pos_at_snaps_line_clicks_to_nearest_point() + { + let area = Rect::new(0, 0, BoardView::size(1).0, BoardView::size(1).1); + assert_eq!(pos_at(area, LEFT + 1, 0), Some((0, 0))); + assert_eq!(pos_at(area, LEFT + 3, 0), Some((1, 0))); + assert_eq!(pos_at(area, LEFT + 16, 1), Some((4, 1))); + } + + #[test] + fn pos_at_rejects_clicks_off_the_grid() + { + let area = Rect::new(2, 2, BoardView::size(1).0, BoardView::size(1).1); + assert_eq!(pos_at(area, 0, 0), None); + assert_eq!(pos_at(area, area.x, area.y), None); // rank label + assert_eq!(pos_at(area, area.x + LEFT, area.y + 9), None); // below the last rank + assert_eq!(pos_at(area, area.x + LEFT + 18, area.y), None); + } +} |
