mirror of
https://github.com/ghndrx/shellmate.git
synced 2026-02-10 06:45:02 +00:00
feat: complete TUI with playable chess game vs AI
This commit is contained in:
62
src/shellmate/cli.py
Normal file
62
src/shellmate/cli.py
Normal 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()
|
||||||
@@ -1,69 +1,278 @@
|
|||||||
"""Main TUI application."""
|
"""Main TUI application."""
|
||||||
|
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.containers import Container, Horizontal, Vertical
|
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.binding import Binding
|
||||||
|
from textual.screen import Screen
|
||||||
|
import chess
|
||||||
|
|
||||||
from shellmate.tui.widgets.board import ChessBoard
|
from shellmate.tui.widgets import (
|
||||||
from shellmate.tui.widgets.move_list import MoveList
|
ChessBoardWidget,
|
||||||
from shellmate.tui.widgets.status import GameStatus
|
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):
|
class ShellMateApp(App):
|
||||||
"""ShellMate TUI Chess Application."""
|
"""ShellMate TUI Chess Application."""
|
||||||
|
|
||||||
|
TITLE = "ShellMate"
|
||||||
|
SUB_TITLE = "SSH into Chess Mastery"
|
||||||
|
|
||||||
CSS = """
|
CSS = """
|
||||||
Screen {
|
Screen {
|
||||||
layout: grid;
|
background: #0a0a0a;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
Binding("q", "quit", "Quit"),
|
Binding("q", "quit", "Quit"),
|
||||||
Binding("n", "new_game", "New Game"),
|
|
||||||
Binding("h", "hint", "Hint"),
|
|
||||||
Binding("u", "undo", "Undo"),
|
|
||||||
Binding("r", "resign", "Resign"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -75,108 +284,21 @@ class ShellMateApp(App):
|
|||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.username = username
|
self.username = username
|
||||||
self.mode = mode
|
self.initial_mode = mode
|
||||||
self._stdin = stdin
|
self._stdin = stdin
|
||||||
self._stdout = stdout
|
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:
|
def on_mount(self) -> None:
|
||||||
"""Called when app is mounted."""
|
"""Start with menu screen."""
|
||||||
self.title = "♟️ ShellMate"
|
self.push_screen(MenuScreen())
|
||||||
self.sub_title = f"Welcome, {self.username}!"
|
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
|
def main():
|
||||||
class ChessBoard(Static):
|
"""Run the app directly for testing."""
|
||||||
"""Chess board widget."""
|
app = ShellMateApp()
|
||||||
|
app.run()
|
||||||
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):
|
if __name__ == "__main__":
|
||||||
"""Move history widget."""
|
main()
|
||||||
|
|
||||||
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"
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
155
src/shellmate/tui/widgets/board.py
Normal file
155
src/shellmate/tui/widgets/board.py
Normal 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
|
||||||
80
src/shellmate/tui/widgets/menu.py
Normal file
80
src/shellmate/tui/widgets/menu.py
Normal 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))
|
||||||
74
src/shellmate/tui/widgets/move_input.py
Normal file
74
src/shellmate/tui/widgets/move_input.py
Normal 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
|
||||||
82
src/shellmate/tui/widgets/move_list.py
Normal file
82
src/shellmate/tui/widgets/move_list.py
Normal 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)
|
||||||
131
src/shellmate/tui/widgets/status.py
Normal file
131
src/shellmate/tui/widgets/status.py
Normal 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()
|
||||||
Reference in New Issue
Block a user