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 { GameScreen {
layout: grid; layout: grid;
grid-size: 2; grid-size: 2;
grid-columns: 2fr 1fr; grid-columns: 3fr 1fr;
padding: 1; grid-rows: 1fr;
padding: 0;
} }
#board-container { #board-container {
align: center middle; 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 { #sidebar {
padding: 1; 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 { #move-input {
dock: bottom; dock: bottom;
height: auto;
} }
.hint-text { .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.widget import Widget
from textual.reactive import reactive from textual.reactive import reactive
from textual.geometry import Size
from rich.text import Text from rich.text import Text
from rich.style import Style from rich.style import Style
from rich.console import RenderableType from rich.console import RenderableType
import chess import chess
# Unicode chess pieces # Unicode chess pieces - using filled variants for better visibility
PIECES = { 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': '',
'k': '', 'q': '', 'r': '', 'b': '', 'n': '', 'p': '', 'k': '', 'q': '', 'r': '', 'b': '', 'n': '', 'p': '',
} }
# Colors # Colors - refined palette
LIGHT_SQUARE = "#3d5a45" LIGHT_SQUARE = "#b8c6a0" # Sage green light
DARK_SQUARE = "#2d4235" DARK_SQUARE = "#6d8b5e" # Forest green dark
LIGHT_SQUARE_SELECTED = "#5d8a65" LIGHT_SQUARE_SELECTED = "#e8d44d" # Gold highlight
DARK_SQUARE_SELECTED = "#4d7255" DARK_SQUARE_SELECTED = "#d4c02a" # Darker gold
HIGHLIGHT_MOVE = "#6b8f71" HIGHLIGHT_LAST_MOVE = "#a8c878" # Bright green for last move
HIGHLIGHT_LEGAL = "#7ba35a" # Legal move dots
WHITE_PIECE = "#ffffff" WHITE_PIECE = "#ffffff"
BLACK_PIECE = "#1a1a1a" 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): class ChessBoardWidget(Widget):
"""Interactive chess board widget.""" """Interactive chess board widget with auto-sizing."""
DEFAULT_CSS = """ DEFAULT_CSS = """
ChessBoardWidget { ChessBoardWidget {
width: 42; width: 100%;
height: 20; height: 100%;
min-width: 34;
min-height: 18;
padding: 0; padding: 0;
} }
""" """
@@ -40,6 +64,8 @@ class ChessBoardWidget(Widget):
legal_moves: reactive[set] = reactive(set) legal_moves: reactive[set] = reactive(set)
last_move: reactive[tuple | None] = reactive(None) last_move: reactive[tuple | None] = reactive(None)
flipped: reactive[bool] = reactive(False) flipped: reactive[bool] = reactive(False)
use_heavy_border: reactive[bool] = reactive(True)
compact_mode: reactive[bool] = reactive(False)
def __init__( def __init__(
self, self,
@@ -48,71 +74,249 @@ class ChessBoardWidget(Widget):
): ):
super().__init__(**kwargs) super().__init__(**kwargs)
self.board = board or chess.Board() 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: def render(self) -> RenderableType:
"""Render the chess board.""" """Render the chess board with perfect alignment."""
text = Text() size = self.size
self._cell_width, self._cell_height = self._calculate_cell_size(size.width, size.height)
# File labels # Use compact mode for small terminals
files = " a b c d e f g h " if not self.flipped else " h g f e d c b a " if size.width < 40 or size.height < 20:
text.append(files + "\n", style="dim") 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
files = "abcdefgh" if not self.flipped else "hgfedcba"
# 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 # 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) ranks = range(7, -1, -1) if not self.flipped else range(8)
for rank_idx, rank in enumerate(ranks): for rank_idx, rank in enumerate(ranks):
# Rank label # 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) 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) square = chess.square(file, rank)
piece = self.board.piece_at(square) piece = self.board.piece_at(square)
# Determine square color # Determine square styling
is_light = (rank + file) % 2 == 1 is_light = (rank + file) % 2 == 1
is_selected = square == self.selected_square is_selected = square == self.selected_square
is_legal_target = square in self.legal_moves is_legal_target = square in self.legal_moves
is_last_move = self.last_move and square in self.last_move is_last_move = self.last_move and square in self.last_move
if is_selected: if is_selected:
bg_color = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED bg = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED
elif is_legal_target or is_last_move: elif is_last_move:
bg_color = HIGHLIGHT_MOVE bg = HIGHLIGHT_LAST_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
elif is_legal_target: elif is_legal_target:
char = '·' bg = HIGHLIGHT_LEGAL
fg_color = "#888888" 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: else:
char = ' ' 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) text.append(f" {char} ", style=style)
if file != (0 if self.flipped else 7): # Cell separator
text.append("", style="dim") 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 # Row separator
if rank_idx < 7: if rank_idx < 7:
text.append(" " + "───┼" * 7 + "───┤\n", style="dim") text.append(" ", style=border_style)
text.append("───┼" * 7 + "───╢\n", style=border_style)
# Bottom border # Bottom border
text.append(" " + "───┴" * 7 + "───┘\n", style="dim") text.append(" ", style=border_style)
text.append("═══╧" * 7 + "═══╝\n", style=border_style)
# File labels # 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 return text