feat: complete TUI with playable chess game vs AI

This commit is contained in:
Greg Hendrickson
2026-01-27 15:30:39 +00:00
parent 732d4eb33f
commit 63dc8deaf8
8 changed files with 865 additions and 144 deletions

62
src/shellmate/cli.py Normal file
View File

@@ -0,0 +1,62 @@
"""CLI entry point for ShellMate."""
import argparse
import sys
def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
description="ShellMate - SSH into Chess Mastery",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
shellmate Launch the TUI
shellmate --mode ai Play against AI
shellmate --mode learn Start tutorial mode
"""
)
parser.add_argument(
"--mode", "-m",
choices=["play", "ai", "learn", "watch"],
default="play",
help="Game mode (default: play)"
)
parser.add_argument(
"--username", "-u",
default="guest",
help="Username (default: guest)"
)
parser.add_argument(
"--difficulty", "-d",
type=int,
choices=range(0, 21),
default=10,
metavar="0-20",
help="AI difficulty level (default: 10)"
)
parser.add_argument(
"--version", "-v",
action="store_true",
help="Show version"
)
args = parser.parse_args()
if args.version:
from shellmate import __version__
print(f"ShellMate v{__version__}")
sys.exit(0)
# Launch TUI
from shellmate.tui.app import ShellMateApp
app = ShellMateApp(username=args.username, mode=args.mode)
app.run()
if __name__ == "__main__":
main()

View File

@@ -1,69 +1,278 @@
"""Main TUI application."""
from typing import Any, Optional
from typing import Any
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Button, Footer, Header, Static, Label
from textual.widgets import Footer, Header, Static
from textual.binding import Binding
from textual.screen import Screen
import chess
from shellmate.tui.widgets.board import ChessBoard
from shellmate.tui.widgets.move_list import MoveList
from shellmate.tui.widgets.status import GameStatus
from shellmate.tui.widgets import (
ChessBoardWidget,
MoveInput,
MoveListWidget,
GameStatusWidget,
MainMenu,
)
from shellmate.tui.widgets.move_input import parse_move
class MenuScreen(Screen):
"""Main menu screen."""
def compose(self) -> ComposeResult:
yield Header()
yield MainMenu()
yield Footer()
def on_main_menu_mode_selected(self, event: MainMenu.ModeSelected) -> None:
"""Handle game mode selection."""
if event.mode == "vs_ai":
self.app.push_screen(GameScreen(opponent="ai"))
elif event.mode == "learn":
self.app.notify("Tutorial mode coming soon!")
elif event.mode == "spectate":
self.app.notify("Spectate mode coming soon!")
else:
self.app.notify(f"Mode '{event.mode}' coming soon!")
class GameScreen(Screen):
"""Main game screen."""
BINDINGS = [
Binding("escape", "back", "Back to Menu"),
Binding("f", "flip", "Flip Board"),
Binding("h", "hint", "Hint"),
Binding("u", "undo", "Undo"),
Binding("n", "new_game", "New Game"),
]
DEFAULT_CSS = """
GameScreen {
layout: grid;
grid-size: 2;
grid-columns: 2fr 1fr;
padding: 1;
}
#board-container {
align: center middle;
padding: 1;
}
#sidebar {
padding: 1;
}
#move-input {
dock: bottom;
}
.hint-text {
padding: 1;
background: #1a3a1a;
border: solid green;
margin: 1 0;
}
"""
def __init__(self, opponent: str = "ai", **kwargs):
super().__init__(**kwargs)
self.opponent = opponent
self.board = chess.Board()
self._hint_text: str | None = None
self._ai_engine = None
def compose(self) -> ComposeResult:
yield Header()
with Horizontal():
with Container(id="board-container"):
yield ChessBoardWidget(board=self.board, id="chess-board")
with Vertical(id="sidebar"):
yield GameStatusWidget(id="game-status")
yield MoveListWidget(id="move-list")
yield MoveInput(id="move-input")
yield Footer()
def on_mount(self) -> None:
"""Initialize game on mount."""
status = self.query_one("#game-status", GameStatusWidget)
status.set_players("You", "Stockfish" if self.opponent == "ai" else "Opponent")
status.update_from_board(self.board)
# Initialize AI if playing against computer
if self.opponent == "ai":
self._init_ai()
def _init_ai(self) -> None:
"""Initialize AI engine."""
try:
from shellmate.ai import ChessAI
self._ai_engine = ChessAI(skill_level=10)
except Exception as e:
self.notify(f"AI unavailable: {e}", severity="warning")
def on_move_input_move_submitted(self, event: MoveInput.MoveSubmitted) -> None:
"""Handle move submission."""
move = parse_move(self.board, event.move)
if move is None:
self.notify(f"Invalid move: {event.move}", severity="error")
return
self._make_move(move)
# AI response
if self.opponent == "ai" and not self.board.is_game_over() and self._ai_engine:
self._ai_move()
def on_move_input_command_submitted(self, event: MoveInput.CommandSubmitted) -> None:
"""Handle command submission."""
cmd = event.command.lower()
if cmd == "hint":
self.action_hint()
elif cmd == "resign":
self.notify("You resigned!")
self.app.pop_screen()
elif cmd == "flip":
self.action_flip()
elif cmd == "undo":
self.action_undo()
elif cmd == "new":
self.action_new_game()
else:
self.notify(f"Unknown command: /{cmd}", severity="warning")
def _make_move(self, move: chess.Move) -> None:
"""Execute a move on the board."""
# Get SAN before pushing
san = self.board.san(move)
# Make the move
self.board.push(move)
# Update widgets
board_widget = self.query_one("#chess-board", ChessBoardWidget)
board_widget.set_board(self.board)
board_widget.set_last_move(move)
board_widget.select_square(None)
move_list = self.query_one("#move-list", MoveListWidget)
move_list.add_move(san)
status = self.query_one("#game-status", GameStatusWidget)
status.update_from_board(self.board)
# Check game end
if self.board.is_checkmate():
winner = "Black" if self.board.turn == chess.WHITE else "White"
self.notify(f"Checkmate! {winner} wins!", severity="information")
elif self.board.is_stalemate():
self.notify("Stalemate! Game drawn.", severity="information")
elif self.board.is_check():
self.notify("Check!", severity="warning")
def _ai_move(self) -> None:
"""Make AI move."""
if not self._ai_engine:
return
try:
best_move_uci = self._ai_engine.get_best_move(self.board.fen())
move = chess.Move.from_uci(best_move_uci)
# Small delay for UX
self.set_timer(0.5, lambda: self._make_move(move))
# Update evaluation
analysis = self._ai_engine.analyze_position(self.board.fen(), depth=10)
status = self.query_one("#game-status", GameStatusWidget)
status.set_evaluation(analysis.evaluation)
except Exception as e:
self.notify(f"AI error: {e}", severity="error")
def action_back(self) -> None:
"""Return to menu."""
self.app.pop_screen()
def action_flip(self) -> None:
"""Flip the board."""
board_widget = self.query_one("#chess-board", ChessBoardWidget)
board_widget.flip()
def action_hint(self) -> None:
"""Get a hint."""
if self._ai_engine and self.board.turn == chess.WHITE:
hint = self._ai_engine.get_hint(self.board.fen())
self.notify(f"💡 {hint}")
else:
self.notify("Hints only available on your turn", severity="warning")
def action_undo(self) -> None:
"""Undo the last move (or last two if vs AI)."""
if len(self.board.move_stack) == 0:
self.notify("No moves to undo", severity="warning")
return
# Undo player move
self.board.pop()
move_list = self.query_one("#move-list", MoveListWidget)
move_list.undo()
# Also undo AI move if applicable
if self.opponent == "ai" and len(self.board.move_stack) > 0:
self.board.pop()
move_list.undo()
# Update display
board_widget = self.query_one("#chess-board", ChessBoardWidget)
board_widget.set_board(self.board)
board_widget.set_last_move(None)
status = self.query_one("#game-status", GameStatusWidget)
status.update_from_board(self.board)
self.notify("Move undone")
def action_new_game(self) -> None:
"""Start a new game."""
self.board = chess.Board()
board_widget = self.query_one("#chess-board", ChessBoardWidget)
board_widget.set_board(self.board)
board_widget.set_last_move(None)
move_list = self.query_one("#move-list", MoveListWidget)
move_list.clear()
status = self.query_one("#game-status", GameStatusWidget)
status.update_from_board(self.board)
status.set_evaluation(None)
self.notify("New game started!")
class ShellMateApp(App):
"""ShellMate TUI Chess Application."""
TITLE = "ShellMate"
SUB_TITLE = "SSH into Chess Mastery"
CSS = """
Screen {
layout: grid;
grid-size: 2;
grid-columns: 3fr 1fr;
}
#board-container {
height: 100%;
content-align: center middle;
}
#sidebar {
height: 100%;
padding: 1;
}
ChessBoard {
width: auto;
height: auto;
}
#move-list {
height: 1fr;
border: solid green;
}
#game-status {
height: auto;
padding: 1;
border: solid blue;
}
#controls {
height: auto;
padding: 1;
}
.menu-button {
width: 100%;
margin: 1 0;
background: #0a0a0a;
}
"""
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("n", "new_game", "New Game"),
Binding("h", "hint", "Hint"),
Binding("u", "undo", "Undo"),
Binding("r", "resign", "Resign"),
]
def __init__(
@@ -75,108 +284,21 @@ class ShellMateApp(App):
):
super().__init__()
self.username = username
self.mode = mode
self.initial_mode = mode
self._stdin = stdin
self._stdout = stdout
def compose(self) -> ComposeResult:
yield Header()
with Horizontal():
with Container(id="board-container"):
yield ChessBoard(id="chess-board")
with Vertical(id="sidebar"):
yield GameStatus(id="game-status")
yield MoveList(id="move-list")
with Container(id="controls"):
yield Button("New Game", id="btn-new", classes="menu-button")
yield Button("vs AI", id="btn-ai", classes="menu-button")
yield Button("vs Player", id="btn-pvp", classes="menu-button")
yield Button("Tutorial", id="btn-learn", classes="menu-button")
yield Footer()
def on_mount(self) -> None:
"""Called when app is mounted."""
self.title = "♟️ ShellMate"
"""Start with menu screen."""
self.push_screen(MenuScreen())
self.sub_title = f"Welcome, {self.username}!"
def action_new_game(self) -> None:
"""Start a new game."""
board = self.query_one("#chess-board", ChessBoard)
board.new_game()
self.notify("New game started!")
def action_hint(self) -> None:
"""Get a hint from the AI."""
self.notify("Hint: Consider developing your pieces...")
def action_undo(self) -> None:
"""Undo the last move."""
board = self.query_one("#chess-board", ChessBoard)
if board.undo_move():
self.notify("Move undone")
else:
self.notify("No moves to undo")
def action_resign(self) -> None:
"""Resign the current game."""
self.notify("Game resigned")
def main():
"""Run the app directly for testing."""
app = ShellMateApp()
app.run()
# Placeholder widgets - to be implemented
class ChessBoard(Static):
"""Chess board widget."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._board_str = self._get_initial_board()
def _get_initial_board(self) -> str:
return """
a b c d e f g h
┌───┬───┬───┬───┬───┬───┬───┬───┐
8 │ ♜ │ ♞ │ ♝ │ ♛ │ ♚ │ ♝ │ ♞ │ ♜ │ 8
├───┼───┼───┼───┼───┼───┼───┼───┤
7 │ ♟ │ ♟ │ ♟ │ ♟ │ ♟ │ ♟ │ ♟ │ ♟ │ 7
├───┼───┼───┼───┼───┼───┼───┼───┤
6 │ │ │ │ │ │ │ │ │ 6
├───┼───┼───┼───┼───┼───┼───┼───┤
5 │ │ │ │ │ │ │ │ │ 5
├───┼───┼───┼───┼───┼───┼───┼───┤
4 │ │ │ │ │ │ │ │ │ 4
├───┼───┼───┼───┼───┼───┼───┼───┤
3 │ │ │ │ │ │ │ │ │ 3
├───┼───┼───┼───┼───┼───┼───┼───┤
2 │ ♙ │ ♙ │ ♙ │ ♙ │ ♙ │ ♙ │ ♙ │ ♙ │ 2
├───┼───┼───┼───┼───┼───┼───┼───┤
1 │ ♖ │ ♘ │ ♗ │ ♕ │ ♔ │ ♗ │ ♘ │ ♖ │ 1
└───┴───┴───┴───┴───┴───┴───┴───┘
a b c d e f g h
"""
def render(self) -> str:
return self._board_str
def new_game(self) -> None:
self._board_str = self._get_initial_board()
self.refresh()
def undo_move(self) -> bool:
return False # TODO: Implement
class MoveList(Static):
"""Move history widget."""
def render(self) -> str:
return "Move History\n─────────────\n1. e4 e5\n2. Nf3 Nc6\n3. ..."
class GameStatus(Static):
"""Game status widget."""
def render(self) -> str:
return "⚪ White to move\n\n🕐 10:00 | 10:00"
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,15 @@
"""TUI widgets for ShellMate."""
from .board import ChessBoardWidget
from .move_input import MoveInput
from .move_list import MoveListWidget
from .status import GameStatusWidget
from .menu import MainMenu
__all__ = [
"ChessBoardWidget",
"MoveInput",
"MoveListWidget",
"GameStatusWidget",
"MainMenu",
]

