feat: hyper-polished chess board rendering

- Auto-fit to terminal size with dynamic cell sizing
- Refined color palette (sage/forest green squares)
- Gold highlights for selected squares
- Perfect border alignment with box-drawing chars
- Double-line outer borders for polish
- Compact mode auto-triggers on small terminals
- Better piece visibility with bold rendering
- Improved sidebar layout and sizing
This commit is contained in:
Greg Hendrickson
2026-01-27 17:58:54 +00:00
parent 58a00c9d2e
commit 795fb73734
2 changed files with 274 additions and 44 deletions

View File

@@ -53,21 +53,47 @@ class GameScreen(Screen):
GameScreen {
layout: grid;
grid-size: 2;
grid-columns: 2fr 1fr;
padding: 1;
grid-columns: 3fr 1fr;
grid-rows: 1fr;
padding: 0;
}
#board-container {
align: center middle;
padding: 1;
padding: 0;
width: 100%;
height: 100%;
min-width: 40;
min-height: 22;
}
#chess-board {
width: auto;
height: auto;
min-width: 36;
min-height: 20;
}
#sidebar {
padding: 1;
min-width: 20;
max-width: 30;
border-left: solid #2a3a2a;
}
#game-status {
height: auto;
max-height: 8;
}
#move-list {
height: 1fr;
min-height: 8;
}
#move-input {
dock: bottom;
height: auto;
}
.hint-text {

View File

@@ -1,36 +1,60 @@
"""Chess board widget with Unicode pieces."""
"""Chess board widget with Unicode pieces - Hyper-polished version."""
from textual.widget import Widget
from textual.reactive import reactive
from textual.geometry import Size
from rich.text import Text
from rich.style import Style
from rich.console import RenderableType
import chess
# Unicode chess pieces
PIECES = {
# Unicode chess pieces - using filled variants for better visibility
PIECES_FILLED = {
'K': '', 'Q': '', 'R': '', 'B': '', 'N': '', 'P': '',
'k': '', 'q': '', 'r': '', 'b': '', 'n': '', 'p': '',
}
# Alternative set with outlined white pieces
PIECES_OUTLINE = {
'K': '', 'Q': '', 'R': '', 'B': '', 'N': '', 'P': '',
'k': '', 'q': '', 'r': '', 'b': '', 'n': '', 'p': '',
}
# Colors
LIGHT_SQUARE = "#3d5a45"
DARK_SQUARE = "#2d4235"
LIGHT_SQUARE_SELECTED = "#5d8a65"
DARK_SQUARE_SELECTED = "#4d7255"
HIGHLIGHT_MOVE = "#6b8f71"
# Colors - refined palette
LIGHT_SQUARE = "#b8c6a0" # Sage green light
DARK_SQUARE = "#6d8b5e" # Forest green dark
LIGHT_SQUARE_SELECTED = "#e8d44d" # Gold highlight
DARK_SQUARE_SELECTED = "#d4c02a" # Darker gold
HIGHLIGHT_LAST_MOVE = "#a8c878" # Bright green for last move
HIGHLIGHT_LEGAL = "#7ba35a" # Legal move dots
WHITE_PIECE = "#ffffff"
BLACK_PIECE = "#1a1a1a"
BORDER_COLOR = "#4a5d4a"
LABEL_COLOR = "#8faf7f"
# Box drawing characters for clean borders
BOX = {
'tl': '', 'tr': '', 'bl': '', 'br': '',
'h': '', 'v': '',
'tj': '', 'bj': '', 'lj': '', 'rj': '',
'x': '',
# Heavy variants for outer border
'TL': '', 'TR': '', 'BL': '', 'BR': '',
'H': '', 'V': '',
'TJ': '', 'BJ': '', 'LJ': '', 'RJ': '',
}
class ChessBoardWidget(Widget):
"""Interactive chess board widget."""
"""Interactive chess board widget with auto-sizing."""
DEFAULT_CSS = """
ChessBoardWidget {
width: 42;
height: 20;
width: 100%;
height: 100%;
min-width: 34;
min-height: 18;
padding: 0;
}
"""
@@ -40,6 +64,8 @@ class ChessBoardWidget(Widget):
legal_moves: reactive[set] = reactive(set)
last_move: reactive[tuple | None] = reactive(None)
flipped: reactive[bool] = reactive(False)
use_heavy_border: reactive[bool] = reactive(True)
compact_mode: reactive[bool] = reactive(False)
def __init__(
self,
@@ -48,71 +74,249 @@ class ChessBoardWidget(Widget):
):
super().__init__(**kwargs)
self.board = board or chess.Board()
self._cell_width = 3 # Will be calculated
self._cell_height = 1
def get_content_width(self, container: Size, viewport: Size) -> int:
"""Calculate optimal width."""
# Each cell is at least 3 chars wide + borders + labels
min_width = 8 * 3 + 9 + 4 # 8 cells * 3 + 9 borders + labels
return max(min_width, container.width)
def get_content_height(self, container: Size, viewport: Size) -> int:
"""Calculate optimal height."""
# Each cell is 1-2 lines + borders + labels
return max(18, min(22, container.height))
def _calculate_cell_size(self, width: int, height: int) -> tuple[int, int]:
"""Calculate cell dimensions based on available space."""
# Available width for cells (minus borders and labels)
available_width = width - 4 # 2 for labels on each side
available_height = height - 4 # 2 for labels + 2 for borders
# Calculate cell width (must be odd for centering)
cell_w = max(3, (available_width - 9) // 8) # -9 for internal borders
if cell_w % 2 == 0:
cell_w -= 1
cell_w = min(cell_w, 7) # Cap at 7 for sanity
# Calculate cell height
cell_h = max(1, (available_height - 9) // 8)
cell_h = min(cell_h, 3)
return cell_w, cell_h
def render(self) -> RenderableType:
"""Render the chess board."""
text = Text()
"""Render the chess board with perfect alignment."""
size = self.size
self._cell_width, self._cell_height = self._calculate_cell_size(size.width, size.height)
# Use compact mode for small terminals
if size.width < 40 or size.height < 20:
return self._render_compact()
else:
return self._render_standard()
def _render_compact(self) -> RenderableType:
"""Compact board for small terminals."""
text = Text()
cw = 3 # Fixed cell width for compact
# File labels
files = "abcdefgh" if not self.flipped else "hgfedcba"
text.append(files + "\n", style="dim")
# File labels - centered over cells
text.append(" ", style=f"dim {LABEL_COLOR}")
for f in files:
text.append(f" {f} ", style=f"dim {LABEL_COLOR}")
text.append("\n")
# Top border
text.append("" + "───┬" * 7 + "───┐\n", style="dim")
border_style = Style(color=BORDER_COLOR)
text.append("", style=border_style)
text.append("═══╤" * 7 + "═══╗\n", style=border_style)
ranks = range(7, -1, -1) if not self.flipped else range(8)
for rank_idx, rank in enumerate(ranks):
# Rank label
text.append(f"{rank + 1}", style="dim")
text.append(f"{rank + 1}", style=f"dim {LABEL_COLOR}")
file_range = range(8) if not self.flipped else range(7, -1, -1)
for file in file_range:
for file_idx, file in enumerate(file_range):
square = chess.square(file, rank)
piece = self.board.piece_at(square)
# Determine square color
# Determine square styling
is_light = (rank + file) % 2 == 1
is_selected = square == self.selected_square
is_legal_target = square in self.legal_moves
is_last_move = self.last_move and square in self.last_move
if is_selected:
bg_color = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED
elif is_legal_target or is_last_move:
bg_color = HIGHLIGHT_MOVE
else:
bg_color = LIGHT_SQUARE if is_light else DARK_SQUARE
# Get piece character
if piece:
char = PIECES.get(piece.symbol(), '?')
fg_color = WHITE_PIECE if piece.color == chess.WHITE else BLACK_PIECE
bg = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED
elif is_last_move:
bg = HIGHLIGHT_LAST_MOVE
elif is_legal_target:
char = '·'
fg_color = "#888888"
bg = HIGHLIGHT_LEGAL
else:
bg = LIGHT_SQUARE if is_light else DARK_SQUARE
# Piece or empty
if piece:
char = PIECES_OUTLINE.get(piece.symbol(), '?')
fg = WHITE_PIECE if piece.color == chess.WHITE else BLACK_PIECE
elif is_legal_target:
char = ''
fg = "#505050"
else:
char = ' '
fg_color = WHITE_PIECE
fg = WHITE_PIECE
style = Style(color=fg_color, bgcolor=bg_color)
style = Style(color=fg, bgcolor=bg, bold=True)
text.append(f" {char} ", style=style)
if file != (0 if self.flipped else 7):
text.append("", style="dim")
# Cell separator
if file_idx < 7:
text.append("", style=border_style)
text.append(f"{rank + 1}\n", style="dim")
text.append(f"{rank + 1}\n", style=f"dim {LABEL_COLOR}")
# Row separator
if rank_idx < 7:
text.append(" " + "───┼" * 7 + "───┤\n", style="dim")
text.append(" ", style=border_style)
text.append("───┼" * 7 + "───╢\n", style=border_style)
# Bottom border
text.append(" " + "───┴" * 7 + "───┘\n", style="dim")
text.append(" ", style=border_style)
text.append("═══╧" * 7 + "═══╝\n", style=border_style)
# File labels
text.append(files, style="dim")
text.append(" ", style=f"dim {LABEL_COLOR}")
for f in files:
text.append(f" {f} ", style=f"dim {LABEL_COLOR}")
return text
def _render_standard(self) -> RenderableType:
"""Standard board with variable cell size."""
text = Text()
cw = self._cell_width
ch = self._cell_height
# Ensure odd width for centering
if cw % 2 == 0:
cw = max(3, cw - 1)
files = "abcdefgh" if not self.flipped else "hgfedcba"
pad = cw // 2
border_style = Style(color=BORDER_COLOR)
label_style = Style(color=LABEL_COLOR, dim=True)
# File labels - perfectly centered
text.append(" ", style=label_style)
for f in files:
text.append(" " * pad + f + " " * pad, style=label_style)
text.append(" ", style=label_style) # For border space
text.append("\n")
# Top border with double lines
cell_border = BOX['H'] * cw
text.append(" " + BOX['TL'], style=border_style)
for i in range(8):
text.append(cell_border, style=border_style)
if i < 7:
text.append(BOX['TJ'], style=border_style)
text.append(BOX['TR'] + "\n", style=border_style)
ranks = range(7, -1, -1) if not self.flipped else range(8)
for rank_idx, rank in enumerate(ranks):
# Multi-line cells
for cell_line in range(ch):
is_middle = cell_line == ch // 2
# Rank label (only on middle line)
if is_middle:
text.append(f"{rank + 1}", style=label_style)
else:
text.append(" ", style=label_style)
text.append(BOX['V'], style=border_style)
file_range = range(8) if not self.flipped else range(7, -1, -1)
for file_idx, file in enumerate(file_range):
square = chess.square(file, rank)
piece = self.board.piece_at(square)
# Square styling
is_light = (rank + file) % 2 == 1
is_selected = square == self.selected_square
is_legal_target = square in self.legal_moves
is_last_move = self.last_move and square in self.last_move
if is_selected:
bg = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED
elif is_last_move:
bg = HIGHLIGHT_LAST_MOVE
elif is_legal_target:
bg = HIGHLIGHT_LEGAL
else:
bg = LIGHT_SQUARE if is_light else DARK_SQUARE
# Content (only on middle line)
if is_middle:
if piece:
char = PIECES_OUTLINE.get(piece.symbol(), '?')
fg = WHITE_PIECE if piece.color == chess.WHITE else BLACK_PIECE
elif is_legal_target:
char = ''
fg = "#404040"
else:
char = ' '
fg = WHITE_PIECE
else:
char = ' '
fg = WHITE_PIECE
style = Style(color=fg, bgcolor=bg, bold=True)
cell_content = " " * pad + char + " " * pad
text.append(cell_content, style=style)
# Separator
if file_idx < 7:
text.append(BOX['v'], style=border_style)
# Right border and rank label
if is_middle:
text.append(f"{BOX['V']}{rank + 1}\n", style=label_style)
else:
text.append(f"{BOX['V']} \n", style=border_style)
# Row separator
if rank_idx < 7:
text.append(" " + BOX['LJ'], style=border_style)
for i in range(8):
text.append(BOX['h'] * cw, style=border_style)
if i < 7:
text.append(BOX['x'], style=border_style)
text.append(BOX['RJ'] + "\n", style=border_style)
# Bottom border
text.append(" " + BOX['BL'], style=border_style)
for i in range(8):
text.append(BOX['H'] * cw, style=border_style)
if i < 7:
text.append(BOX['BJ'], style=border_style)
text.append(BOX['BR'] + "\n", style=border_style)
# File labels
text.append(" ", style=label_style)
for f in files:
text.append(" " * pad + f + " " * pad, style=label_style)
text.append(" ", style=label_style)
return text