From 63dc8deaf8b302823965798e5c6f7ea4e232ff52 Mon Sep 17 00:00:00 2001 From: Greg Hendrickson Date: Tue, 27 Jan 2026 15:30:39 +0000 Subject: [PATCH] feat: complete TUI with playable chess game vs AI --- src/shellmate/cli.py | 62 ++++ src/shellmate/tui/app.py | 410 +++++++++++++++--------- src/shellmate/tui/widgets/__init__.py | 15 + src/shellmate/tui/widgets/board.py | 155 +++++++++ src/shellmate/tui/widgets/menu.py | 80 +++++ src/shellmate/tui/widgets/move_input.py | 74 +++++ src/shellmate/tui/widgets/move_list.py | 82 +++++ src/shellmate/tui/widgets/status.py | 131 ++++++++ 8 files changed, 865 insertions(+), 144 deletions(-) create mode 100644 src/shellmate/cli.py create mode 100644 src/shellmate/tui/widgets/board.py create mode 100644 src/shellmate/tui/widgets/menu.py create mode 100644 src/shellmate/tui/widgets/move_input.py create mode 100644 src/shellmate/tui/widgets/move_list.py create mode 100644 src/shellmate/tui/widgets/status.py diff --git a/src/shellmate/cli.py b/src/shellmate/cli.py new file mode 100644 index 0000000..9e0b110 --- /dev/null +++ b/src/shellmate/cli.py @@ -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() diff --git a/src/shellmate/tui/app.py b/src/shellmate/tui/app.py index 7bbb38c..36637e6 100644 --- a/src/shellmate/tui/app.py +++ b/src/shellmate/tui/app.py @@ -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") -# 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 +def main(): + """Run the app directly for testing.""" + app = ShellMateApp() + app.run() -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() diff --git a/src/shellmate/tui/widgets/__init__.py b/src/shellmate/tui/widgets/__init__.py index e69de29..1044efe 100644 --- a/src/shellmate/tui/widgets/__init__.py +++ b/src/shellmate/tui/widgets/__init__.py @@ -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", +] diff --git a/src/shellmate/tui/widgets/board.py b/src/shellmate/tui/widgets/board.py new file mode 100644 index 0000000..453916f --- /dev/null +++ b/src/shellmate/tui/widgets/board.py @@ -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 diff --git a/src/shellmate/tui/widgets/menu.py b/src/shellmate/tui/widgets/menu.py new file mode 100644 index 0000000..6474f86 --- /dev/null +++ b/src/shellmate/tui/widgets/menu.py @@ -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)) diff --git a/src/shellmate/tui/widgets/move_input.py b/src/shellmate/tui/widgets/move_input.py new file mode 100644 index 0000000..938589f --- /dev/null +++ b/src/shellmate/tui/widgets/move_input.py @@ -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 diff --git a/src/shellmate/tui/widgets/move_list.py b/src/shellmate/tui/widgets/move_list.py new file mode 100644 index 0000000..e5965ab --- /dev/null +++ b/src/shellmate/tui/widgets/move_list.py @@ -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) diff --git a/src/shellmate/tui/widgets/status.py b/src/shellmate/tui/widgets/status.py new file mode 100644 index 0000000..902d8ac --- /dev/null +++ b/src/shellmate/tui/widgets/status.py @@ -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()