View File

@@ -0,0 +1,155 @@
"""Chess board widget with Unicode pieces."""
from textual.widget import Widget
from textual.reactive import reactive
from rich.text import Text
from rich.style import Style
from rich.console import RenderableType
import chess
# Unicode chess pieces
PIECES = {
'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"
WHITE_PIECE = "#ffffff"
BLACK_PIECE = "#1a1a1a"
class ChessBoardWidget(Widget):
"""Interactive chess board widget."""
DEFAULT_CSS = """
ChessBoardWidget {
width: 42;
height: 20;
padding: 0;
}
"""
# Reactive properties
selected_square: reactive[int | None] = reactive(None)
legal_moves: reactive[set] = reactive(set)
last_move: reactive[tuple | None] = reactive(None)
flipped: reactive[bool] = reactive(False)
def __init__(
self,
board: chess.Board | None = None,
**kwargs
):
super().__init__(**kwargs)
self.board = board or chess.Board()
def render(self) -> RenderableType:
"""Render the chess board."""
text = Text()
# File labels
files = " a b c d e f g h " if not self.flipped else " h g f e d c b a "
text.append(files + "\n", style="dim")
# Top border
text.append("" + "───┬" * 7 + "───┐\n", style="dim")
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")
file_range = range(8) if not self.flipped else range(7, -1, -1)
for file in file_range:
square = chess.square(file, rank)
piece = self.board.piece_at(square)
# Determine square color
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
elif is_legal_target:
char = '·'
fg_color = "#888888"
else:
char = ' '
fg_color = WHITE_PIECE
style = Style(color=fg_color, bgcolor=bg_color)
text.append(f" {char} ", style=style)
if file != (0 if self.flipped else 7):
text.append("", style="dim")
text.append(f"{rank + 1}\n", style="dim")
# Row separator
if rank_idx < 7:
text.append("" + "───┼" * 7 + "───┤\n", style="dim")
# Bottom border
text.append("" + "───┴" * 7 + "───┘\n", style="dim")
# File labels
text.append(files, style="dim")
return text
def set_board(self, board: chess.Board) -> None:
"""Update the board state."""
self.board = board
self.refresh()
def select_square(self, square: int | None) -> None:
"""Select a square and show legal moves from it."""
self.selected_square = square
if square is not None:
self.legal_moves = {
move.to_square
for move in self.board.legal_moves
if move.from_square == square
}
else:
self.legal_moves = set()
self.refresh()
def set_last_move(self, move: chess.Move | None) -> None:
"""Highlight the last move played."""
if move:
self.last_move = (move.from_square, move.to_square)
else:
self.last_move = None
self.refresh()
def flip(self) -> None:
"""Flip the board perspective."""
self.flipped = not self.flipped
self.refresh()
def square_from_coords(self, file: str, rank: str) -> int | None:
"""Convert algebraic notation to square index."""
try:
return chess.parse_square(f"{file}{rank}")
except ValueError:
return None

View File

@@ -0,0 +1,80 @@
"""Main menu widget."""
from textual.widget import Widget
from textual.widgets import Button, Static
from textual.containers import Vertical, Center
from textual.message import Message
from rich.text import Text
from rich.console import RenderableType
class MainMenu(Widget):
"""Main menu for game mode selection."""
DEFAULT_CSS = """
MainMenu {
width: 100%;
height: 100%;
align: center middle;
}
MainMenu > Vertical {
width: 50;
height: auto;
padding: 2;
border: round #333;
background: #111;
}
MainMenu .title {
text-align: center;
padding: 1 0 2 0;
}
MainMenu Button {
width: 100%;
margin: 1 0;
}
MainMenu .subtitle {
text-align: center;
color: #666;
padding: 1 0;
}
"""
class ModeSelected(Message):
"""Emitted when a game mode is selected."""
def __init__(self, mode: str) -> None:
super().__init__()
self.mode = mode
LOGO = """
┌─────────────────────────────┐
│ ♟️ S H E L L M A T E │
│ SSH into Chess Mastery │
└─────────────────────────────┘
"""
def compose(self):
with Vertical():
yield Static(self.LOGO, classes="title")
yield Button("⚔️ Play vs AI", id="btn-ai", variant="primary")
yield Button("👥 Play vs Player", id="btn-pvp", variant="default")
yield Button("🎯 Quick Match", id="btn-quick", variant="default")
yield Button("📚 Learn Chess", id="btn-learn", variant="default")
yield Button("👀 Spectate", id="btn-watch", variant="default")
yield Static("Press Q to quit", classes="subtitle")
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button press."""
mode_map = {
"btn-ai": "vs_ai",
"btn-pvp": "vs_player",
"btn-quick": "quick_match",
"btn-learn": "learn",
"btn-watch": "spectate",
}
mode = mode_map.get(event.button.id)
if mode:
self.post_message(self.ModeSelected(mode))

View File

@@ -0,0 +1,74 @@
"""Move input widget."""
from textual.widgets import Input
from textual.message import Message
import chess
class MoveInput(Input):
"""Input widget for chess moves."""
DEFAULT_CSS = """
MoveInput {
dock: bottom;
width: 100%;
margin: 1 0;
}
"""
class MoveSubmitted(Message):
"""Message sent when a move is submitted."""
def __init__(self, move: str) -> None:
super().__init__()
self.move = move
class CommandSubmitted(Message):
"""Message sent when a command is submitted."""
def __init__(self, command: str) -> None:
super().__init__()
self.command = command
def __init__(self, **kwargs):
super().__init__(
placeholder="Enter move (e.g., e4, Nf3, O-O) or command (/hint, /resign)",
**kwargs
)
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle input submission."""
value = event.value.strip()
if not value:
return
if value.startswith('/'):
# It's a command
self.post_message(self.CommandSubmitted(value[1:]))
else:
# It's a move
self.post_message(self.MoveSubmitted(value))
self.value = ""
def parse_move(board: chess.Board, move_str: str) -> chess.Move | None:
"""Parse a move string into a chess.Move object."""
move_str = move_str.strip()
# Try UCI format first (e2e4)
try:
move = chess.Move.from_uci(move_str)
if move in board.legal_moves:
return move
except ValueError:
pass
# Try SAN format (e4, Nf3, O-O)
try:
move = board.parse_san(move_str)
if move in board.legal_moves:
return move
except ValueError:
pass
return None

View File

@@ -0,0 +1,82 @@
"""Move list/history widget."""
from textual.widget import Widget
from textual.reactive import reactive
from rich.text import Text
from rich.console import RenderableType
import chess
class MoveListWidget(Widget):
"""Display the move history in standard notation."""
DEFAULT_CSS = """
MoveListWidget {
width: 100%;
height: auto;
min-height: 10;
padding: 1;
border: solid #333;
}
"""
moves: reactive[list] = reactive(list, always_update=True)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._moves: list[str] = []
def render(self) -> RenderableType:
"""Render the move list."""
text = Text()
text.append("Move History\n", style="bold underline")
text.append("" * 20 + "\n", style="dim")
if not self._moves:
text.append("No moves yet", style="dim italic")
return text
# Display moves in pairs (white, black)
for i in range(0, len(self._moves), 2):
move_num = i // 2 + 1
white_move = self._moves[i]
black_move = self._moves[i + 1] if i + 1 < len(self._moves) else ""
text.append(f"{move_num:>3}. ", style="dim")
text.append(f"{white_move:<8}", style="bold white")
if black_move:
text.append(f"{black_move:<8}", style="bold green")
text.append("\n")
return text
def add_move(self, move_san: str) -> None:
"""Add a move to the history."""
self._moves.append(move_san)
self.refresh()
def clear(self) -> None:
"""Clear the move history."""
self._moves = []
self.refresh()
def undo(self) -> str | None:
"""Remove and return the last move."""
if self._moves:
move = self._moves.pop()
self.refresh()
return move
return None
def get_pgn_moves(self) -> str:
"""Get moves in PGN format."""
result = []
for i in range(0, len(self._moves), 2):
move_num = i // 2 + 1
white = self._moves[i]
black = self._moves[i + 1] if i + 1 < len(self._moves) else ""
if black:
result.append(f"{move_num}. {white} {black}")
else:
result.append(f"{move_num}. {white}")
return " ".join(result)

View File

@@ -0,0 +1,131 @@
"""Game status widget."""
from textual.widget import Widget
from textual.reactive import reactive
from rich.text import Text
from rich.panel import Panel
from rich.console import RenderableType
import chess
class GameStatusWidget(Widget):
"""Display current game status."""
DEFAULT_CSS = """
GameStatusWidget {
width: 100%;
height: auto;
padding: 1;
border: solid #333;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._turn = chess.WHITE
self._is_check = False
self._is_checkmate = False
self._is_stalemate = False
self._is_draw = False
self._white_name = "White"
self._black_name = "Black"
self._white_time: int | None = None # seconds
self._black_time: int | None = None
self._evaluation: float | None = None
def render(self) -> RenderableType:
"""Render the status panel."""
text = Text()
# Player indicator
turn_indicator = "" if self._turn == chess.WHITE else ""
turn_name = self._white_name if self._turn == chess.WHITE else self._black_name
if self._is_checkmate:
winner = self._black_name if self._turn == chess.WHITE else self._white_name
text.append(f"♚ CHECKMATE!\n", style="bold red")
text.append(f"{winner} wins!", style="bold yellow")
elif self._is_stalemate:
text.append("½ STALEMATE\n", style="bold yellow")
text.append("Game drawn", style="dim")
elif self._is_draw:
text.append("½ DRAW\n", style="bold yellow")
text.append("Game drawn", style="dim")
else:
text.append(f"{turn_indicator} {turn_name}'s turn\n", style="bold")
if self._is_check:
text.append("⚠ CHECK!\n", style="bold red")
# Time controls (if set)
if self._white_time is not None or self._black_time is not None:
text.append("\n")
white_time_str = self._format_time(self._white_time)
black_time_str = self._format_time(self._black_time)
white_style = "bold" if self._turn == chess.WHITE else "dim"
black_style = "bold" if self._turn == chess.BLACK else "dim"
text.append(f"{white_time_str}", style=white_style)
text.append("", style="dim")
text.append(f"{black_time_str}", style=black_style)
# Evaluation (if available)
if self._evaluation is not None and not (self._is_checkmate or self._is_stalemate or self._is_draw):
text.append("\n\n")
eval_str = f"+{self._evaluation:.1f}" if self._evaluation > 0 else f"{self._evaluation:.1f}"
bar = self._eval_bar(self._evaluation)
text.append(f"Eval: {eval_str}\n", style="dim")
text.append(bar)
return text
def _format_time(self, seconds: int | None) -> str:
"""Format seconds as MM:SS."""
if seconds is None:
return "--:--"
mins = seconds // 60
secs = seconds % 60
return f"{mins:02d}:{secs:02d}"
def _eval_bar(self, evaluation: float) -> Text:
"""Create a visual evaluation bar."""
text = Text()
bar_width = 20
# Clamp evaluation to -5 to +5 for display
clamped = max(-5, min(5, evaluation))
# Convert to 0-1 scale (0.5 = equal)
normalized = (clamped + 5) / 10
white_chars = int(normalized * bar_width)
black_chars = bar_width - white_chars
text.append("" * white_chars, style="white")
text.append("" * black_chars, style="green")
return text
def update_from_board(self, board: chess.Board) -> None:
"""Update status from a board state."""
self._turn = board.turn
self._is_check = board.is_check()
self._is_checkmate = board.is_checkmate()
self._is_stalemate = board.is_stalemate()
self._is_draw = board.is_insufficient_material() or board.can_claim_draw()
self.refresh()
def set_players(self, white: str, black: str) -> None:
"""Set player names."""
self._white_name = white
self._black_name = black
self.refresh()
def set_time(self, white_seconds: int | None, black_seconds: int | None) -> None:
"""Set time remaining."""
self._white_time = white_seconds
self._black_time = black_seconds
self.refresh()
def set_evaluation(self, evaluation: float | None) -> None:
"""Set position evaluation."""
self._evaluation = evaluation
self.refresh()