mirror of
https://github.com/ghndrx/shellmate.git
synced 2026-02-10 06:45:02 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user