mirror of
https://github.com/ghndrx/shellmate.git
synced 2026-02-10 06:45:02 +00:00
Fix linting: remove trailing whitespace
This commit is contained in:
@@ -19,7 +19,7 @@ class MoveAnalysis:
|
|||||||
|
|
||||||
class ChessAI:
|
class ChessAI:
|
||||||
"""Chess AI powered by Stockfish with explanations."""
|
"""Chess AI powered by Stockfish with explanations."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
stockfish_path: str = "/usr/bin/stockfish",
|
stockfish_path: str = "/usr/bin/stockfish",
|
||||||
@@ -30,35 +30,35 @@ class ChessAI:
|
|||||||
self.engine.set_skill_level(skill_level)
|
self.engine.set_skill_level(skill_level)
|
||||||
self.think_time_ms = think_time_ms
|
self.think_time_ms = think_time_ms
|
||||||
self._skill_level = skill_level
|
self._skill_level = skill_level
|
||||||
|
|
||||||
def set_difficulty(self, level: int) -> None:
|
def set_difficulty(self, level: int) -> None:
|
||||||
"""Set AI difficulty (0-20)."""
|
"""Set AI difficulty (0-20)."""
|
||||||
self._skill_level = max(0, min(20, level))
|
self._skill_level = max(0, min(20, level))
|
||||||
self.engine.set_skill_level(self._skill_level)
|
self.engine.set_skill_level(self._skill_level)
|
||||||
|
|
||||||
def get_best_move(self, fen: str) -> str:
|
def get_best_move(self, fen: str) -> str:
|
||||||
"""Get the best move for the current position."""
|
"""Get the best move for the current position."""
|
||||||
self.engine.set_fen_position(fen)
|
self.engine.set_fen_position(fen)
|
||||||
return self.engine.get_best_move_time(self.think_time_ms)
|
return self.engine.get_best_move_time(self.think_time_ms)
|
||||||
|
|
||||||
def analyze_position(self, fen: str, depth: int = 15) -> MoveAnalysis:
|
def analyze_position(self, fen: str, depth: int = 15) -> MoveAnalysis:
|
||||||
"""Analyze a position and return detailed analysis."""
|
"""Analyze a position and return detailed analysis."""
|
||||||
self.engine.set_fen_position(fen)
|
self.engine.set_fen_position(fen)
|
||||||
self.engine.set_depth(depth)
|
self.engine.set_depth(depth)
|
||||||
|
|
||||||
evaluation = self.engine.get_evaluation()
|
evaluation = self.engine.get_evaluation()
|
||||||
best_move = self.engine.get_best_move()
|
best_move = self.engine.get_best_move()
|
||||||
top_moves = self.engine.get_top_moves(3)
|
top_moves = self.engine.get_top_moves(3)
|
||||||
|
|
||||||
# Convert evaluation to centipawns
|
# Convert evaluation to centipawns
|
||||||
if evaluation["type"] == "cp":
|
if evaluation["type"] == "cp":
|
||||||
eval_cp = evaluation["value"]
|
eval_cp = evaluation["value"]
|
||||||
else: # mate
|
else: # mate
|
||||||
eval_cp = 10000 if evaluation["value"] > 0 else -10000
|
eval_cp = 10000 if evaluation["value"] > 0 else -10000
|
||||||
|
|
||||||
# Generate explanation
|
# Generate explanation
|
||||||
explanation = self._generate_explanation(fen, best_move, eval_cp, top_moves)
|
explanation = self._generate_explanation(fen, best_move, eval_cp, top_moves)
|
||||||
|
|
||||||
return MoveAnalysis(
|
return MoveAnalysis(
|
||||||
best_move=best_move,
|
best_move=best_move,
|
||||||
evaluation=eval_cp / 100, # convert to pawns
|
evaluation=eval_cp / 100, # convert to pawns
|
||||||
@@ -66,7 +66,7 @@ class ChessAI:
|
|||||||
pv=[m["Move"] for m in top_moves] if top_moves else [best_move],
|
pv=[m["Move"] for m in top_moves] if top_moves else [best_move],
|
||||||
explanation=explanation,
|
explanation=explanation,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _generate_explanation(
|
def _generate_explanation(
|
||||||
self,
|
self,
|
||||||
fen: str,
|
fen: str,
|
||||||
@@ -79,19 +79,19 @@ class ChessAI:
|
|||||||
move = chess.Move.from_uci(best_move)
|
move = chess.Move.from_uci(best_move)
|
||||||
san = board.san(move)
|
san = board.san(move)
|
||||||
piece = board.piece_at(move.from_square)
|
piece = board.piece_at(move.from_square)
|
||||||
|
|
||||||
explanations = []
|
explanations = []
|
||||||
|
|
||||||
# Describe the move
|
# Describe the move
|
||||||
piece_name = chess.piece_name(piece.piece_type).capitalize() if piece else "Piece"
|
piece_name = chess.piece_name(piece.piece_type).capitalize() if piece else "Piece"
|
||||||
explanations.append(f"{piece_name} to {chess.square_name(move.to_square)} ({san})")
|
explanations.append(f"{piece_name} to {chess.square_name(move.to_square)} ({san})")
|
||||||
|
|
||||||
# Check for captures
|
# Check for captures
|
||||||
if board.is_capture(move):
|
if board.is_capture(move):
|
||||||
captured = board.piece_at(move.to_square)
|
captured = board.piece_at(move.to_square)
|
||||||
if captured:
|
if captured:
|
||||||
explanations.append(f"Captures {chess.piece_name(captured.piece_type)}")
|
explanations.append(f"Captures {chess.piece_name(captured.piece_type)}")
|
||||||
|
|
||||||
# Check for checks
|
# Check for checks
|
||||||
board.push(move)
|
board.push(move)
|
||||||
if board.is_check():
|
if board.is_check():
|
||||||
@@ -100,7 +100,7 @@ class ChessAI:
|
|||||||
else:
|
else:
|
||||||
explanations.append("Puts the king in check")
|
explanations.append("Puts the king in check")
|
||||||
board.pop()
|
board.pop()
|
||||||
|
|
||||||
# Evaluation context
|
# Evaluation context
|
||||||
if abs(eval_cp) < 50:
|
if abs(eval_cp) < 50:
|
||||||
explanations.append("Position is roughly equal")
|
explanations.append("Position is roughly equal")
|
||||||
@@ -112,22 +112,22 @@ class ChessAI:
|
|||||||
explanations.append("White is slightly better")
|
explanations.append("White is slightly better")
|
||||||
else:
|
else:
|
||||||
explanations.append("Black is slightly better")
|
explanations.append("Black is slightly better")
|
||||||
|
|
||||||
return ". ".join(explanations) + "."
|
return ". ".join(explanations) + "."
|
||||||
|
|
||||||
def explain_move(self, fen_before: str, move_uci: str) -> str:
|
def explain_move(self, fen_before: str, move_uci: str) -> str:
|
||||||
"""Explain why a specific move is good or bad."""
|
"""Explain why a specific move is good or bad."""
|
||||||
analysis_before = self.analyze_position(fen_before)
|
analysis_before = self.analyze_position(fen_before)
|
||||||
|
|
||||||
board = chess.Board(fen_before)
|
board = chess.Board(fen_before)
|
||||||
move = chess.Move.from_uci(move_uci)
|
move = chess.Move.from_uci(move_uci)
|
||||||
san = board.san(move)
|
san = board.san(move)
|
||||||
board.push(move)
|
board.push(move)
|
||||||
|
|
||||||
analysis_after = self.analyze_position(board.fen())
|
analysis_after = self.analyze_position(board.fen())
|
||||||
|
|
||||||
eval_change = analysis_before.evaluation - analysis_after.evaluation
|
eval_change = analysis_before.evaluation - analysis_after.evaluation
|
||||||
|
|
||||||
if move_uci == analysis_before.best_move:
|
if move_uci == analysis_before.best_move:
|
||||||
quality = "This is the best move in this position!"
|
quality = "This is the best move in this position!"
|
||||||
elif abs(eval_change) < 0.3:
|
elif abs(eval_change) < 0.3:
|
||||||
@@ -138,21 +138,21 @@ class ChessAI:
|
|||||||
quality = "A slight inaccuracy - there was a better option."
|
quality = "A slight inaccuracy - there was a better option."
|
||||||
else:
|
else:
|
||||||
quality = "A good move!"
|
quality = "A good move!"
|
||||||
|
|
||||||
return f"{san}: {quality} {analysis_before.explanation}"
|
return f"{san}: {quality} {analysis_before.explanation}"
|
||||||
|
|
||||||
def get_hint(self, fen: str) -> str:
|
def get_hint(self, fen: str) -> str:
|
||||||
"""Get a hint for the current position."""
|
"""Get a hint for the current position."""
|
||||||
analysis = self.analyze_position(fen)
|
analysis = self.analyze_position(fen)
|
||||||
board = chess.Board(fen)
|
board = chess.Board(fen)
|
||||||
move = chess.Move.from_uci(analysis.best_move)
|
move = chess.Move.from_uci(analysis.best_move)
|
||||||
piece = board.piece_at(move.from_square)
|
piece = board.piece_at(move.from_square)
|
||||||
|
|
||||||
if piece:
|
if piece:
|
||||||
piece_name = chess.piece_name(piece.piece_type).capitalize()
|
piece_name = chess.piece_name(piece.piece_type).capitalize()
|
||||||
return f"Consider moving your {piece_name}..."
|
return f"Consider moving your {piece_name}..."
|
||||||
return "Look for tactical opportunities..."
|
return "Look for tactical opportunities..."
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""Clean up engine resources."""
|
"""Clean up engine resources."""
|
||||||
del self.engine
|
del self.engine
|
||||||
|
|||||||
@@ -16,20 +16,20 @@ Examples:
|
|||||||
shellmate --mode learn Start tutorial mode
|
shellmate --mode learn Start tutorial mode
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--mode", "-m",
|
"--mode", "-m",
|
||||||
choices=["play", "ai", "learn", "watch"],
|
choices=["play", "ai", "learn", "watch"],
|
||||||
default="play",
|
default="play",
|
||||||
help="Game mode (default: play)"
|
help="Game mode (default: play)"
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--username", "-u",
|
"--username", "-u",
|
||||||
default="guest",
|
default="guest",
|
||||||
help="Username (default: guest)"
|
help="Username (default: guest)"
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--difficulty", "-d",
|
"--difficulty", "-d",
|
||||||
type=int,
|
type=int,
|
||||||
@@ -38,20 +38,20 @@ Examples:
|
|||||||
metavar="0-20",
|
metavar="0-20",
|
||||||
help="AI difficulty level (default: 10)"
|
help="AI difficulty level (default: 10)"
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--version", "-v",
|
"--version", "-v",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Show version"
|
help="Show version"
|
||||||
)
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.version:
|
if args.version:
|
||||||
from shellmate import __version__
|
from shellmate import __version__
|
||||||
print(f"ShellMate v{__version__}")
|
print(f"ShellMate v{__version__}")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
# Launch TUI
|
# Launch TUI
|
||||||
from shellmate.tui.app import ShellMateApp
|
from shellmate.tui.app import ShellMateApp
|
||||||
app = ShellMateApp(username=args.username, mode=args.mode)
|
app = ShellMateApp(username=args.username, mode=args.mode)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class Move:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class ChessGame:
|
class ChessGame:
|
||||||
"""Chess game instance."""
|
"""Chess game instance."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
white_player_id: str
|
white_player_id: str
|
||||||
black_player_id: str
|
black_player_id: str
|
||||||
@@ -40,29 +40,29 @@ class ChessGame:
|
|||||||
moves: list[Move] = field(default_factory=list)
|
moves: list[Move] = field(default_factory=list)
|
||||||
result: GameResult = GameResult.IN_PROGRESS
|
result: GameResult = GameResult.IN_PROGRESS
|
||||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_turn(self) -> str:
|
def current_turn(self) -> str:
|
||||||
"""Return whose turn it is."""
|
"""Return whose turn it is."""
|
||||||
return "white" if self.board.turn == chess.WHITE else "black"
|
return "white" if self.board.turn == chess.WHITE else "black"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_player_id(self) -> str:
|
def current_player_id(self) -> str:
|
||||||
"""Return the ID of the player whose turn it is."""
|
"""Return the ID of the player whose turn it is."""
|
||||||
return self.white_player_id if self.board.turn == chess.WHITE else self.black_player_id
|
return self.white_player_id if self.board.turn == chess.WHITE else self.black_player_id
|
||||||
|
|
||||||
def make_move(self, uci: str) -> Optional[Move]:
|
def make_move(self, uci: str) -> Optional[Move]:
|
||||||
"""Make a move and return Move object if valid."""
|
"""Make a move and return Move object if valid."""
|
||||||
try:
|
try:
|
||||||
chess_move = chess.Move.from_uci(uci)
|
chess_move = chess.Move.from_uci(uci)
|
||||||
if chess_move not in self.board.legal_moves:
|
if chess_move not in self.board.legal_moves:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
fen_before = self.board.fen()
|
fen_before = self.board.fen()
|
||||||
san = self.board.san(chess_move)
|
san = self.board.san(chess_move)
|
||||||
self.board.push(chess_move)
|
self.board.push(chess_move)
|
||||||
fen_after = self.board.fen()
|
fen_after = self.board.fen()
|
||||||
|
|
||||||
move = Move(
|
move = Move(
|
||||||
uci=uci,
|
uci=uci,
|
||||||
san=san,
|
san=san,
|
||||||
@@ -74,15 +74,15 @@ class ChessGame:
|
|||||||
return move
|
return move
|
||||||
except (ValueError, chess.InvalidMoveError):
|
except (ValueError, chess.InvalidMoveError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_legal_moves(self) -> list[str]:
|
def get_legal_moves(self) -> list[str]:
|
||||||
"""Return list of legal moves in UCI format."""
|
"""Return list of legal moves in UCI format."""
|
||||||
return [move.uci() for move in self.board.legal_moves]
|
return [move.uci() for move in self.board.legal_moves]
|
||||||
|
|
||||||
def get_legal_moves_san(self) -> list[str]:
|
def get_legal_moves_san(self) -> list[str]:
|
||||||
"""Return list of legal moves in SAN format."""
|
"""Return list of legal moves in SAN format."""
|
||||||
return [self.board.san(move) for move in self.board.legal_moves]
|
return [self.board.san(move) for move in self.board.legal_moves]
|
||||||
|
|
||||||
def _check_game_end(self) -> None:
|
def _check_game_end(self) -> None:
|
||||||
"""Check if the game has ended and set result."""
|
"""Check if the game has ended and set result."""
|
||||||
if self.board.is_checkmate():
|
if self.board.is_checkmate():
|
||||||
@@ -91,31 +91,31 @@ class ChessGame:
|
|||||||
self.result = GameResult.DRAW
|
self.result = GameResult.DRAW
|
||||||
elif self.board.can_claim_draw():
|
elif self.board.can_claim_draw():
|
||||||
self.result = GameResult.DRAW
|
self.result = GameResult.DRAW
|
||||||
|
|
||||||
def is_check(self) -> bool:
|
def is_check(self) -> bool:
|
||||||
"""Return True if current player is in check."""
|
"""Return True if current player is in check."""
|
||||||
return self.board.is_check()
|
return self.board.is_check()
|
||||||
|
|
||||||
def is_game_over(self) -> bool:
|
def is_game_over(self) -> bool:
|
||||||
"""Return True if the game is over."""
|
"""Return True if the game is over."""
|
||||||
return self.result != GameResult.IN_PROGRESS
|
return self.result != GameResult.IN_PROGRESS
|
||||||
|
|
||||||
def to_pgn(self) -> str:
|
def to_pgn(self) -> str:
|
||||||
"""Export game as PGN string."""
|
"""Export game as PGN string."""
|
||||||
game = chess.pgn.Game()
|
game = chess.pgn.Game()
|
||||||
game.headers["White"] = self.white_player_id
|
game.headers["White"] = self.white_player_id
|
||||||
game.headers["Black"] = self.black_player_id
|
game.headers["Black"] = self.black_player_id
|
||||||
game.headers["Date"] = self.created_at.strftime("%Y.%m.%d")
|
game.headers["Date"] = self.created_at.strftime("%Y.%m.%d")
|
||||||
|
|
||||||
node = game
|
node = game
|
||||||
temp_board = chess.Board()
|
temp_board = chess.Board()
|
||||||
for move in self.moves:
|
for move in self.moves:
|
||||||
chess_move = chess.Move.from_uci(move.uci)
|
chess_move = chess.Move.from_uci(move.uci)
|
||||||
node = node.add_variation(chess_move)
|
node = node.add_variation(chess_move)
|
||||||
temp_board.push(chess_move)
|
temp_board.push(chess_move)
|
||||||
|
|
||||||
return str(game)
|
return str(game)
|
||||||
|
|
||||||
def get_board_display(self, perspective: str = "white") -> str:
|
def get_board_display(self, perspective: str = "white") -> str:
|
||||||
"""Return ASCII board from given perspective."""
|
"""Return ASCII board from given perspective."""
|
||||||
board_str = str(self.board)
|
board_str = str(self.board)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class PlayerType(Enum):
|
|||||||
@dataclass
|
@dataclass
|
||||||
class Player:
|
class Player:
|
||||||
"""Represents a player."""
|
"""Represents a player."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
username: str
|
username: str
|
||||||
player_type: PlayerType = PlayerType.HUMAN
|
player_type: PlayerType = PlayerType.HUMAN
|
||||||
@@ -26,14 +26,14 @@ class Player:
|
|||||||
draws: int = 0
|
draws: int = 0
|
||||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||||
last_seen: Optional[datetime] = None
|
last_seen: Optional[datetime] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def winrate(self) -> float:
|
def winrate(self) -> float:
|
||||||
"""Calculate win rate percentage."""
|
"""Calculate win rate percentage."""
|
||||||
if self.games_played == 0:
|
if self.games_played == 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
return (self.wins / self.games_played) * 100
|
return (self.wins / self.games_played) * 100
|
||||||
|
|
||||||
def update_elo(self, opponent_elo: int, result: float, k: int = 32) -> int:
|
def update_elo(self, opponent_elo: int, result: float, k: int = 32) -> int:
|
||||||
"""
|
"""
|
||||||
Update ELO rating based on game result.
|
Update ELO rating based on game result.
|
||||||
@@ -44,7 +44,7 @@ class Player:
|
|||||||
change = int(k * (result - expected))
|
change = int(k * (result - expected))
|
||||||
self.elo += change
|
self.elo += change
|
||||||
return change
|
return change
|
||||||
|
|
||||||
def record_game(self, won: bool, draw: bool = False) -> None:
|
def record_game(self, won: bool, draw: bool = False) -> None:
|
||||||
"""Record a completed game."""
|
"""Record a completed game."""
|
||||||
self.games_played += 1
|
self.games_played += 1
|
||||||
@@ -57,12 +57,12 @@ class Player:
|
|||||||
self.last_seen = datetime.utcnow()
|
self.last_seen = datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AIPlayer(Player):
|
class AIPlayer(Player):
|
||||||
"""AI player with configurable difficulty."""
|
"""AI player with configurable difficulty."""
|
||||||
|
|
||||||
difficulty: int = 10 # Stockfish skill level 0-20
|
difficulty: int = 10 # Stockfish skill level 0-20
|
||||||
think_time_ms: int = 1000 # Time to think per move
|
think_time_ms: int = 1000 # Time to think per move
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
self.player_type = PlayerType.AI
|
self.player_type = PlayerType.AI
|
||||||
|
|||||||
@@ -14,48 +14,48 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class ShellMateSSHServer(asyncssh.SSHServer):
|
class ShellMateSSHServer(asyncssh.SSHServer):
|
||||||
"""SSH server that launches ShellMate TUI for each connection."""
|
"""SSH server that launches ShellMate TUI for each connection."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._username: Optional[str] = None
|
self._username: Optional[str] = None
|
||||||
|
|
||||||
def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:
|
def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:
|
||||||
peername = conn.get_extra_info('peername')
|
peername = conn.get_extra_info('peername')
|
||||||
logger.info(f"SSH connection from {peername}")
|
logger.info(f"SSH connection from {peername}")
|
||||||
|
|
||||||
def connection_lost(self, exc: Optional[Exception]) -> None:
|
def connection_lost(self, exc: Optional[Exception]) -> None:
|
||||||
if exc:
|
if exc:
|
||||||
logger.error(f"SSH connection error: {exc}")
|
logger.error(f"SSH connection error: {exc}")
|
||||||
else:
|
else:
|
||||||
logger.info("SSH connection closed")
|
logger.info("SSH connection closed")
|
||||||
|
|
||||||
def begin_auth(self, username: str) -> bool:
|
def begin_auth(self, username: str) -> bool:
|
||||||
self._username = username
|
self._username = username
|
||||||
# No auth required - instant connection
|
# No auth required - instant connection
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def password_auth_supported(self) -> bool:
|
def password_auth_supported(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def validate_password(self, username: str, password: str) -> bool:
|
def validate_password(self, username: str, password: str) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def public_key_auth_supported(self) -> bool:
|
def public_key_auth_supported(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool:
|
def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class TerminalSession:
|
class TerminalSession:
|
||||||
"""Manages terminal state for an SSH session."""
|
"""Manages terminal state for an SSH session."""
|
||||||
|
|
||||||
def __init__(self, process: asyncssh.SSHServerProcess):
|
def __init__(self, process: asyncssh.SSHServerProcess):
|
||||||
self.process = process
|
self.process = process
|
||||||
self.width = 80
|
self.width = 80
|
||||||
self.height = 24
|
self.height = 24
|
||||||
self._resize_event = asyncio.Event()
|
self._resize_event = asyncio.Event()
|
||||||
self._update_size()
|
self._update_size()
|
||||||
|
|
||||||
def _update_size(self):
|
def _update_size(self):
|
||||||
"""Update terminal size from process."""
|
"""Update terminal size from process."""
|
||||||
try:
|
try:
|
||||||
@@ -69,28 +69,28 @@ class TerminalSession:
|
|||||||
logger.debug(f"Terminal size updated: {self.width}x{self.height}")
|
logger.debug(f"Terminal size updated: {self.width}x{self.height}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not get terminal size: {e}")
|
logger.warning(f"Could not get terminal size: {e}")
|
||||||
|
|
||||||
def handle_resize(self, width, height, pixwidth, pixheight):
|
def handle_resize(self, width, height, pixwidth, pixheight):
|
||||||
"""Handle terminal resize event."""
|
"""Handle terminal resize event."""
|
||||||
logger.debug(f"Resize event: {width}x{height}")
|
logger.debug(f"Resize event: {width}x{height}")
|
||||||
self.width = max(width, 40)
|
self.width = max(width, 40)
|
||||||
self.height = max(height, 10)
|
self.height = max(height, 10)
|
||||||
self._resize_event.set()
|
self._resize_event.set()
|
||||||
|
|
||||||
def write(self, data: str):
|
def write(self, data: str):
|
||||||
"""Write string data to terminal."""
|
"""Write string data to terminal."""
|
||||||
if isinstance(data, str):
|
if isinstance(data, str):
|
||||||
data = data.encode('utf-8')
|
data = data.encode('utf-8')
|
||||||
self.process.stdout.write(data)
|
self.process.stdout.write(data)
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
"""Clear screen completely and move cursor home."""
|
"""Clear screen completely and move cursor home."""
|
||||||
# Reset scrolling region, clear entire screen, move to home
|
# Reset scrolling region, clear entire screen, move to home
|
||||||
self.write("\033[r\033[2J\033[3J\033[H")
|
self.write("\033[r\033[2J\033[3J\033[H")
|
||||||
|
|
||||||
def hide_cursor(self):
|
def hide_cursor(self):
|
||||||
self.write("\033[?25l")
|
self.write("\033[?25l")
|
||||||
|
|
||||||
def show_cursor(self):
|
def show_cursor(self):
|
||||||
self.write("\033[?25h")
|
self.write("\033[?25h")
|
||||||
|
|
||||||
@@ -98,13 +98,13 @@ class TerminalSession:
|
|||||||
async def handle_client(process: asyncssh.SSHServerProcess) -> None:
|
async def handle_client(process: asyncssh.SSHServerProcess) -> None:
|
||||||
"""Handle an SSH client session."""
|
"""Handle an SSH client session."""
|
||||||
username = process.get_extra_info("username") or "guest"
|
username = process.get_extra_info("username") or "guest"
|
||||||
|
|
||||||
# Create terminal session
|
# Create terminal session
|
||||||
session = TerminalSession(process)
|
session = TerminalSession(process)
|
||||||
|
|
||||||
term_type = process.get_terminal_type() or "xterm-256color"
|
term_type = process.get_terminal_type() or "xterm-256color"
|
||||||
logger.info(f"Client {username}: term={term_type}, size={session.width}x{session.height}")
|
logger.info(f"Client {username}: term={term_type}, size={session.width}x{session.height}")
|
||||||
|
|
||||||
# Determine mode
|
# Determine mode
|
||||||
if username == "learn":
|
if username == "learn":
|
||||||
mode = "tutorial"
|
mode = "tutorial"
|
||||||
@@ -112,7 +112,7 @@ async def handle_client(process: asyncssh.SSHServerProcess) -> None:
|
|||||||
mode = "spectate"
|
mode = "spectate"
|
||||||
else:
|
else:
|
||||||
mode = "play"
|
mode = "play"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session.hide_cursor()
|
session.hide_cursor()
|
||||||
await run_simple_menu(process, session, username, mode)
|
await run_simple_menu(process, session, username, mode)
|
||||||
@@ -139,7 +139,7 @@ async def run_simple_menu(process, session: TerminalSession, username: str, mode
|
|||||||
from rich.layout import Layout
|
from rich.layout import Layout
|
||||||
from rich.style import Style
|
from rich.style import Style
|
||||||
import io
|
import io
|
||||||
|
|
||||||
class ProcessWriter:
|
class ProcessWriter:
|
||||||
def __init__(self, sess):
|
def __init__(self, sess):
|
||||||
self._session = sess
|
self._session = sess
|
||||||
@@ -147,27 +147,27 @@ async def run_simple_menu(process, session: TerminalSession, username: str, mode
|
|||||||
self._session.write(data)
|
self._session.write(data)
|
||||||
def flush(self):
|
def flush(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def render_menu():
|
def render_menu():
|
||||||
"""Render the main menu centered on screen."""
|
"""Render the main menu centered on screen."""
|
||||||
# Re-fetch terminal size before rendering
|
# Re-fetch terminal size before rendering
|
||||||
session._update_size()
|
session._update_size()
|
||||||
|
|
||||||
writer = ProcessWriter(session)
|
writer = ProcessWriter(session)
|
||||||
console = Console(file=writer, width=session.width, height=session.height, force_terminal=True, color_system="truecolor")
|
console = Console(file=writer, width=session.width, height=session.height, force_terminal=True, color_system="truecolor")
|
||||||
|
|
||||||
session.clear()
|
session.clear()
|
||||||
|
|
||||||
# Calculate vertical padding for centering
|
# Calculate vertical padding for centering
|
||||||
menu_height = 22
|
menu_height = 22
|
||||||
top_pad = max(0, (session.height - menu_height) // 2)
|
top_pad = max(0, (session.height - menu_height) // 2)
|
||||||
|
|
||||||
for _ in range(top_pad):
|
for _ in range(top_pad):
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
# Chess piece decorations
|
# Chess piece decorations
|
||||||
pieces = "♔ ♕ ♖ ♗ ♘ ♙"
|
pieces = "♔ ♕ ♖ ♗ ♘ ♙"
|
||||||
|
|
||||||
# Title with gradient effect
|
# Title with gradient effect
|
||||||
if session.width >= 50:
|
if session.width >= 50:
|
||||||
console.print(Align.center(Text(pieces, style="dim white")))
|
console.print(Align.center(Text(pieces, style="dim white")))
|
||||||
@@ -178,9 +178,9 @@ async def run_simple_menu(process, session: TerminalSession, username: str, mode
|
|||||||
console.print(Align.center(Text(pieces[::-1], style="dim white")))
|
console.print(Align.center(Text(pieces[::-1], style="dim white")))
|
||||||
else:
|
else:
|
||||||
console.print(Align.center(Text("♟ SHELLMATE ♟", style="bold green")))
|
console.print(Align.center(Text("♟ SHELLMATE ♟", style="bold green")))
|
||||||
|
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
# Menu items as a table for better alignment
|
# Menu items as a table for better alignment
|
||||||
menu_table = Table(show_header=False, box=None, padding=(0, 2))
|
menu_table = Table(show_header=False, box=None, padding=(0, 2))
|
||||||
menu_table.add_column(justify="center")
|
menu_table.add_column(justify="center")
|
||||||
@@ -192,7 +192,7 @@ async def run_simple_menu(process, session: TerminalSession, username: str, mode
|
|||||||
menu_table.add_row("[bright_white on red] q [/bright_white on red] Quit [dim]👋[/dim]")
|
menu_table.add_row("[bright_white on red] q [/bright_white on red] Quit [dim]👋[/dim]")
|
||||||
menu_table.add_row("")
|
menu_table.add_row("")
|
||||||
menu_table.add_row(Text("Press a key to select...", style="dim italic"))
|
menu_table.add_row(Text("Press a key to select...", style="dim italic"))
|
||||||
|
|
||||||
panel_width = min(45, session.width - 4)
|
panel_width = min(45, session.width - 4)
|
||||||
panel = Panel(
|
panel = Panel(
|
||||||
Align.center(menu_table),
|
Align.center(menu_table),
|
||||||
@@ -202,25 +202,25 @@ async def run_simple_menu(process, session: TerminalSession, username: str, mode
|
|||||||
padding=(1, 2),
|
padding=(1, 2),
|
||||||
)
|
)
|
||||||
console.print(Align.center(panel))
|
console.print(Align.center(panel))
|
||||||
|
|
||||||
# Footer
|
# Footer
|
||||||
console.print()
|
console.print()
|
||||||
console.print(Align.center(Text(f"Terminal: {session.width}×{session.height}", style="dim")))
|
console.print(Align.center(Text(f"Terminal: {session.width}×{session.height}", style="dim")))
|
||||||
|
|
||||||
render_menu()
|
render_menu()
|
||||||
|
|
||||||
# Wait for input
|
# Wait for input
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
data = await process.stdin.read(1)
|
data = await process.stdin.read(1)
|
||||||
if not data:
|
if not data:
|
||||||
break
|
break
|
||||||
|
|
||||||
char = data.decode() if isinstance(data, bytes) else data
|
char = data.decode() if isinstance(data, bytes) else data
|
||||||
|
|
||||||
# Update terminal size in case it changed
|
# Update terminal size in case it changed
|
||||||
session._update_size()
|
session._update_size()
|
||||||
|
|
||||||
if char in ('q', 'Q', '\x03', '\x04'): # q, Ctrl+C, Ctrl+D
|
if char in ('q', 'Q', '\x03', '\x04'): # q, Ctrl+C, Ctrl+D
|
||||||
session.clear()
|
session.clear()
|
||||||
session.write("\r\n\033[33mGoodbye! Thanks for playing!\033[0m\r\n\r\n")
|
session.write("\r\n\033[33mGoodbye! Thanks for playing!\033[0m\r\n\r\n")
|
||||||
@@ -253,7 +253,7 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
|
|||||||
from rich.align import Align
|
from rich.align import Align
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from rich.box import ROUNDED
|
from rich.box import ROUNDED
|
||||||
|
|
||||||
class ProcessWriter:
|
class ProcessWriter:
|
||||||
def __init__(self, sess):
|
def __init__(self, sess):
|
||||||
self._session = sess
|
self._session = sess
|
||||||
@@ -261,11 +261,11 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
|
|||||||
self._session.write(data)
|
self._session.write(data)
|
||||||
def flush(self):
|
def flush(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
board = chess.Board()
|
board = chess.Board()
|
||||||
move_history = []
|
move_history = []
|
||||||
status_msg = ""
|
status_msg = ""
|
||||||
|
|
||||||
# Try to use Stockfish
|
# Try to use Stockfish
|
||||||
stockfish_engine = None
|
stockfish_engine = None
|
||||||
try:
|
try:
|
||||||
@@ -274,22 +274,22 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
|
|||||||
stockfish_engine.set_skill_level(5) # Medium difficulty
|
stockfish_engine.set_skill_level(5) # Medium difficulty
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Stockfish not available: {e}")
|
logger.warning(f"Stockfish not available: {e}")
|
||||||
|
|
||||||
# Unicode pieces
|
# Unicode pieces
|
||||||
PIECES = {
|
PIECES = {
|
||||||
'K': '♔', 'Q': '♕', 'R': '♖', 'B': '♗', 'N': '♘', 'P': '♙',
|
'K': '♔', 'Q': '♕', 'R': '♖', 'B': '♗', 'N': '♘', 'P': '♙',
|
||||||
'k': '♚', 'q': '♛', 'r': '♜', 'b': '♝', 'n': '♞', 'p': '♟',
|
'k': '♚', 'q': '♛', 'r': '♜', 'b': '♝', 'n': '♞', 'p': '♟',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Selection state for two-step moves
|
# Selection state for two-step moves
|
||||||
selected_square = None # None or chess square int
|
selected_square = None # None or chess square int
|
||||||
legal_targets = set() # Set of legal destination squares
|
legal_targets = set() # Set of legal destination squares
|
||||||
|
|
||||||
def get_cell_style(square, piece, is_light):
|
def get_cell_style(square, piece, is_light):
|
||||||
"""Get ANSI style for a cell based on selection state."""
|
"""Get ANSI style for a cell based on selection state."""
|
||||||
is_selected = (square == selected_square)
|
is_selected = (square == selected_square)
|
||||||
is_target = (square in legal_targets)
|
is_target = (square in legal_targets)
|
||||||
|
|
||||||
# Background colors
|
# Background colors
|
||||||
if is_selected:
|
if is_selected:
|
||||||
bg = "\033[48;5;33m" # Blue for selected
|
bg = "\033[48;5;33m" # Blue for selected
|
||||||
@@ -299,28 +299,28 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
|
|||||||
bg = "" # Default terminal
|
bg = "" # Default terminal
|
||||||
else:
|
else:
|
||||||
bg = "\033[48;5;236m" # Dark grey
|
bg = "\033[48;5;236m" # Dark grey
|
||||||
|
|
||||||
bg_end = "\033[0m" if bg else ""
|
bg_end = "\033[0m" if bg else ""
|
||||||
return bg, bg_end
|
return bg, bg_end
|
||||||
|
|
||||||
def render_board():
|
def render_board():
|
||||||
"""Large, terminal-filling chess board with selection highlighting."""
|
"""Large, terminal-filling chess board with selection highlighting."""
|
||||||
nonlocal status_msg
|
nonlocal status_msg
|
||||||
session._update_size()
|
session._update_size()
|
||||||
session.clear()
|
session.clear()
|
||||||
|
|
||||||
# Use compact board to fit most terminals
|
# Use compact board to fit most terminals
|
||||||
lines = []
|
lines = []
|
||||||
|
|
||||||
# Title
|
# Title
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("\033[1;32m♔ S H E L L M A T E ♔\033[0m")
|
lines.append("\033[1;32m♔ S H E L L M A T E ♔\033[0m")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Column labels
|
# Column labels
|
||||||
lines.append(" A B C D E F G H")
|
lines.append(" A B C D E F G H")
|
||||||
lines.append(" ╔═════╤═════╤═════╤═════╤═════╤═════╤═════╤═════╗")
|
lines.append(" ╔═════╤═════╤═════╤═════╤═════╤═════╤═════╤═════╗")
|
||||||
|
|
||||||
for rank in range(7, -1, -1):
|
for rank in range(7, -1, -1):
|
||||||
# Piece row
|
# Piece row
|
||||||
piece_row = f" \033[1;36m{rank + 1}\033[0m ║"
|
piece_row = f" \033[1;36m{rank + 1}\033[0m ║"
|
||||||
@@ -329,7 +329,7 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
|
|||||||
piece = board.piece_at(square)
|
piece = board.piece_at(square)
|
||||||
is_light = (rank + file) % 2 == 1
|
is_light = (rank + file) % 2 == 1
|
||||||
bg, bg_end = get_cell_style(square, piece, is_light)
|
bg, bg_end = get_cell_style(square, piece, is_light)
|
||||||
|
|
||||||
if piece:
|
if piece:
|
||||||
char = PIECES.get(piece.symbol(), '?')
|
char = PIECES.get(piece.symbol(), '?')
|
||||||
if piece.color == chess.WHITE:
|
if piece.color == chess.WHITE:
|
||||||
@@ -341,68 +341,68 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
|
|||||||
piece_row += f"{bg} \033[1;32m·\033[0m{bg} {bg_end}"
|
piece_row += f"{bg} \033[1;32m·\033[0m{bg} {bg_end}"
|
||||||
else:
|
else:
|
||||||
piece_row += f"{bg} {bg_end}"
|
piece_row += f"{bg} {bg_end}"
|
||||||
|
|
||||||
if file < 7:
|
if file < 7:
|
||||||
piece_row += "│"
|
piece_row += "│"
|
||||||
piece_row += f"║ \033[1;36m{rank + 1}\033[0m"
|
piece_row += f"║ \033[1;36m{rank + 1}\033[0m"
|
||||||
lines.append(piece_row)
|
lines.append(piece_row)
|
||||||
|
|
||||||
if rank > 0:
|
if rank > 0:
|
||||||
lines.append(" ╟─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────╢")
|
lines.append(" ╟─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────╢")
|
||||||
|
|
||||||
lines.append(" ╚═════╧═════╧═════╧═════╧═════╧═════╧═════╧═════╝")
|
lines.append(" ╚═════╧═════╧═════╧═════╧═════╧═════╧═════╧═════╝")
|
||||||
lines.append(" A B C D E F G H")
|
lines.append(" A B C D E F G H")
|
||||||
board_width = 57
|
board_width = 57
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
if board.turn == chess.WHITE:
|
if board.turn == chess.WHITE:
|
||||||
lines.append(" \033[1;97mWhite ♔\033[0m to move")
|
lines.append(" \033[1;97mWhite ♔\033[0m to move")
|
||||||
else:
|
else:
|
||||||
lines.append(" \033[1;33mBlack ♚\033[0m to move")
|
lines.append(" \033[1;33mBlack ♚\033[0m to move")
|
||||||
|
|
||||||
if board.is_check():
|
if board.is_check():
|
||||||
lines.append(" \033[1;31m⚠ CHECK! ⚠\033[0m")
|
lines.append(" \033[1;31m⚠ CHECK! ⚠\033[0m")
|
||||||
|
|
||||||
if move_history:
|
if move_history:
|
||||||
last_moves = move_history[-5:]
|
last_moves = move_history[-5:]
|
||||||
lines.append(f" \033[90mRecent: {' '.join(last_moves)}\033[0m")
|
lines.append(f" \033[90mRecent: {' '.join(last_moves)}\033[0m")
|
||||||
|
|
||||||
if status_msg:
|
if status_msg:
|
||||||
lines.append(f" \033[33m{status_msg}\033[0m")
|
lines.append(f" \033[33m{status_msg}\033[0m")
|
||||||
status_msg = ""
|
status_msg = ""
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Instructions based on state
|
# Instructions based on state
|
||||||
if selected_square is not None:
|
if selected_square is not None:
|
||||||
sq_name = chess.square_name(selected_square).upper()
|
sq_name = chess.square_name(selected_square).upper()
|
||||||
lines.append(f" \033[36mSelected: {sq_name}\033[0m → Type destination (or ESC to cancel)")
|
lines.append(f" \033[36mSelected: {sq_name}\033[0m → Type destination (or ESC to cancel)")
|
||||||
else:
|
else:
|
||||||
lines.append(" Type \033[36msquare\033[0m (e.g. E2) to select │ \033[31mQ\033[0m = quit")
|
lines.append(" Type \033[36msquare\033[0m (e.g. E2) to select │ \033[31mQ\033[0m = quit")
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Calculate centering
|
# Calculate centering
|
||||||
total_height = len(lines)
|
total_height = len(lines)
|
||||||
left_pad = max(0, (session.width - board_width) // 2)
|
left_pad = max(0, (session.width - board_width) // 2)
|
||||||
pad = " " * left_pad
|
pad = " " * left_pad
|
||||||
top_pad = max(0, (session.height - total_height) // 2)
|
top_pad = max(0, (session.height - total_height) // 2)
|
||||||
|
|
||||||
for _ in range(top_pad):
|
for _ in range(top_pad):
|
||||||
session.write("\r\n")
|
session.write("\r\n")
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
session.write(pad + line + "\r\n")
|
session.write(pad + line + "\r\n")
|
||||||
|
|
||||||
# Input prompt
|
# Input prompt
|
||||||
if selected_square is not None:
|
if selected_square is not None:
|
||||||
session.write(pad + f" \033[32m→ \033[0m")
|
session.write(pad + f" \033[32m→ \033[0m")
|
||||||
else:
|
else:
|
||||||
session.write(pad + f" \033[36m> \033[0m")
|
session.write(pad + f" \033[36m> \033[0m")
|
||||||
session.show_cursor()
|
session.show_cursor()
|
||||||
|
|
||||||
def parse_square(text):
|
def parse_square(text):
|
||||||
"""Parse a square name like 'e2' or 'E2' into a chess square."""
|
"""Parse a square name like 'e2' or 'E2' into a chess square."""
|
||||||
text = text.strip().lower()
|
text = text.strip().lower()
|
||||||
@@ -414,39 +414,39 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
|
|||||||
file_idx = ord(file_char) - ord('a')
|
file_idx = ord(file_char) - ord('a')
|
||||||
rank_idx = int(rank_char) - 1
|
rank_idx = int(rank_char) - 1
|
||||||
return chess.square(file_idx, rank_idx)
|
return chess.square(file_idx, rank_idx)
|
||||||
|
|
||||||
def select_square(sq):
|
def select_square(sq):
|
||||||
"""Select a square and compute legal moves from it."""
|
"""Select a square and compute legal moves from it."""
|
||||||
nonlocal selected_square, legal_targets
|
nonlocal selected_square, legal_targets
|
||||||
selected_square = sq
|
selected_square = sq
|
||||||
legal_targets = set()
|
legal_targets = set()
|
||||||
|
|
||||||
piece = board.piece_at(sq)
|
piece = board.piece_at(sq)
|
||||||
if piece and piece.color == board.turn:
|
if piece and piece.color == board.turn:
|
||||||
# Find all legal moves from this square
|
# Find all legal moves from this square
|
||||||
for move in board.legal_moves:
|
for move in board.legal_moves:
|
||||||
if move.from_square == sq:
|
if move.from_square == sq:
|
||||||
legal_targets.add(move.to_square)
|
legal_targets.add(move.to_square)
|
||||||
|
|
||||||
def clear_selection():
|
def clear_selection():
|
||||||
"""Clear the current selection."""
|
"""Clear the current selection."""
|
||||||
nonlocal selected_square, legal_targets
|
nonlocal selected_square, legal_targets
|
||||||
selected_square = None
|
selected_square = None
|
||||||
legal_targets = set()
|
legal_targets = set()
|
||||||
|
|
||||||
render_board()
|
render_board()
|
||||||
|
|
||||||
input_buffer = ""
|
input_buffer = ""
|
||||||
|
|
||||||
while not board.is_game_over():
|
while not board.is_game_over():
|
||||||
try:
|
try:
|
||||||
data = await process.stdin.read(1)
|
data = await process.stdin.read(1)
|
||||||
if not data:
|
if not data:
|
||||||
break
|
break
|
||||||
|
|
||||||
char = data.decode() if isinstance(data, bytes) else data
|
char = data.decode() if isinstance(data, bytes) else data
|
||||||
session._update_size()
|
session._update_size()
|
||||||
|
|
||||||
# Quit
|
# Quit
|
||||||
if char.lower() == 'q' and not input_buffer:
|
if char.lower() == 'q' and not input_buffer:
|
||||||
status_msg = "Thanks for playing!"
|
status_msg = "Thanks for playing!"
|
||||||
@@ -454,7 +454,7 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
|
|||||||
render_board()
|
render_board()
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
break
|
break
|
||||||
|
|
||||||
# Escape - cancel selection
|
# Escape - cancel selection
|
||||||
if char == '\x1b':
|
if char == '\x1b':
|
||||||
if selected_square is not None:
|
if selected_square is not None:
|
||||||
@@ -462,24 +462,24 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
|
|||||||
render_board()
|
render_board()
|
||||||
input_buffer = ""
|
input_buffer = ""
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Ctrl+C/D
|
# Ctrl+C/D
|
||||||
if char in ('\x03', '\x04'):
|
if char in ('\x03', '\x04'):
|
||||||
break
|
break
|
||||||
|
|
||||||
# Backspace
|
# Backspace
|
||||||
if char == '\x7f' or char == '\b':
|
if char == '\x7f' or char == '\b':
|
||||||
if input_buffer:
|
if input_buffer:
|
||||||
input_buffer = input_buffer[:-1]
|
input_buffer = input_buffer[:-1]
|
||||||
session.write('\b \b')
|
session.write('\b \b')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Enter - process input
|
# Enter - process input
|
||||||
if char in ('\r', '\n'):
|
if char in ('\r', '\n'):
|
||||||
if input_buffer:
|
if input_buffer:
|
||||||
sq = parse_square(input_buffer)
|
sq = parse_square(input_buffer)
|
||||||
input_buffer = ""
|
input_buffer = ""
|
||||||
|
|
||||||
if sq is not None:
|
if sq is not None:
|
||||||
if selected_square is None:
|
if selected_square is None:
|
||||||
# First click - select piece
|
# First click - select piece
|
||||||
@@ -500,17 +500,17 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
|
|||||||
if (piece.color == chess.WHITE and chess.square_rank(sq) == 7) or \
|
if (piece.color == chess.WHITE and chess.square_rank(sq) == 7) or \
|
||||||
(piece.color == chess.BLACK and chess.square_rank(sq) == 0):
|
(piece.color == chess.BLACK and chess.square_rank(sq) == 0):
|
||||||
move = chess.Move(selected_square, sq, promotion=chess.QUEEN)
|
move = chess.Move(selected_square, sq, promotion=chess.QUEEN)
|
||||||
|
|
||||||
board.push(move)
|
board.push(move)
|
||||||
move_history.append(move.uci())
|
move_history.append(move.uci())
|
||||||
clear_selection()
|
clear_selection()
|
||||||
render_board()
|
render_board()
|
||||||
|
|
||||||
# AI response
|
# AI response
|
||||||
if opponent == "ai" and not board.is_game_over():
|
if opponent == "ai" and not board.is_game_over():
|
||||||
session.hide_cursor()
|
session.hide_cursor()
|
||||||
session.write("\r\n \033[36mAI thinking...\033[0m")
|
session.write("\r\n \033[36mAI thinking...\033[0m")
|
||||||
|
|
||||||
if stockfish_engine:
|
if stockfish_engine:
|
||||||
try:
|
try:
|
||||||
stockfish_engine.set_fen_position(board.fen())
|
stockfish_engine.set_fen_position(board.fen())
|
||||||
@@ -523,7 +523,7 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
|
|||||||
import random
|
import random
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
ai_move = random.choice(list(board.legal_moves))
|
ai_move = random.choice(list(board.legal_moves))
|
||||||
|
|
||||||
board.push(ai_move)
|
board.push(ai_move)
|
||||||
move_history.append(ai_move.uci())
|
move_history.append(ai_move.uci())
|
||||||
render_board()
|
render_board()
|
||||||
@@ -545,23 +545,23 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
|
|||||||
status_msg = "Invalid square (use a1-h8)"
|
status_msg = "Invalid square (use a1-h8)"
|
||||||
render_board()
|
render_board()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Regular character input
|
# Regular character input
|
||||||
if char.isprintable() and len(input_buffer) < 2:
|
if char.isprintable() and len(input_buffer) < 2:
|
||||||
input_buffer += char
|
input_buffer += char
|
||||||
session.write(char.upper())
|
session.write(char.upper())
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Game input error: {e}")
|
logger.error(f"Game input error: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if board.is_game_over():
|
if board.is_game_over():
|
||||||
session.clear()
|
session.clear()
|
||||||
writer = ProcessWriter(session)
|
writer = ProcessWriter(session)
|
||||||
console = Console(file=writer, width=session.width, height=session.height, force_terminal=True)
|
console = Console(file=writer, width=session.width, height=session.height, force_terminal=True)
|
||||||
|
|
||||||
console.print()
|
console.print()
|
||||||
if board.is_checkmate():
|
if board.is_checkmate():
|
||||||
winner = "Black ♚" if board.turn == chess.WHITE else "White ♔"
|
winner = "Black ♚" if board.turn == chess.WHITE else "White ♔"
|
||||||
@@ -585,19 +585,19 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
|
|||||||
border_style="yellow",
|
border_style="yellow",
|
||||||
width=40
|
width=40
|
||||||
)))
|
)))
|
||||||
|
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
|
||||||
def ensure_host_key(key_path: str) -> None:
|
def ensure_host_key(key_path: str) -> None:
|
||||||
"""Generate SSH host key if it doesn't exist."""
|
"""Generate SSH host key if it doesn't exist."""
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
if not os.path.exists(key_path):
|
if not os.path.exists(key_path):
|
||||||
logger.info(f"Generating SSH host key at {key_path}")
|
logger.info(f"Generating SSH host key at {key_path}")
|
||||||
os.makedirs(os.path.dirname(key_path), exist_ok=True)
|
os.makedirs(os.path.dirname(key_path), exist_ok=True)
|
||||||
subprocess.run([
|
subprocess.run([
|
||||||
"ssh-keygen", "-t", "ed25519",
|
"ssh-keygen", "-t", "ed25519",
|
||||||
"-f", key_path, "-N", ""
|
"-f", key_path, "-N", ""
|
||||||
], check=True)
|
], check=True)
|
||||||
logger.info("SSH host key generated")
|
logger.info("SSH host key generated")
|
||||||
@@ -611,13 +611,13 @@ async def start_server(
|
|||||||
"""Start the SSH server."""
|
"""Start the SSH server."""
|
||||||
port = port or int(os.environ.get("SHELLMATE_SSH_PORT", "2222"))
|
port = port or int(os.environ.get("SHELLMATE_SSH_PORT", "2222"))
|
||||||
host_keys = host_keys or ["/etc/shellmate/ssh_host_key"]
|
host_keys = host_keys or ["/etc/shellmate/ssh_host_key"]
|
||||||
|
|
||||||
# Ensure host key exists (generate if needed)
|
# Ensure host key exists (generate if needed)
|
||||||
for key_path in host_keys:
|
for key_path in host_keys:
|
||||||
ensure_host_key(key_path)
|
ensure_host_key(key_path)
|
||||||
|
|
||||||
logger.info(f"Starting ShellMate SSH server on {host}:{port}")
|
logger.info(f"Starting ShellMate SSH server on {host}:{port}")
|
||||||
|
|
||||||
server = await asyncssh.create_server(
|
server = await asyncssh.create_server(
|
||||||
ShellMateSSHServer,
|
ShellMateSSHServer,
|
||||||
host,
|
host,
|
||||||
@@ -626,7 +626,7 @@ async def start_server(
|
|||||||
process_factory=handle_client,
|
process_factory=handle_client,
|
||||||
encoding=None, # Binary mode
|
encoding=None, # Binary mode
|
||||||
)
|
)
|
||||||
|
|
||||||
return server
|
return server
|
||||||
|
|
||||||
|
|
||||||
@@ -636,10 +636,10 @@ def main() -> None:
|
|||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
)
|
)
|
||||||
|
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loop.run_until_complete(start_server())
|
loop.run_until_complete(start_server())
|
||||||
loop.run_forever()
|
loop.run_forever()
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ from shellmate.tui.widgets.move_input import parse_move
|
|||||||
|
|
||||||
class MenuScreen(Screen):
|
class MenuScreen(Screen):
|
||||||
"""Main menu screen."""
|
"""Main menu screen."""
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header()
|
yield Header()
|
||||||
yield MainMenu()
|
yield MainMenu()
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def on_main_menu_mode_selected(self, event: MainMenu.ModeSelected) -> None:
|
def on_main_menu_mode_selected(self, event: MainMenu.ModeSelected) -> None:
|
||||||
"""Handle game mode selection."""
|
"""Handle game mode selection."""
|
||||||
if event.mode == "vs_ai":
|
if event.mode == "vs_ai":
|
||||||
@@ -40,7 +40,7 @@ class MenuScreen(Screen):
|
|||||||
|
|
||||||
class GameScreen(Screen):
|
class GameScreen(Screen):
|
||||||
"""Main game screen."""
|
"""Main game screen."""
|
||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
Binding("escape", "back", "Back to Menu"),
|
Binding("escape", "back", "Back to Menu"),
|
||||||
Binding("f", "flip", "Flip Board"),
|
Binding("f", "flip", "Flip Board"),
|
||||||
@@ -48,7 +48,7 @@ class GameScreen(Screen):
|
|||||||
Binding("u", "undo", "Undo"),
|
Binding("u", "undo", "Undo"),
|
||||||
Binding("n", "new_game", "New Game"),
|
Binding("n", "new_game", "New Game"),
|
||||||
]
|
]
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
GameScreen {
|
GameScreen {
|
||||||
layout: grid;
|
layout: grid;
|
||||||
@@ -57,7 +57,7 @@ class GameScreen(Screen):
|
|||||||
grid-rows: 1fr;
|
grid-rows: 1fr;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#board-container {
|
#board-container {
|
||||||
align: center middle;
|
align: center middle;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -66,36 +66,36 @@ class GameScreen(Screen):
|
|||||||
min-width: 40;
|
min-width: 40;
|
||||||
min-height: 22;
|
min-height: 22;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chess-board {
|
#chess-board {
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-width: 36;
|
min-width: 36;
|
||||||
min-height: 20;
|
min-height: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar {
|
#sidebar {
|
||||||
padding: 1;
|
padding: 1;
|
||||||
min-width: 20;
|
min-width: 20;
|
||||||
max-width: 30;
|
max-width: 30;
|
||||||
border-left: solid #2a3a2a;
|
border-left: solid #2a3a2a;
|
||||||
}
|
}
|
||||||
|
|
||||||
#game-status {
|
#game-status {
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 8;
|
max-height: 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
#move-list {
|
#move-list {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
min-height: 8;
|
min-height: 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
#move-input {
|
#move-input {
|
||||||
dock: bottom;
|
dock: bottom;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint-text {
|
.hint-text {
|
||||||
padding: 1;
|
padding: 1;
|
||||||
background: #1a3a1a;
|
background: #1a3a1a;
|
||||||
@@ -103,38 +103,38 @@ class GameScreen(Screen):
|
|||||||
margin: 1 0;
|
margin: 1 0;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, opponent: str = "ai", **kwargs):
|
def __init__(self, opponent: str = "ai", **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.opponent = opponent
|
self.opponent = opponent
|
||||||
self.board = chess.Board()
|
self.board = chess.Board()
|
||||||
self._hint_text: str | None = None
|
self._hint_text: str | None = None
|
||||||
self._ai_engine = None
|
self._ai_engine = None
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header()
|
yield Header()
|
||||||
|
|
||||||
with Horizontal():
|
with Horizontal():
|
||||||
with Container(id="board-container"):
|
with Container(id="board-container"):
|
||||||
yield ChessBoardWidget(board=self.board, id="chess-board")
|
yield ChessBoardWidget(board=self.board, id="chess-board")
|
||||||
|
|
||||||
with Vertical(id="sidebar"):
|
with Vertical(id="sidebar"):
|
||||||
yield GameStatusWidget(id="game-status")
|
yield GameStatusWidget(id="game-status")
|
||||||
yield MoveListWidget(id="move-list")
|
yield MoveListWidget(id="move-list")
|
||||||
yield MoveInput(id="move-input")
|
yield MoveInput(id="move-input")
|
||||||
|
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
"""Initialize game on mount."""
|
"""Initialize game on mount."""
|
||||||
status = self.query_one("#game-status", GameStatusWidget)
|
status = self.query_one("#game-status", GameStatusWidget)
|
||||||
status.set_players("You", "Stockfish" if self.opponent == "ai" else "Opponent")
|
status.set_players("You", "Stockfish" if self.opponent == "ai" else "Opponent")
|
||||||
status.update_from_board(self.board)
|
status.update_from_board(self.board)
|
||||||
|
|
||||||
# Initialize AI if playing against computer
|
# Initialize AI if playing against computer
|
||||||
if self.opponent == "ai":
|
if self.opponent == "ai":
|
||||||
self._init_ai()
|
self._init_ai()
|
||||||
|
|
||||||
def _init_ai(self) -> None:
|
def _init_ai(self) -> None:
|
||||||
"""Initialize AI engine."""
|
"""Initialize AI engine."""
|
||||||
try:
|
try:
|
||||||
@@ -142,25 +142,25 @@ class GameScreen(Screen):
|
|||||||
self._ai_engine = ChessAI(skill_level=10)
|
self._ai_engine = ChessAI(skill_level=10)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.notify(f"AI unavailable: {e}", severity="warning")
|
self.notify(f"AI unavailable: {e}", severity="warning")
|
||||||
|
|
||||||
def on_move_input_move_submitted(self, event: MoveInput.MoveSubmitted) -> None:
|
def on_move_input_move_submitted(self, event: MoveInput.MoveSubmitted) -> None:
|
||||||
"""Handle move submission."""
|
"""Handle move submission."""
|
||||||
move = parse_move(self.board, event.move)
|
move = parse_move(self.board, event.move)
|
||||||
|
|
||||||
if move is None:
|
if move is None:
|
||||||
self.notify(f"Invalid move: {event.move}", severity="error")
|
self.notify(f"Invalid move: {event.move}", severity="error")
|
||||||
return
|
return
|
||||||
|
|
||||||
self._make_move(move)
|
self._make_move(move)
|
||||||
|
|
||||||
# AI response
|
# AI response
|
||||||
if self.opponent == "ai" and not self.board.is_game_over() and self._ai_engine:
|
if self.opponent == "ai" and not self.board.is_game_over() and self._ai_engine:
|
||||||
self._ai_move()
|
self._ai_move()
|
||||||
|
|
||||||
def on_move_input_command_submitted(self, event: MoveInput.CommandSubmitted) -> None:
|
def on_move_input_command_submitted(self, event: MoveInput.CommandSubmitted) -> None:
|
||||||
"""Handle command submission."""
|
"""Handle command submission."""
|
||||||
cmd = event.command.lower()
|
cmd = event.command.lower()
|
||||||
|
|
||||||
if cmd == "hint":
|
if cmd == "hint":
|
||||||
self.action_hint()
|
self.action_hint()
|
||||||
elif cmd == "resign":
|
elif cmd == "resign":
|
||||||
@@ -174,27 +174,27 @@ class GameScreen(Screen):
|
|||||||
self.action_new_game()
|
self.action_new_game()
|
||||||
else:
|
else:
|
||||||
self.notify(f"Unknown command: /{cmd}", severity="warning")
|
self.notify(f"Unknown command: /{cmd}", severity="warning")
|
||||||
|
|
||||||
def _make_move(self, move: chess.Move) -> None:
|
def _make_move(self, move: chess.Move) -> None:
|
||||||
"""Execute a move on the board."""
|
"""Execute a move on the board."""
|
||||||
# Get SAN before pushing
|
# Get SAN before pushing
|
||||||
san = self.board.san(move)
|
san = self.board.san(move)
|
||||||
|
|
||||||
# Make the move
|
# Make the move
|
||||||
self.board.push(move)
|
self.board.push(move)
|
||||||
|
|
||||||
# Update widgets
|
# Update widgets
|
||||||
board_widget = self.query_one("#chess-board", ChessBoardWidget)
|
board_widget = self.query_one("#chess-board", ChessBoardWidget)
|
||||||
board_widget.set_board(self.board)
|
board_widget.set_board(self.board)
|
||||||
board_widget.set_last_move(move)
|
board_widget.set_last_move(move)
|
||||||
board_widget.select_square(None)
|
board_widget.select_square(None)
|
||||||
|
|
||||||
move_list = self.query_one("#move-list", MoveListWidget)
|
move_list = self.query_one("#move-list", MoveListWidget)
|
||||||
move_list.add_move(san)
|
move_list.add_move(san)
|
||||||
|
|
||||||
status = self.query_one("#game-status", GameStatusWidget)
|
status = self.query_one("#game-status", GameStatusWidget)
|
||||||
status.update_from_board(self.board)
|
status.update_from_board(self.board)
|
||||||
|
|
||||||
# Check game end
|
# Check game end
|
||||||
if self.board.is_checkmate():
|
if self.board.is_checkmate():
|
||||||
winner = "Black" if self.board.turn == chess.WHITE else "White"
|
winner = "Black" if self.board.turn == chess.WHITE else "White"
|
||||||
@@ -203,36 +203,36 @@ class GameScreen(Screen):
|
|||||||
self.notify("Stalemate! Game drawn.", severity="information")
|
self.notify("Stalemate! Game drawn.", severity="information")
|
||||||
elif self.board.is_check():
|
elif self.board.is_check():
|
||||||
self.notify("Check!", severity="warning")
|
self.notify("Check!", severity="warning")
|
||||||
|
|
||||||
def _ai_move(self) -> None:
|
def _ai_move(self) -> None:
|
||||||
"""Make AI move."""
|
"""Make AI move."""
|
||||||
if not self._ai_engine:
|
if not self._ai_engine:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
best_move_uci = self._ai_engine.get_best_move(self.board.fen())
|
best_move_uci = self._ai_engine.get_best_move(self.board.fen())
|
||||||
move = chess.Move.from_uci(best_move_uci)
|
move = chess.Move.from_uci(best_move_uci)
|
||||||
|
|
||||||
# Small delay for UX
|
# Small delay for UX
|
||||||
self.set_timer(0.5, lambda: self._make_move(move))
|
self.set_timer(0.5, lambda: self._make_move(move))
|
||||||
|
|
||||||
# Update evaluation
|
# Update evaluation
|
||||||
analysis = self._ai_engine.analyze_position(self.board.fen(), depth=10)
|
analysis = self._ai_engine.analyze_position(self.board.fen(), depth=10)
|
||||||
status = self.query_one("#game-status", GameStatusWidget)
|
status = self.query_one("#game-status", GameStatusWidget)
|
||||||
status.set_evaluation(analysis.evaluation)
|
status.set_evaluation(analysis.evaluation)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.notify(f"AI error: {e}", severity="error")
|
self.notify(f"AI error: {e}", severity="error")
|
||||||
|
|
||||||
def action_back(self) -> None:
|
def action_back(self) -> None:
|
||||||
"""Return to menu."""
|
"""Return to menu."""
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|
||||||
def action_flip(self) -> None:
|
def action_flip(self) -> None:
|
||||||
"""Flip the board."""
|
"""Flip the board."""
|
||||||
board_widget = self.query_one("#chess-board", ChessBoardWidget)
|
board_widget = self.query_one("#chess-board", ChessBoardWidget)
|
||||||
board_widget.flip()
|
board_widget.flip()
|
||||||
|
|
||||||
def action_hint(self) -> None:
|
def action_hint(self) -> None:
|
||||||
"""Get a hint."""
|
"""Get a hint."""
|
||||||
if self._ai_engine and self.board.turn == chess.WHITE:
|
if self._ai_engine and self.board.turn == chess.WHITE:
|
||||||
@@ -240,67 +240,67 @@ class GameScreen(Screen):
|
|||||||
self.notify(f"💡 {hint}")
|
self.notify(f"💡 {hint}")
|
||||||
else:
|
else:
|
||||||
self.notify("Hints only available on your turn", severity="warning")
|
self.notify("Hints only available on your turn", severity="warning")
|
||||||
|
|
||||||
def action_undo(self) -> None:
|
def action_undo(self) -> None:
|
||||||
"""Undo the last move (or last two if vs AI)."""
|
"""Undo the last move (or last two if vs AI)."""
|
||||||
if len(self.board.move_stack) == 0:
|
if len(self.board.move_stack) == 0:
|
||||||
self.notify("No moves to undo", severity="warning")
|
self.notify("No moves to undo", severity="warning")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Undo player move
|
# Undo player move
|
||||||
self.board.pop()
|
self.board.pop()
|
||||||
move_list = self.query_one("#move-list", MoveListWidget)
|
move_list = self.query_one("#move-list", MoveListWidget)
|
||||||
move_list.undo()
|
move_list.undo()
|
||||||
|
|
||||||
# Also undo AI move if applicable
|
# Also undo AI move if applicable
|
||||||
if self.opponent == "ai" and len(self.board.move_stack) > 0:
|
if self.opponent == "ai" and len(self.board.move_stack) > 0:
|
||||||
self.board.pop()
|
self.board.pop()
|
||||||
move_list.undo()
|
move_list.undo()
|
||||||
|
|
||||||
# Update display
|
# Update display
|
||||||
board_widget = self.query_one("#chess-board", ChessBoardWidget)
|
board_widget = self.query_one("#chess-board", ChessBoardWidget)
|
||||||
board_widget.set_board(self.board)
|
board_widget.set_board(self.board)
|
||||||
board_widget.set_last_move(None)
|
board_widget.set_last_move(None)
|
||||||
|
|
||||||
status = self.query_one("#game-status", GameStatusWidget)
|
status = self.query_one("#game-status", GameStatusWidget)
|
||||||
status.update_from_board(self.board)
|
status.update_from_board(self.board)
|
||||||
|
|
||||||
self.notify("Move undone")
|
self.notify("Move undone")
|
||||||
|
|
||||||
def action_new_game(self) -> None:
|
def action_new_game(self) -> None:
|
||||||
"""Start a new game."""
|
"""Start a new game."""
|
||||||
self.board = chess.Board()
|
self.board = chess.Board()
|
||||||
|
|
||||||
board_widget = self.query_one("#chess-board", ChessBoardWidget)
|
board_widget = self.query_one("#chess-board", ChessBoardWidget)
|
||||||
board_widget.set_board(self.board)
|
board_widget.set_board(self.board)
|
||||||
board_widget.set_last_move(None)
|
board_widget.set_last_move(None)
|
||||||
|
|
||||||
move_list = self.query_one("#move-list", MoveListWidget)
|
move_list = self.query_one("#move-list", MoveListWidget)
|
||||||
move_list.clear()
|
move_list.clear()
|
||||||
|
|
||||||
status = self.query_one("#game-status", GameStatusWidget)
|
status = self.query_one("#game-status", GameStatusWidget)
|
||||||
status.update_from_board(self.board)
|
status.update_from_board(self.board)
|
||||||
status.set_evaluation(None)
|
status.set_evaluation(None)
|
||||||
|
|
||||||
self.notify("New game started!")
|
self.notify("New game started!")
|
||||||
|
|
||||||
|
|
||||||
class ShellMateApp(App):
|
class ShellMateApp(App):
|
||||||
"""ShellMate TUI Chess Application."""
|
"""ShellMate TUI Chess Application."""
|
||||||
|
|
||||||
TITLE = "ShellMate"
|
TITLE = "ShellMate"
|
||||||
SUB_TITLE = "SSH into Chess Mastery"
|
SUB_TITLE = "SSH into Chess Mastery"
|
||||||
|
|
||||||
CSS = """
|
CSS = """
|
||||||
Screen {
|
Screen {
|
||||||
background: #0a0a0a;
|
background: #0a0a0a;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
Binding("q", "quit", "Quit"),
|
Binding("q", "quit", "Quit"),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
username: str = "guest",
|
username: str = "guest",
|
||||||
@@ -309,7 +309,7 @@ class ShellMateApp(App):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.username = username
|
self.username = username
|
||||||
self.initial_mode = mode
|
self.initial_mode = mode
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
"""Start with menu screen."""
|
"""Start with menu screen."""
|
||||||
self.push_screen(MenuScreen())
|
self.push_screen(MenuScreen())
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from .menu import MainMenu
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ChessBoardWidget",
|
"ChessBoardWidget",
|
||||||
"MoveInput",
|
"MoveInput",
|
||||||
"MoveListWidget",
|
"MoveListWidget",
|
||||||
"GameStatusWidget",
|
"GameStatusWidget",
|
||||||
"MainMenu",
|
"MainMenu",
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ BOX = {
|
|||||||
|
|
||||||
class ChessBoardWidget(Widget):
|
class ChessBoardWidget(Widget):
|
||||||
"""Interactive chess board widget with auto-sizing."""
|
"""Interactive chess board widget with auto-sizing."""
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
ChessBoardWidget {
|
ChessBoardWidget {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -58,7 +58,7 @@ class ChessBoardWidget(Widget):
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Reactive properties
|
# Reactive properties
|
||||||
selected_square: reactive[int | None] = reactive(None)
|
selected_square: reactive[int | None] = reactive(None)
|
||||||
legal_moves: reactive[set] = reactive(set)
|
legal_moves: reactive[set] = reactive(set)
|
||||||
@@ -66,7 +66,7 @@ class ChessBoardWidget(Widget):
|
|||||||
flipped: reactive[bool] = reactive(False)
|
flipped: reactive[bool] = reactive(False)
|
||||||
use_heavy_border: reactive[bool] = reactive(True)
|
use_heavy_border: reactive[bool] = reactive(True)
|
||||||
compact_mode: reactive[bool] = reactive(False)
|
compact_mode: reactive[bool] = reactive(False)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
board: chess.Board | None = None,
|
board: chess.Board | None = None,
|
||||||
@@ -76,83 +76,83 @@ class ChessBoardWidget(Widget):
|
|||||||
self.board = board or chess.Board()
|
self.board = board or chess.Board()
|
||||||
self._cell_width = 3 # Will be calculated
|
self._cell_width = 3 # Will be calculated
|
||||||
self._cell_height = 1
|
self._cell_height = 1
|
||||||
|
|
||||||
def get_content_width(self, container: Size, viewport: Size) -> int:
|
def get_content_width(self, container: Size, viewport: Size) -> int:
|
||||||
"""Calculate optimal width."""
|
"""Calculate optimal width."""
|
||||||
# Each cell is at least 3 chars wide + borders + labels
|
# Each cell is at least 3 chars wide + borders + labels
|
||||||
min_width = 8 * 3 + 9 + 4 # 8 cells * 3 + 9 borders + labels
|
min_width = 8 * 3 + 9 + 4 # 8 cells * 3 + 9 borders + labels
|
||||||
return max(min_width, container.width)
|
return max(min_width, container.width)
|
||||||
|
|
||||||
def get_content_height(self, container: Size, viewport: Size) -> int:
|
def get_content_height(self, container: Size, viewport: Size) -> int:
|
||||||
"""Calculate optimal height."""
|
"""Calculate optimal height."""
|
||||||
# Each cell is 1-2 lines + borders + labels
|
# Each cell is 1-2 lines + borders + labels
|
||||||
return max(18, min(22, container.height))
|
return max(18, min(22, container.height))
|
||||||
|
|
||||||
def _calculate_cell_size(self, width: int, height: int) -> tuple[int, int]:
|
def _calculate_cell_size(self, width: int, height: int) -> tuple[int, int]:
|
||||||
"""Calculate cell dimensions based on available space."""
|
"""Calculate cell dimensions based on available space."""
|
||||||
# Available width for cells (minus borders and labels)
|
# Available width for cells (minus borders and labels)
|
||||||
available_width = width - 4 # 2 for labels on each side
|
available_width = width - 4 # 2 for labels on each side
|
||||||
available_height = height - 4 # 2 for labels + 2 for borders
|
available_height = height - 4 # 2 for labels + 2 for borders
|
||||||
|
|
||||||
# Calculate cell width (must be odd for centering)
|
# Calculate cell width (must be odd for centering)
|
||||||
cell_w = max(3, (available_width - 9) // 8) # -9 for internal borders
|
cell_w = max(3, (available_width - 9) // 8) # -9 for internal borders
|
||||||
if cell_w % 2 == 0:
|
if cell_w % 2 == 0:
|
||||||
cell_w -= 1
|
cell_w -= 1
|
||||||
cell_w = min(cell_w, 7) # Cap at 7 for sanity
|
cell_w = min(cell_w, 7) # Cap at 7 for sanity
|
||||||
|
|
||||||
# Calculate cell height
|
# Calculate cell height
|
||||||
cell_h = max(1, (available_height - 9) // 8)
|
cell_h = max(1, (available_height - 9) // 8)
|
||||||
cell_h = min(cell_h, 3)
|
cell_h = min(cell_h, 3)
|
||||||
|
|
||||||
return cell_w, cell_h
|
return cell_w, cell_h
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
"""Render the chess board with perfect alignment."""
|
"""Render the chess board with perfect alignment."""
|
||||||
size = self.size
|
size = self.size
|
||||||
self._cell_width, self._cell_height = self._calculate_cell_size(size.width, size.height)
|
self._cell_width, self._cell_height = self._calculate_cell_size(size.width, size.height)
|
||||||
|
|
||||||
# Use compact mode for small terminals
|
# Use compact mode for small terminals
|
||||||
if size.width < 40 or size.height < 20:
|
if size.width < 40 or size.height < 20:
|
||||||
return self._render_compact()
|
return self._render_compact()
|
||||||
else:
|
else:
|
||||||
return self._render_standard()
|
return self._render_standard()
|
||||||
|
|
||||||
def _render_compact(self) -> RenderableType:
|
def _render_compact(self) -> RenderableType:
|
||||||
"""Compact board for small terminals."""
|
"""Compact board for small terminals."""
|
||||||
text = Text()
|
text = Text()
|
||||||
cw = 3 # Fixed cell width for compact
|
cw = 3 # Fixed cell width for compact
|
||||||
|
|
||||||
files = "abcdefgh" if not self.flipped else "hgfedcba"
|
files = "abcdefgh" if not self.flipped else "hgfedcba"
|
||||||
|
|
||||||
# File labels - centered over cells
|
# File labels - centered over cells
|
||||||
text.append(" ", style=f"dim {LABEL_COLOR}")
|
text.append(" ", style=f"dim {LABEL_COLOR}")
|
||||||
for f in files:
|
for f in files:
|
||||||
text.append(f" {f} ", style=f"dim {LABEL_COLOR}")
|
text.append(f" {f} ", style=f"dim {LABEL_COLOR}")
|
||||||
text.append("\n")
|
text.append("\n")
|
||||||
|
|
||||||
# Top border
|
# Top border
|
||||||
border_style = Style(color=BORDER_COLOR)
|
border_style = Style(color=BORDER_COLOR)
|
||||||
text.append(" ╔", style=border_style)
|
text.append(" ╔", style=border_style)
|
||||||
text.append("═══╤" * 7 + "═══╗\n", style=border_style)
|
text.append("═══╤" * 7 + "═══╗\n", style=border_style)
|
||||||
|
|
||||||
ranks = range(7, -1, -1) if not self.flipped else range(8)
|
ranks = range(7, -1, -1) if not self.flipped else range(8)
|
||||||
|
|
||||||
for rank_idx, rank in enumerate(ranks):
|
for rank_idx, rank in enumerate(ranks):
|
||||||
# Rank label
|
# Rank label
|
||||||
text.append(f"{rank + 1}║", style=f"dim {LABEL_COLOR}")
|
text.append(f"{rank + 1}║", style=f"dim {LABEL_COLOR}")
|
||||||
|
|
||||||
file_range = range(8) if not self.flipped else range(7, -1, -1)
|
file_range = range(8) if not self.flipped else range(7, -1, -1)
|
||||||
|
|
||||||
for file_idx, file in enumerate(file_range):
|
for file_idx, file in enumerate(file_range):
|
||||||
square = chess.square(file, rank)
|
square = chess.square(file, rank)
|
||||||
piece = self.board.piece_at(square)
|
piece = self.board.piece_at(square)
|
||||||
|
|
||||||
# Determine square styling
|
# Determine square styling
|
||||||
is_light = (rank + file) % 2 == 1
|
is_light = (rank + file) % 2 == 1
|
||||||
is_selected = square == self.selected_square
|
is_selected = square == self.selected_square
|
||||||
is_legal_target = square in self.legal_moves
|
is_legal_target = square in self.legal_moves
|
||||||
is_last_move = self.last_move and square in self.last_move
|
is_last_move = self.last_move and square in self.last_move
|
||||||
|
|
||||||
if is_selected:
|
if is_selected:
|
||||||
bg = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED
|
bg = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED
|
||||||
elif is_last_move:
|
elif is_last_move:
|
||||||
@@ -161,7 +161,7 @@ class ChessBoardWidget(Widget):
|
|||||||
bg = HIGHLIGHT_LEGAL
|
bg = HIGHLIGHT_LEGAL
|
||||||
else:
|
else:
|
||||||
bg = LIGHT_SQUARE if is_light else DARK_SQUARE
|
bg = LIGHT_SQUARE if is_light else DARK_SQUARE
|
||||||
|
|
||||||
# Piece or empty
|
# Piece or empty
|
||||||
if piece:
|
if piece:
|
||||||
char = PIECES_OUTLINE.get(piece.symbol(), '?')
|
char = PIECES_OUTLINE.get(piece.symbol(), '?')
|
||||||
@@ -172,55 +172,55 @@ class ChessBoardWidget(Widget):
|
|||||||
else:
|
else:
|
||||||
char = ' '
|
char = ' '
|
||||||
fg = WHITE_PIECE
|
fg = WHITE_PIECE
|
||||||
|
|
||||||
style = Style(color=fg, bgcolor=bg, bold=True)
|
style = Style(color=fg, bgcolor=bg, bold=True)
|
||||||
text.append(f" {char} ", style=style)
|
text.append(f" {char} ", style=style)
|
||||||
|
|
||||||
# Cell separator
|
# Cell separator
|
||||||
if file_idx < 7:
|
if file_idx < 7:
|
||||||
text.append("│", style=border_style)
|
text.append("│", style=border_style)
|
||||||
|
|
||||||
text.append(f"║{rank + 1}\n", style=f"dim {LABEL_COLOR}")
|
text.append(f"║{rank + 1}\n", style=f"dim {LABEL_COLOR}")
|
||||||
|
|
||||||
# Row separator
|
# Row separator
|
||||||
if rank_idx < 7:
|
if rank_idx < 7:
|
||||||
text.append(" ╟", style=border_style)
|
text.append(" ╟", style=border_style)
|
||||||
text.append("───┼" * 7 + "───╢\n", style=border_style)
|
text.append("───┼" * 7 + "───╢\n", style=border_style)
|
||||||
|
|
||||||
# Bottom border
|
# Bottom border
|
||||||
text.append(" ╚", style=border_style)
|
text.append(" ╚", style=border_style)
|
||||||
text.append("═══╧" * 7 + "═══╝\n", style=border_style)
|
text.append("═══╧" * 7 + "═══╝\n", style=border_style)
|
||||||
|
|
||||||
# File labels
|
# File labels
|
||||||
text.append(" ", style=f"dim {LABEL_COLOR}")
|
text.append(" ", style=f"dim {LABEL_COLOR}")
|
||||||
for f in files:
|
for f in files:
|
||||||
text.append(f" {f} ", style=f"dim {LABEL_COLOR}")
|
text.append(f" {f} ", style=f"dim {LABEL_COLOR}")
|
||||||
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def _render_standard(self) -> RenderableType:
|
def _render_standard(self) -> RenderableType:
|
||||||
"""Standard board with variable cell size."""
|
"""Standard board with variable cell size."""
|
||||||
text = Text()
|
text = Text()
|
||||||
cw = self._cell_width
|
cw = self._cell_width
|
||||||
ch = self._cell_height
|
ch = self._cell_height
|
||||||
|
|
||||||
# Ensure odd width for centering
|
# Ensure odd width for centering
|
||||||
if cw % 2 == 0:
|
if cw % 2 == 0:
|
||||||
cw = max(3, cw - 1)
|
cw = max(3, cw - 1)
|
||||||
|
|
||||||
files = "abcdefgh" if not self.flipped else "hgfedcba"
|
files = "abcdefgh" if not self.flipped else "hgfedcba"
|
||||||
pad = cw // 2
|
pad = cw // 2
|
||||||
|
|
||||||
border_style = Style(color=BORDER_COLOR)
|
border_style = Style(color=BORDER_COLOR)
|
||||||
label_style = Style(color=LABEL_COLOR, dim=True)
|
label_style = Style(color=LABEL_COLOR, dim=True)
|
||||||
|
|
||||||
# File labels - perfectly centered
|
# File labels - perfectly centered
|
||||||
text.append(" ", style=label_style)
|
text.append(" ", style=label_style)
|
||||||
for f in files:
|
for f in files:
|
||||||
text.append(" " * pad + f + " " * pad, style=label_style)
|
text.append(" " * pad + f + " " * pad, style=label_style)
|
||||||
text.append(" ", style=label_style) # For border space
|
text.append(" ", style=label_style) # For border space
|
||||||
text.append("\n")
|
text.append("\n")
|
||||||
|
|
||||||
# Top border with double lines
|
# Top border with double lines
|
||||||
cell_border = BOX['H'] * cw
|
cell_border = BOX['H'] * cw
|
||||||
text.append(" " + BOX['TL'], style=border_style)
|
text.append(" " + BOX['TL'], style=border_style)
|
||||||
@@ -229,34 +229,34 @@ class ChessBoardWidget(Widget):
|
|||||||
if i < 7:
|
if i < 7:
|
||||||
text.append(BOX['TJ'], style=border_style)
|
text.append(BOX['TJ'], style=border_style)
|
||||||
text.append(BOX['TR'] + "\n", style=border_style)
|
text.append(BOX['TR'] + "\n", style=border_style)
|
||||||
|
|
||||||
ranks = range(7, -1, -1) if not self.flipped else range(8)
|
ranks = range(7, -1, -1) if not self.flipped else range(8)
|
||||||
|
|
||||||
for rank_idx, rank in enumerate(ranks):
|
for rank_idx, rank in enumerate(ranks):
|
||||||
# Multi-line cells
|
# Multi-line cells
|
||||||
for cell_line in range(ch):
|
for cell_line in range(ch):
|
||||||
is_middle = cell_line == ch // 2
|
is_middle = cell_line == ch // 2
|
||||||
|
|
||||||
# Rank label (only on middle line)
|
# Rank label (only on middle line)
|
||||||
if is_middle:
|
if is_middle:
|
||||||
text.append(f"{rank + 1}", style=label_style)
|
text.append(f"{rank + 1}", style=label_style)
|
||||||
else:
|
else:
|
||||||
text.append(" ", style=label_style)
|
text.append(" ", style=label_style)
|
||||||
|
|
||||||
text.append(BOX['V'], style=border_style)
|
text.append(BOX['V'], style=border_style)
|
||||||
|
|
||||||
file_range = range(8) if not self.flipped else range(7, -1, -1)
|
file_range = range(8) if not self.flipped else range(7, -1, -1)
|
||||||
|
|
||||||
for file_idx, file in enumerate(file_range):
|
for file_idx, file in enumerate(file_range):
|
||||||
square = chess.square(file, rank)
|
square = chess.square(file, rank)
|
||||||
piece = self.board.piece_at(square)
|
piece = self.board.piece_at(square)
|
||||||
|
|
||||||
# Square styling
|
# Square styling
|
||||||
is_light = (rank + file) % 2 == 1
|
is_light = (rank + file) % 2 == 1
|
||||||
is_selected = square == self.selected_square
|
is_selected = square == self.selected_square
|
||||||
is_legal_target = square in self.legal_moves
|
is_legal_target = square in self.legal_moves
|
||||||
is_last_move = self.last_move and square in self.last_move
|
is_last_move = self.last_move and square in self.last_move
|
||||||
|
|
||||||
if is_selected:
|
if is_selected:
|
||||||
bg = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED
|
bg = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED
|
||||||
elif is_last_move:
|
elif is_last_move:
|
||||||
@@ -265,7 +265,7 @@ class ChessBoardWidget(Widget):
|
|||||||
bg = HIGHLIGHT_LEGAL
|
bg = HIGHLIGHT_LEGAL
|
||||||
else:
|
else:
|
||||||
bg = LIGHT_SQUARE if is_light else DARK_SQUARE
|
bg = LIGHT_SQUARE if is_light else DARK_SQUARE
|
||||||
|
|
||||||
# Content (only on middle line)
|
# Content (only on middle line)
|
||||||
if is_middle:
|
if is_middle:
|
||||||
if piece:
|
if piece:
|
||||||
@@ -280,21 +280,21 @@ class ChessBoardWidget(Widget):
|
|||||||
else:
|
else:
|
||||||
char = ' '
|
char = ' '
|
||||||
fg = WHITE_PIECE
|
fg = WHITE_PIECE
|
||||||
|
|
||||||
style = Style(color=fg, bgcolor=bg, bold=True)
|
style = Style(color=fg, bgcolor=bg, bold=True)
|
||||||
cell_content = " " * pad + char + " " * pad
|
cell_content = " " * pad + char + " " * pad
|
||||||
text.append(cell_content, style=style)
|
text.append(cell_content, style=style)
|
||||||
|
|
||||||
# Separator
|
# Separator
|
||||||
if file_idx < 7:
|
if file_idx < 7:
|
||||||
text.append(BOX['v'], style=border_style)
|
text.append(BOX['v'], style=border_style)
|
||||||
|
|
||||||
# Right border and rank label
|
# Right border and rank label
|
||||||
if is_middle:
|
if is_middle:
|
||||||
text.append(f"{BOX['V']}{rank + 1}\n", style=label_style)
|
text.append(f"{BOX['V']}{rank + 1}\n", style=label_style)
|
||||||
else:
|
else:
|
||||||
text.append(f"{BOX['V']} \n", style=border_style)
|
text.append(f"{BOX['V']} \n", style=border_style)
|
||||||
|
|
||||||
# Row separator
|
# Row separator
|
||||||
if rank_idx < 7:
|
if rank_idx < 7:
|
||||||
text.append(" " + BOX['LJ'], style=border_style)
|
text.append(" " + BOX['LJ'], style=border_style)
|
||||||
@@ -303,7 +303,7 @@ class ChessBoardWidget(Widget):
|
|||||||
if i < 7:
|
if i < 7:
|
||||||
text.append(BOX['x'], style=border_style)
|
text.append(BOX['x'], style=border_style)
|
||||||
text.append(BOX['RJ'] + "\n", style=border_style)
|
text.append(BOX['RJ'] + "\n", style=border_style)
|
||||||
|
|
||||||
# Bottom border
|
# Bottom border
|
||||||
text.append(" " + BOX['BL'], style=border_style)
|
text.append(" " + BOX['BL'], style=border_style)
|
||||||
for i in range(8):
|
for i in range(8):
|
||||||
@@ -311,33 +311,33 @@ class ChessBoardWidget(Widget):
|
|||||||
if i < 7:
|
if i < 7:
|
||||||
text.append(BOX['BJ'], style=border_style)
|
text.append(BOX['BJ'], style=border_style)
|
||||||
text.append(BOX['BR'] + "\n", style=border_style)
|
text.append(BOX['BR'] + "\n", style=border_style)
|
||||||
|
|
||||||
# File labels
|
# File labels
|
||||||
text.append(" ", style=label_style)
|
text.append(" ", style=label_style)
|
||||||
for f in files:
|
for f in files:
|
||||||
text.append(" " * pad + f + " " * pad, style=label_style)
|
text.append(" " * pad + f + " " * pad, style=label_style)
|
||||||
text.append(" ", style=label_style)
|
text.append(" ", style=label_style)
|
||||||
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def set_board(self, board: chess.Board) -> None:
|
def set_board(self, board: chess.Board) -> None:
|
||||||
"""Update the board state."""
|
"""Update the board state."""
|
||||||
self.board = board
|
self.board = board
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def select_square(self, square: int | None) -> None:
|
def select_square(self, square: int | None) -> None:
|
||||||
"""Select a square and show legal moves from it."""
|
"""Select a square and show legal moves from it."""
|
||||||
self.selected_square = square
|
self.selected_square = square
|
||||||
if square is not None:
|
if square is not None:
|
||||||
self.legal_moves = {
|
self.legal_moves = {
|
||||||
move.to_square
|
move.to_square
|
||||||
for move in self.board.legal_moves
|
for move in self.board.legal_moves
|
||||||
if move.from_square == square
|
if move.from_square == square
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
self.legal_moves = set()
|
self.legal_moves = set()
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def set_last_move(self, move: chess.Move | None) -> None:
|
def set_last_move(self, move: chess.Move | None) -> None:
|
||||||
"""Highlight the last move played."""
|
"""Highlight the last move played."""
|
||||||
if move:
|
if move:
|
||||||
@@ -345,12 +345,12 @@ class ChessBoardWidget(Widget):
|
|||||||
else:
|
else:
|
||||||
self.last_move = None
|
self.last_move = None
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def flip(self) -> None:
|
def flip(self) -> None:
|
||||||
"""Flip the board perspective."""
|
"""Flip the board perspective."""
|
||||||
self.flipped = not self.flipped
|
self.flipped = not self.flipped
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def square_from_coords(self, file: str, rank: str) -> int | None:
|
def square_from_coords(self, file: str, rank: str) -> int | None:
|
||||||
"""Convert algebraic notation to square index."""
|
"""Convert algebraic notation to square index."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ from rich.console import RenderableType
|
|||||||
|
|
||||||
class MainMenu(Widget):
|
class MainMenu(Widget):
|
||||||
"""Main menu for game mode selection."""
|
"""Main menu for game mode selection."""
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
MainMenu {
|
MainMenu {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
align: center middle;
|
align: center middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
MainMenu > Vertical {
|
MainMenu > Vertical {
|
||||||
width: 50;
|
width: 50;
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -25,37 +25,37 @@ class MainMenu(Widget):
|
|||||||
border: round #333;
|
border: round #333;
|
||||||
background: #111;
|
background: #111;
|
||||||
}
|
}
|
||||||
|
|
||||||
MainMenu .title {
|
MainMenu .title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 1 0 2 0;
|
padding: 1 0 2 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
MainMenu Button {
|
MainMenu Button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 1 0;
|
margin: 1 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
MainMenu .subtitle {
|
MainMenu .subtitle {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #666;
|
color: #666;
|
||||||
padding: 1 0;
|
padding: 1 0;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class ModeSelected(Message):
|
class ModeSelected(Message):
|
||||||
"""Emitted when a game mode is selected."""
|
"""Emitted when a game mode is selected."""
|
||||||
def __init__(self, mode: str) -> None:
|
def __init__(self, mode: str) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
|
|
||||||
LOGO = """
|
LOGO = """
|
||||||
┌─────────────────────────────┐
|
┌─────────────────────────────┐
|
||||||
│ ♟️ S H E L L M A T E │
|
│ ♟️ S H E L L M A T E │
|
||||||
│ SSH into Chess Mastery │
|
│ SSH into Chess Mastery │
|
||||||
└─────────────────────────────┘
|
└─────────────────────────────┘
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def compose(self):
|
def compose(self):
|
||||||
with Vertical():
|
with Vertical():
|
||||||
yield Static(self.LOGO, classes="title")
|
yield Static(self.LOGO, classes="title")
|
||||||
@@ -65,7 +65,7 @@ class MainMenu(Widget):
|
|||||||
yield Button("📚 Learn Chess", id="btn-learn", variant="default")
|
yield Button("📚 Learn Chess", id="btn-learn", variant="default")
|
||||||
yield Button("👀 Spectate", id="btn-watch", variant="default")
|
yield Button("👀 Spectate", id="btn-watch", variant="default")
|
||||||
yield Static("Press Q to quit", classes="subtitle")
|
yield Static("Press Q to quit", classes="subtitle")
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
"""Handle button press."""
|
"""Handle button press."""
|
||||||
mode_map = {
|
mode_map = {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import chess
|
|||||||
|
|
||||||
class MoveInput(Input):
|
class MoveInput(Input):
|
||||||
"""Input widget for chess moves."""
|
"""Input widget for chess moves."""
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
MoveInput {
|
MoveInput {
|
||||||
dock: bottom;
|
dock: bottom;
|
||||||
@@ -15,46 +15,46 @@ class MoveInput(Input):
|
|||||||
margin: 1 0;
|
margin: 1 0;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class MoveSubmitted(Message):
|
class MoveSubmitted(Message):
|
||||||
"""Message sent when a move is submitted."""
|
"""Message sent when a move is submitted."""
|
||||||
def __init__(self, move: str) -> None:
|
def __init__(self, move: str) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.move = move
|
self.move = move
|
||||||
|
|
||||||
class CommandSubmitted(Message):
|
class CommandSubmitted(Message):
|
||||||
"""Message sent when a command is submitted."""
|
"""Message sent when a command is submitted."""
|
||||||
def __init__(self, command: str) -> None:
|
def __init__(self, command: str) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.command = command
|
self.command = command
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
placeholder="Enter move (e.g., e4, Nf3, O-O) or command (/hint, /resign)",
|
placeholder="Enter move (e.g., e4, Nf3, O-O) or command (/hint, /resign)",
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
"""Handle input submission."""
|
"""Handle input submission."""
|
||||||
value = event.value.strip()
|
value = event.value.strip()
|
||||||
|
|
||||||
if not value:
|
if not value:
|
||||||
return
|
return
|
||||||
|
|
||||||
if value.startswith('/'):
|
if value.startswith('/'):
|
||||||
# It's a command
|
# It's a command
|
||||||
self.post_message(self.CommandSubmitted(value[1:]))
|
self.post_message(self.CommandSubmitted(value[1:]))
|
||||||
else:
|
else:
|
||||||
# It's a move
|
# It's a move
|
||||||
self.post_message(self.MoveSubmitted(value))
|
self.post_message(self.MoveSubmitted(value))
|
||||||
|
|
||||||
self.value = ""
|
self.value = ""
|
||||||
|
|
||||||
|
|
||||||
def parse_move(board: chess.Board, move_str: str) -> chess.Move | None:
|
def parse_move(board: chess.Board, move_str: str) -> chess.Move | None:
|
||||||
"""Parse a move string into a chess.Move object."""
|
"""Parse a move string into a chess.Move object."""
|
||||||
move_str = move_str.strip()
|
move_str = move_str.strip()
|
||||||
|
|
||||||
# Try UCI format first (e2e4)
|
# Try UCI format first (e2e4)
|
||||||
try:
|
try:
|
||||||
move = chess.Move.from_uci(move_str)
|
move = chess.Move.from_uci(move_str)
|
||||||
@@ -62,7 +62,7 @@ def parse_move(board: chess.Board, move_str: str) -> chess.Move | None:
|
|||||||
return move
|
return move
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Try SAN format (e4, Nf3, O-O)
|
# Try SAN format (e4, Nf3, O-O)
|
||||||
try:
|
try:
|
||||||
move = board.parse_san(move_str)
|
move = board.parse_san(move_str)
|
||||||
@@ -70,5 +70,5 @@ def parse_move(board: chess.Board, move_str: str) -> chess.Move | None:
|
|||||||
return move
|
return move
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import chess
|
|||||||
|
|
||||||
class MoveListWidget(Widget):
|
class MoveListWidget(Widget):
|
||||||
"""Display the move history in standard notation."""
|
"""Display the move history in standard notation."""
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
MoveListWidget {
|
MoveListWidget {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -19,47 +19,47 @@ class MoveListWidget(Widget):
|
|||||||
border: solid #333;
|
border: solid #333;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
moves: reactive[list] = reactive(list, always_update=True)
|
moves: reactive[list] = reactive(list, always_update=True)
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self._moves: list[str] = []
|
self._moves: list[str] = []
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
"""Render the move list."""
|
"""Render the move list."""
|
||||||
text = Text()
|
text = Text()
|
||||||
text.append("Move History\n", style="bold underline")
|
text.append("Move History\n", style="bold underline")
|
||||||
text.append("─" * 20 + "\n", style="dim")
|
text.append("─" * 20 + "\n", style="dim")
|
||||||
|
|
||||||
if not self._moves:
|
if not self._moves:
|
||||||
text.append("No moves yet", style="dim italic")
|
text.append("No moves yet", style="dim italic")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
# Display moves in pairs (white, black)
|
# Display moves in pairs (white, black)
|
||||||
for i in range(0, len(self._moves), 2):
|
for i in range(0, len(self._moves), 2):
|
||||||
move_num = i // 2 + 1
|
move_num = i // 2 + 1
|
||||||
white_move = self._moves[i]
|
white_move = self._moves[i]
|
||||||
black_move = self._moves[i + 1] if i + 1 < len(self._moves) else ""
|
black_move = self._moves[i + 1] if i + 1 < len(self._moves) else ""
|
||||||
|
|
||||||
text.append(f"{move_num:>3}. ", style="dim")
|
text.append(f"{move_num:>3}. ", style="dim")
|
||||||
text.append(f"{white_move:<8}", style="bold white")
|
text.append(f"{white_move:<8}", style="bold white")
|
||||||
if black_move:
|
if black_move:
|
||||||
text.append(f"{black_move:<8}", style="bold green")
|
text.append(f"{black_move:<8}", style="bold green")
|
||||||
text.append("\n")
|
text.append("\n")
|
||||||
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def add_move(self, move_san: str) -> None:
|
def add_move(self, move_san: str) -> None:
|
||||||
"""Add a move to the history."""
|
"""Add a move to the history."""
|
||||||
self._moves.append(move_san)
|
self._moves.append(move_san)
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Clear the move history."""
|
"""Clear the move history."""
|
||||||
self._moves = []
|
self._moves = []
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def undo(self) -> str | None:
|
def undo(self) -> str | None:
|
||||||
"""Remove and return the last move."""
|
"""Remove and return the last move."""
|
||||||
if self._moves:
|
if self._moves:
|
||||||
@@ -67,7 +67,7 @@ class MoveListWidget(Widget):
|
|||||||
self.refresh()
|
self.refresh()
|
||||||
return move
|
return move
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_pgn_moves(self) -> str:
|
def get_pgn_moves(self) -> str:
|
||||||
"""Get moves in PGN format."""
|
"""Get moves in PGN format."""
|
||||||
result = []
|
result = []
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import chess
|
|||||||
|
|
||||||
class GameStatusWidget(Widget):
|
class GameStatusWidget(Widget):
|
||||||
"""Display current game status."""
|
"""Display current game status."""
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
GameStatusWidget {
|
GameStatusWidget {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -19,7 +19,7 @@ class GameStatusWidget(Widget):
|
|||||||
border: solid #333;
|
border: solid #333;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self._turn = chess.WHITE
|
self._turn = chess.WHITE
|
||||||
@@ -32,15 +32,15 @@ class GameStatusWidget(Widget):
|
|||||||
self._white_time: int | None = None # seconds
|
self._white_time: int | None = None # seconds
|
||||||
self._black_time: int | None = None
|
self._black_time: int | None = None
|
||||||
self._evaluation: float | None = None
|
self._evaluation: float | None = None
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
"""Render the status panel."""
|
"""Render the status panel."""
|
||||||
text = Text()
|
text = Text()
|
||||||
|
|
||||||
# Player indicator
|
# Player indicator
|
||||||
turn_indicator = "⚪" if self._turn == chess.WHITE else "⚫"
|
turn_indicator = "⚪" if self._turn == chess.WHITE else "⚫"
|
||||||
turn_name = self._white_name if self._turn == chess.WHITE else self._black_name
|
turn_name = self._white_name if self._turn == chess.WHITE else self._black_name
|
||||||
|
|
||||||
if self._is_checkmate:
|
if self._is_checkmate:
|
||||||
winner = self._black_name if self._turn == chess.WHITE else self._white_name
|
winner = self._black_name if self._turn == chess.WHITE else self._white_name
|
||||||
text.append(f"♚ CHECKMATE!\n", style="bold red")
|
text.append(f"♚ CHECKMATE!\n", style="bold red")
|
||||||
@@ -55,20 +55,20 @@ class GameStatusWidget(Widget):
|
|||||||
text.append(f"{turn_indicator} {turn_name}'s turn\n", style="bold")
|
text.append(f"{turn_indicator} {turn_name}'s turn\n", style="bold")
|
||||||
if self._is_check:
|
if self._is_check:
|
||||||
text.append("⚠ CHECK!\n", style="bold red")
|
text.append("⚠ CHECK!\n", style="bold red")
|
||||||
|
|
||||||
# Time controls (if set)
|
# Time controls (if set)
|
||||||
if self._white_time is not None or self._black_time is not None:
|
if self._white_time is not None or self._black_time is not None:
|
||||||
text.append("\n")
|
text.append("\n")
|
||||||
white_time_str = self._format_time(self._white_time)
|
white_time_str = self._format_time(self._white_time)
|
||||||
black_time_str = self._format_time(self._black_time)
|
black_time_str = self._format_time(self._black_time)
|
||||||
|
|
||||||
white_style = "bold" if self._turn == chess.WHITE else "dim"
|
white_style = "bold" if self._turn == chess.WHITE else "dim"
|
||||||
black_style = "bold" if self._turn == chess.BLACK else "dim"
|
black_style = "bold" if self._turn == chess.BLACK else "dim"
|
||||||
|
|
||||||
text.append(f"⚪ {white_time_str}", style=white_style)
|
text.append(f"⚪ {white_time_str}", style=white_style)
|
||||||
text.append(" │ ", style="dim")
|
text.append(" │ ", style="dim")
|
||||||
text.append(f"⚫ {black_time_str}", style=black_style)
|
text.append(f"⚫ {black_time_str}", style=black_style)
|
||||||
|
|
||||||
# Evaluation (if available)
|
# Evaluation (if available)
|
||||||
if self._evaluation is not None and not (self._is_checkmate or self._is_stalemate or self._is_draw):
|
if self._evaluation is not None and not (self._is_checkmate or self._is_stalemate or self._is_draw):
|
||||||
text.append("\n\n")
|
text.append("\n\n")
|
||||||
@@ -76,9 +76,9 @@ class GameStatusWidget(Widget):
|
|||||||
bar = self._eval_bar(self._evaluation)
|
bar = self._eval_bar(self._evaluation)
|
||||||
text.append(f"Eval: {eval_str}\n", style="dim")
|
text.append(f"Eval: {eval_str}\n", style="dim")
|
||||||
text.append(bar)
|
text.append(bar)
|
||||||
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def _format_time(self, seconds: int | None) -> str:
|
def _format_time(self, seconds: int | None) -> str:
|
||||||
"""Format seconds as MM:SS."""
|
"""Format seconds as MM:SS."""
|
||||||
if seconds is None:
|
if seconds is None:
|
||||||
@@ -86,24 +86,24 @@ class GameStatusWidget(Widget):
|
|||||||
mins = seconds // 60
|
mins = seconds // 60
|
||||||
secs = seconds % 60
|
secs = seconds % 60
|
||||||
return f"{mins:02d}:{secs:02d}"
|
return f"{mins:02d}:{secs:02d}"
|
||||||
|
|
||||||
def _eval_bar(self, evaluation: float) -> Text:
|
def _eval_bar(self, evaluation: float) -> Text:
|
||||||
"""Create a visual evaluation bar."""
|
"""Create a visual evaluation bar."""
|
||||||
text = Text()
|
text = Text()
|
||||||
bar_width = 20
|
bar_width = 20
|
||||||
|
|
||||||
# Clamp evaluation to -5 to +5 for display
|
# Clamp evaluation to -5 to +5 for display
|
||||||
clamped = max(-5, min(5, evaluation))
|
clamped = max(-5, min(5, evaluation))
|
||||||
# Convert to 0-1 scale (0.5 = equal)
|
# Convert to 0-1 scale (0.5 = equal)
|
||||||
normalized = (clamped + 5) / 10
|
normalized = (clamped + 5) / 10
|
||||||
white_chars = int(normalized * bar_width)
|
white_chars = int(normalized * bar_width)
|
||||||
black_chars = bar_width - white_chars
|
black_chars = bar_width - white_chars
|
||||||
|
|
||||||
text.append("█" * white_chars, style="white")
|
text.append("█" * white_chars, style="white")
|
||||||
text.append("█" * black_chars, style="green")
|
text.append("█" * black_chars, style="green")
|
||||||
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def update_from_board(self, board: chess.Board) -> None:
|
def update_from_board(self, board: chess.Board) -> None:
|
||||||
"""Update status from a board state."""
|
"""Update status from a board state."""
|
||||||
self._turn = board.turn
|
self._turn = board.turn
|
||||||
@@ -112,19 +112,19 @@ class GameStatusWidget(Widget):
|
|||||||
self._is_stalemate = board.is_stalemate()
|
self._is_stalemate = board.is_stalemate()
|
||||||
self._is_draw = board.is_insufficient_material() or board.can_claim_draw()
|
self._is_draw = board.is_insufficient_material() or board.can_claim_draw()
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def set_players(self, white: str, black: str) -> None:
|
def set_players(self, white: str, black: str) -> None:
|
||||||
"""Set player names."""
|
"""Set player names."""
|
||||||
self._white_name = white
|
self._white_name = white
|
||||||
self._black_name = black
|
self._black_name = black
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def set_time(self, white_seconds: int | None, black_seconds: int | None) -> None:
|
def set_time(self, white_seconds: int | None, black_seconds: int | None) -> None:
|
||||||
"""Set time remaining."""
|
"""Set time remaining."""
|
||||||
self._white_time = white_seconds
|
self._white_time = white_seconds
|
||||||
self._black_time = black_seconds
|
self._black_time = black_seconds
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def set_evaluation(self, evaluation: float | None) -> None:
|
def set_evaluation(self, evaluation: float | None) -> None:
|
||||||
"""Set position evaluation."""
|
"""Set position evaluation."""
|
||||||
self._evaluation = evaluation
|
self._evaluation = evaluation
|
||||||
|
|||||||
@@ -7,46 +7,46 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
class TestShellMateSSHServer:
|
class TestShellMateSSHServer:
|
||||||
"""Test SSH server authentication."""
|
"""Test SSH server authentication."""
|
||||||
|
|
||||||
def test_begin_auth_returns_false(self):
|
def test_begin_auth_returns_false(self):
|
||||||
"""Auth should complete immediately (no auth required)."""
|
"""Auth should complete immediately (no auth required)."""
|
||||||
from shellmate.ssh.server import ShellMateSSHServer
|
from shellmate.ssh.server import ShellMateSSHServer
|
||||||
|
|
||||||
server = ShellMateSSHServer()
|
server = ShellMateSSHServer()
|
||||||
result = server.begin_auth("anyuser")
|
result = server.begin_auth("anyuser")
|
||||||
|
|
||||||
assert result is False, "begin_auth should return False for no-auth"
|
assert result is False, "begin_auth should return False for no-auth"
|
||||||
|
|
||||||
def test_password_auth_accepts_any(self):
|
def test_password_auth_accepts_any(self):
|
||||||
"""Any password should be accepted."""
|
"""Any password should be accepted."""
|
||||||
from shellmate.ssh.server import ShellMateSSHServer
|
from shellmate.ssh.server import ShellMateSSHServer
|
||||||
|
|
||||||
server = ShellMateSSHServer()
|
server = ShellMateSSHServer()
|
||||||
|
|
||||||
assert server.password_auth_supported() is True
|
assert server.password_auth_supported() is True
|
||||||
assert server.validate_password("user", "") is True
|
assert server.validate_password("user", "") is True
|
||||||
assert server.validate_password("user", "anypass") is True
|
assert server.validate_password("user", "anypass") is True
|
||||||
assert server.validate_password("guest", "password123") is True
|
assert server.validate_password("guest", "password123") is True
|
||||||
|
|
||||||
def test_pubkey_auth_accepts_any(self):
|
def test_pubkey_auth_accepts_any(self):
|
||||||
"""Any public key should be accepted."""
|
"""Any public key should be accepted."""
|
||||||
from shellmate.ssh.server import ShellMateSSHServer
|
from shellmate.ssh.server import ShellMateSSHServer
|
||||||
|
|
||||||
server = ShellMateSSHServer()
|
server = ShellMateSSHServer()
|
||||||
mock_key = MagicMock()
|
mock_key = MagicMock()
|
||||||
|
|
||||||
assert server.public_key_auth_supported() is True
|
assert server.public_key_auth_supported() is True
|
||||||
assert server.validate_public_key("user", mock_key) is True
|
assert server.validate_public_key("user", mock_key) is True
|
||||||
|
|
||||||
|
|
||||||
class TestModeSelection:
|
class TestModeSelection:
|
||||||
"""Test username-to-mode mapping."""
|
"""Test username-to-mode mapping."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_play_mode_default(self):
|
async def test_play_mode_default(self):
|
||||||
"""Default users should get play mode."""
|
"""Default users should get play mode."""
|
||||||
from shellmate.ssh.server import handle_client
|
from shellmate.ssh.server import handle_client
|
||||||
|
|
||||||
process = MagicMock()
|
process = MagicMock()
|
||||||
process.get_extra_info = MagicMock(return_value="greg")
|
process.get_extra_info = MagicMock(return_value="greg")
|
||||||
process.get_terminal_type = MagicMock(return_value="xterm-256color")
|
process.get_terminal_type = MagicMock(return_value="xterm-256color")
|
||||||
@@ -54,24 +54,24 @@ class TestModeSelection:
|
|||||||
process.stdin = AsyncMock()
|
process.stdin = AsyncMock()
|
||||||
process.stdout = MagicMock()
|
process.stdout = MagicMock()
|
||||||
process.exit = MagicMock()
|
process.exit = MagicMock()
|
||||||
|
|
||||||
# Mock stdin to return quit immediately
|
# Mock stdin to return quit immediately
|
||||||
process.stdin.read = AsyncMock(return_value=b'q')
|
process.stdin.read = AsyncMock(return_value=b'q')
|
||||||
|
|
||||||
with patch('shellmate.ssh.server.run_simple_menu', new_callable=AsyncMock) as mock_menu:
|
with patch('shellmate.ssh.server.run_simple_menu', new_callable=AsyncMock) as mock_menu:
|
||||||
mock_menu.return_value = None
|
mock_menu.return_value = None
|
||||||
await handle_client(process)
|
await handle_client(process)
|
||||||
|
|
||||||
# Verify mode was 'play'
|
# Verify mode was 'play'
|
||||||
mock_menu.assert_called_once()
|
mock_menu.assert_called_once()
|
||||||
call_args = mock_menu.call_args
|
call_args = mock_menu.call_args
|
||||||
assert call_args[0][2] == "play" # mode argument
|
assert call_args[0][2] == "play" # mode argument
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_learn_mode(self):
|
async def test_learn_mode(self):
|
||||||
"""Username 'learn' should get tutorial mode."""
|
"""Username 'learn' should get tutorial mode."""
|
||||||
from shellmate.ssh.server import handle_client
|
from shellmate.ssh.server import handle_client
|
||||||
|
|
||||||
process = MagicMock()
|
process = MagicMock()
|
||||||
process.get_extra_info = MagicMock(return_value="learn")
|
process.get_extra_info = MagicMock(return_value="learn")
|
||||||
process.get_terminal_type = MagicMock(return_value="xterm")
|
process.get_terminal_type = MagicMock(return_value="xterm")
|
||||||
@@ -80,18 +80,18 @@ class TestModeSelection:
|
|||||||
process.stdout = MagicMock()
|
process.stdout = MagicMock()
|
||||||
process.exit = MagicMock()
|
process.exit = MagicMock()
|
||||||
process.stdin.read = AsyncMock(return_value=b'q')
|
process.stdin.read = AsyncMock(return_value=b'q')
|
||||||
|
|
||||||
with patch('shellmate.ssh.server.run_simple_menu', new_callable=AsyncMock) as mock_menu:
|
with patch('shellmate.ssh.server.run_simple_menu', new_callable=AsyncMock) as mock_menu:
|
||||||
await handle_client(process)
|
await handle_client(process)
|
||||||
|
|
||||||
call_args = mock_menu.call_args
|
call_args = mock_menu.call_args
|
||||||
assert call_args[0][2] == "tutorial"
|
assert call_args[0][2] == "tutorial"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_watch_mode(self):
|
async def test_watch_mode(self):
|
||||||
"""Username 'watch' should get spectate mode."""
|
"""Username 'watch' should get spectate mode."""
|
||||||
from shellmate.ssh.server import handle_client
|
from shellmate.ssh.server import handle_client
|
||||||
|
|
||||||
process = MagicMock()
|
process = MagicMock()
|
||||||
process.get_extra_info = MagicMock(return_value="watch")
|
process.get_extra_info = MagicMock(return_value="watch")
|
||||||
process.get_terminal_type = MagicMock(return_value="xterm")
|
process.get_terminal_type = MagicMock(return_value="xterm")
|
||||||
@@ -100,100 +100,100 @@ class TestModeSelection:
|
|||||||
process.stdout = MagicMock()
|
process.stdout = MagicMock()
|
||||||
process.exit = MagicMock()
|
process.exit = MagicMock()
|
||||||
process.stdin.read = AsyncMock(return_value=b'q')
|
process.stdin.read = AsyncMock(return_value=b'q')
|
||||||
|
|
||||||
with patch('shellmate.ssh.server.run_simple_menu', new_callable=AsyncMock) as mock_menu:
|
with patch('shellmate.ssh.server.run_simple_menu', new_callable=AsyncMock) as mock_menu:
|
||||||
await handle_client(process)
|
await handle_client(process)
|
||||||
|
|
||||||
call_args = mock_menu.call_args
|
call_args = mock_menu.call_args
|
||||||
assert call_args[0][2] == "spectate"
|
assert call_args[0][2] == "spectate"
|
||||||
|
|
||||||
|
|
||||||
class TestChessBoard:
|
class TestChessBoard:
|
||||||
"""Test chess board widget rendering."""
|
"""Test chess board widget rendering."""
|
||||||
|
|
||||||
def test_board_initialization(self):
|
def test_board_initialization(self):
|
||||||
"""Board should initialize with standard position."""
|
"""Board should initialize with standard position."""
|
||||||
from shellmate.tui.widgets.board import ChessBoardWidget
|
from shellmate.tui.widgets.board import ChessBoardWidget
|
||||||
import chess
|
import chess
|
||||||
|
|
||||||
widget = ChessBoardWidget()
|
widget = ChessBoardWidget()
|
||||||
|
|
||||||
assert widget.board.fen() == chess.STARTING_FEN
|
assert widget.board.fen() == chess.STARTING_FEN
|
||||||
assert widget.selected_square is None
|
assert widget.selected_square is None
|
||||||
assert widget.flipped is False
|
assert widget.flipped is False
|
||||||
|
|
||||||
def test_board_flip(self):
|
def test_board_flip(self):
|
||||||
"""Board flip should toggle perspective."""
|
"""Board flip should toggle perspective."""
|
||||||
from shellmate.tui.widgets.board import ChessBoardWidget
|
from shellmate.tui.widgets.board import ChessBoardWidget
|
||||||
|
|
||||||
widget = ChessBoardWidget()
|
widget = ChessBoardWidget()
|
||||||
|
|
||||||
assert widget.flipped is False
|
assert widget.flipped is False
|
||||||
widget.flip()
|
widget.flip()
|
||||||
assert widget.flipped is True
|
assert widget.flipped is True
|
||||||
widget.flip()
|
widget.flip()
|
||||||
assert widget.flipped is False
|
assert widget.flipped is False
|
||||||
|
|
||||||
def test_square_selection(self):
|
def test_square_selection(self):
|
||||||
"""Selecting a square should show legal moves."""
|
"""Selecting a square should show legal moves."""
|
||||||
from shellmate.tui.widgets.board import ChessBoardWidget
|
from shellmate.tui.widgets.board import ChessBoardWidget
|
||||||
import chess
|
import chess
|
||||||
|
|
||||||
widget = ChessBoardWidget()
|
widget = ChessBoardWidget()
|
||||||
|
|
||||||
# Select e2 pawn
|
# Select e2 pawn
|
||||||
e2 = chess.E2
|
e2 = chess.E2
|
||||||
widget.select_square(e2)
|
widget.select_square(e2)
|
||||||
|
|
||||||
assert widget.selected_square == e2
|
assert widget.selected_square == e2
|
||||||
assert chess.E3 in widget.legal_moves
|
assert chess.E3 in widget.legal_moves
|
||||||
assert chess.E4 in widget.legal_moves
|
assert chess.E4 in widget.legal_moves
|
||||||
assert len(widget.legal_moves) == 2 # e3 and e4
|
assert len(widget.legal_moves) == 2 # e3 and e4
|
||||||
|
|
||||||
def test_square_deselection(self):
|
def test_square_deselection(self):
|
||||||
"""Deselecting should clear legal moves."""
|
"""Deselecting should clear legal moves."""
|
||||||
from shellmate.tui.widgets.board import ChessBoardWidget
|
from shellmate.tui.widgets.board import ChessBoardWidget
|
||||||
import chess
|
import chess
|
||||||
|
|
||||||
widget = ChessBoardWidget()
|
widget = ChessBoardWidget()
|
||||||
widget.select_square(chess.E2)
|
widget.select_square(chess.E2)
|
||||||
widget.select_square(None)
|
widget.select_square(None)
|
||||||
|
|
||||||
assert widget.selected_square is None
|
assert widget.selected_square is None
|
||||||
assert len(widget.legal_moves) == 0
|
assert len(widget.legal_moves) == 0
|
||||||
|
|
||||||
|
|
||||||
class TestMoveValidation:
|
class TestMoveValidation:
|
||||||
"""Test move parsing and validation."""
|
"""Test move parsing and validation."""
|
||||||
|
|
||||||
def test_valid_uci_move(self):
|
def test_valid_uci_move(self):
|
||||||
"""Valid UCI moves should be accepted."""
|
"""Valid UCI moves should be accepted."""
|
||||||
import chess
|
import chess
|
||||||
|
|
||||||
board = chess.Board()
|
board = chess.Board()
|
||||||
move = chess.Move.from_uci("e2e4")
|
move = chess.Move.from_uci("e2e4")
|
||||||
|
|
||||||
assert move in board.legal_moves
|
assert move in board.legal_moves
|
||||||
|
|
||||||
def test_invalid_uci_move(self):
|
def test_invalid_uci_move(self):
|
||||||
"""Invalid UCI moves should be rejected."""
|
"""Invalid UCI moves should be rejected."""
|
||||||
import chess
|
import chess
|
||||||
|
|
||||||
board = chess.Board()
|
board = chess.Board()
|
||||||
move = chess.Move.from_uci("e2e5") # Invalid - pawn can't go there
|
move = chess.Move.from_uci("e2e5") # Invalid - pawn can't go there
|
||||||
|
|
||||||
assert move not in board.legal_moves
|
assert move not in board.legal_moves
|
||||||
|
|
||||||
def test_algebraic_to_uci(self):
|
def test_algebraic_to_uci(self):
|
||||||
"""Test converting algebraic notation."""
|
"""Test converting algebraic notation."""
|
||||||
import chess
|
import chess
|
||||||
|
|
||||||
board = chess.Board()
|
board = chess.Board()
|
||||||
|
|
||||||
# e4 in algebraic
|
# e4 in algebraic
|
||||||
move = board.parse_san("e4")
|
move = board.parse_san("e4")
|
||||||
assert move.uci() == "e2e4"
|
assert move.uci() == "e2e4"
|
||||||
|
|
||||||
# Nf3 in algebraic
|
# Nf3 in algebraic
|
||||||
board.push_san("e4")
|
board.push_san("e4")
|
||||||
board.push_san("e5")
|
board.push_san("e5")
|
||||||
@@ -203,77 +203,77 @@ class TestMoveValidation:
|
|||||||
|
|
||||||
class TestGameState:
|
class TestGameState:
|
||||||
"""Test game state detection."""
|
"""Test game state detection."""
|
||||||
|
|
||||||
def test_checkmate_detection(self):
|
def test_checkmate_detection(self):
|
||||||
"""Checkmate should be detected."""
|
"""Checkmate should be detected."""
|
||||||
import chess
|
import chess
|
||||||
|
|
||||||
# Fool's mate
|
# Fool's mate
|
||||||
board = chess.Board()
|
board = chess.Board()
|
||||||
board.push_san("f3")
|
board.push_san("f3")
|
||||||
board.push_san("e5")
|
board.push_san("e5")
|
||||||
board.push_san("g4")
|
board.push_san("g4")
|
||||||
board.push_san("Qh4")
|
board.push_san("Qh4")
|
||||||
|
|
||||||
assert board.is_checkmate()
|
assert board.is_checkmate()
|
||||||
assert board.is_game_over()
|
assert board.is_game_over()
|
||||||
|
|
||||||
def test_stalemate_detection(self):
|
def test_stalemate_detection(self):
|
||||||
"""Stalemate should be detected."""
|
"""Stalemate should be detected."""
|
||||||
import chess
|
import chess
|
||||||
|
|
||||||
# Set up a stalemate position
|
# Set up a stalemate position
|
||||||
board = chess.Board("k7/8/1K6/8/8/8/8/8 b - - 0 1")
|
board = chess.Board("k7/8/1K6/8/8/8/8/8 b - - 0 1")
|
||||||
|
|
||||||
# This isn't stalemate, let's use a real one
|
# This isn't stalemate, let's use a real one
|
||||||
board = chess.Board("k7/8/8/8/8/8/1R6/K7 b - - 0 1")
|
board = chess.Board("k7/8/8/8/8/8/1R6/K7 b - - 0 1")
|
||||||
# Actually, let's just check the method exists
|
# Actually, let's just check the method exists
|
||||||
assert hasattr(board, 'is_stalemate')
|
assert hasattr(board, 'is_stalemate')
|
||||||
|
|
||||||
def test_check_detection(self):
|
def test_check_detection(self):
|
||||||
"""Check should be detected."""
|
"""Check should be detected."""
|
||||||
import chess
|
import chess
|
||||||
|
|
||||||
board = chess.Board()
|
board = chess.Board()
|
||||||
board.push_san("e4")
|
board.push_san("e4")
|
||||||
board.push_san("e5")
|
board.push_san("e5")
|
||||||
board.push_san("Qh5")
|
board.push_san("Qh5")
|
||||||
board.push_san("Nc6")
|
board.push_san("Nc6")
|
||||||
board.push_san("Qxf7")
|
board.push_san("Qxf7")
|
||||||
|
|
||||||
assert board.is_check()
|
assert board.is_check()
|
||||||
|
|
||||||
|
|
||||||
# Integration tests
|
# Integration tests
|
||||||
class TestIntegration:
|
class TestIntegration:
|
||||||
"""Integration tests for full flow."""
|
"""Integration tests for full flow."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_server_starts(self):
|
async def test_server_starts(self):
|
||||||
"""Server should start without errors."""
|
"""Server should start without errors."""
|
||||||
from shellmate.ssh.server import start_server
|
from shellmate.ssh.server import start_server
|
||||||
import tempfile
|
import tempfile
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Create a temporary host key
|
# Create a temporary host key
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
key_path = os.path.join(tmpdir, "test_key")
|
key_path = os.path.join(tmpdir, "test_key")
|
||||||
|
|
||||||
# Generate a key
|
# Generate a key
|
||||||
import subprocess
|
import subprocess
|
||||||
subprocess.run([
|
subprocess.run([
|
||||||
"ssh-keygen", "-t", "ed25519", "-f", key_path, "-N", ""
|
"ssh-keygen", "-t", "ed25519", "-f", key_path, "-N", ""
|
||||||
], check=True, capture_output=True)
|
], check=True, capture_output=True)
|
||||||
|
|
||||||
# Start server on a random port
|
# Start server on a random port
|
||||||
server = await start_server(
|
server = await start_server(
|
||||||
host="127.0.0.1",
|
host="127.0.0.1",
|
||||||
port=0, # Random available port
|
port=0, # Random available port
|
||||||
host_keys=[key_path],
|
host_keys=[key_path],
|
||||||
)
|
)
|
||||||
|
|
||||||
assert server is not None
|
assert server is not None
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
server.close()
|
server.close()
|
||||||
await server.wait_closed()
|
await server.wait_closed()
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ def test_menu_render_no_markup_errors():
|
|||||||
# Simulate the menu rendering code
|
# Simulate the menu rendering code
|
||||||
output = StringIO()
|
output = StringIO()
|
||||||
console = Console(file=output, width=80, height=24, force_terminal=True, color_system="truecolor")
|
console = Console(file=output, width=80, height=24, force_terminal=True, color_system="truecolor")
|
||||||
|
|
||||||
username = "testuser"
|
username = "testuser"
|
||||||
width = 80
|
width = 80
|
||||||
height = 24
|
height = 24
|
||||||
pieces = "♔ ♕ ♖ ♗ ♘ ♙"
|
pieces = "♔ ♕ ♖ ♗ ♘ ♙"
|
||||||
|
|
||||||
# Title section
|
# Title section
|
||||||
console.print(Align.center(Text(pieces, style="dim white")))
|
console.print(Align.center(Text(pieces, style="dim white")))
|
||||||
console.print()
|
console.print()
|
||||||
@@ -29,7 +29,7 @@ def test_menu_render_no_markup_errors():
|
|||||||
console.print(Align.center(Text("SSH into Chess Mastery", style="italic bright_black")))
|
console.print(Align.center(Text("SSH into Chess Mastery", style="italic bright_black")))
|
||||||
console.print(Align.center(Text(pieces[::-1], style="dim white")))
|
console.print(Align.center(Text(pieces[::-1], style="dim white")))
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
# Menu table - this is where markup errors often occur
|
# Menu table - this is where markup errors often occur
|
||||||
menu_table = Table(show_header=False, box=None, padding=(0, 2))
|
menu_table = Table(show_header=False, box=None, padding=(0, 2))
|
||||||
menu_table.add_column(justify="center")
|
menu_table.add_column(justify="center")
|
||||||
@@ -41,7 +41,7 @@ def test_menu_render_no_markup_errors():
|
|||||||
menu_table.add_row("[bright_white on red] q [/bright_white on red] Quit [dim]👋[/dim]")
|
menu_table.add_row("[bright_white on red] q [/bright_white on red] Quit [dim]👋[/dim]")
|
||||||
menu_table.add_row("")
|
menu_table.add_row("")
|
||||||
menu_table.add_row(Text("Press a key to select...", style="dim italic"))
|
menu_table.add_row(Text("Press a key to select...", style="dim italic"))
|
||||||
|
|
||||||
panel_width = min(45, width - 4)
|
panel_width = min(45, width - 4)
|
||||||
panel = Panel(
|
panel = Panel(
|
||||||
Align.center(menu_table),
|
Align.center(menu_table),
|
||||||
@@ -51,11 +51,11 @@ def test_menu_render_no_markup_errors():
|
|||||||
padding=(1, 2),
|
padding=(1, 2),
|
||||||
)
|
)
|
||||||
console.print(Align.center(panel))
|
console.print(Align.center(panel))
|
||||||
|
|
||||||
# Footer
|
# Footer
|
||||||
console.print()
|
console.print()
|
||||||
console.print(Align.center(Text(f"Terminal: {width}×{height}", style="dim")))
|
console.print(Align.center(Text(f"Terminal: {width}×{height}", style="dim")))
|
||||||
|
|
||||||
# If we got here without exception, markup is valid
|
# If we got here without exception, markup is valid
|
||||||
rendered = output.getvalue()
|
rendered = output.getvalue()
|
||||||
# Check key content is present (content may have ANSI codes)
|
# Check key content is present (content may have ANSI codes)
|
||||||
@@ -68,13 +68,13 @@ def test_game_status_render():
|
|||||||
"""Test that game status panel renders correctly."""
|
"""Test that game status panel renders correctly."""
|
||||||
output = StringIO()
|
output = StringIO()
|
||||||
console = Console(file=output, width=80, height=24, force_terminal=True)
|
console = Console(file=output, width=80, height=24, force_terminal=True)
|
||||||
|
|
||||||
status_lines = []
|
status_lines = []
|
||||||
status_lines.append("[bold white]White ♔ to move[/bold white]")
|
status_lines.append("[bold white]White ♔ to move[/bold white]")
|
||||||
status_lines.append("[dim]Moves: e2e4 e7e5 g1f3[/dim]")
|
status_lines.append("[dim]Moves: e2e4 e7e5 g1f3[/dim]")
|
||||||
status_lines.append("")
|
status_lines.append("")
|
||||||
status_lines.append("[dim]Enter move (e.g. e2e4) │ [q]uit │ [r]esign[/dim]")
|
status_lines.append("[dim]Enter move (e.g. e2e4) │ [q]uit │ [r]esign[/dim]")
|
||||||
|
|
||||||
panel = Panel(
|
panel = Panel(
|
||||||
"\n".join(status_lines),
|
"\n".join(status_lines),
|
||||||
box=ROUNDED,
|
box=ROUNDED,
|
||||||
@@ -83,7 +83,7 @@ def test_game_status_render():
|
|||||||
title="[bold]Game Status[/bold]"
|
title="[bold]Game Status[/bold]"
|
||||||
)
|
)
|
||||||
console.print(panel)
|
console.print(panel)
|
||||||
|
|
||||||
rendered = output.getvalue()
|
rendered = output.getvalue()
|
||||||
assert "White" in rendered
|
assert "White" in rendered
|
||||||
assert "Game Status" in rendered
|
assert "Game Status" in rendered
|
||||||
@@ -92,17 +92,17 @@ def test_game_status_render():
|
|||||||
def test_chess_board_render():
|
def test_chess_board_render():
|
||||||
"""Test that chess board renders without errors."""
|
"""Test that chess board renders without errors."""
|
||||||
output = StringIO()
|
output = StringIO()
|
||||||
|
|
||||||
PIECES = {
|
PIECES = {
|
||||||
'K': '♔', 'Q': '♕', 'R': '♖', 'B': '♗', 'N': '♘', 'P': '♙',
|
'K': '♔', 'Q': '♕', 'R': '♖', 'B': '♗', 'N': '♘', 'P': '♙',
|
||||||
'k': '♚', 'q': '♛', 'r': '♜', 'b': '♝', 'n': '♞', 'p': '♟',
|
'k': '♚', 'q': '♛', 'r': '♜', 'b': '♝', 'n': '♞', 'p': '♟',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Simplified board rendering (plain text)
|
# Simplified board rendering (plain text)
|
||||||
lines = []
|
lines = []
|
||||||
lines.append(" a b c d e f g h")
|
lines.append(" a b c d e f g h")
|
||||||
lines.append(" +---+---+---+---+---+---+---+---+")
|
lines.append(" +---+---+---+---+---+---+---+---+")
|
||||||
|
|
||||||
# Render rank 8 with pieces
|
# Render rank 8 with pieces
|
||||||
pieces_row = ['r', 'n', 'b', 'q', 'k', 'b', 'n', 'r']
|
pieces_row = ['r', 'n', 'b', 'q', 'k', 'b', 'n', 'r']
|
||||||
row = " 8 |"
|
row = " 8 |"
|
||||||
@@ -112,10 +112,10 @@ def test_chess_board_render():
|
|||||||
row += " 8"
|
row += " 8"
|
||||||
lines.append(row)
|
lines.append(row)
|
||||||
lines.append(" +---+---+---+---+---+---+---+---+")
|
lines.append(" +---+---+---+---+---+---+---+---+")
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
output.write(line + "\n")
|
output.write(line + "\n")
|
||||||
|
|
||||||
rendered = output.getvalue()
|
rendered = output.getvalue()
|
||||||
assert "a b c" in rendered
|
assert "a b c" in rendered
|
||||||
assert "♜" in rendered # Black rook
|
assert "♜" in rendered # Black rook
|
||||||
@@ -126,19 +126,19 @@ def test_narrow_terminal_render():
|
|||||||
"""Test that menu renders correctly on narrow terminals."""
|
"""Test that menu renders correctly on narrow terminals."""
|
||||||
output = StringIO()
|
output = StringIO()
|
||||||
console = Console(file=output, width=40, height=20, force_terminal=True)
|
console = Console(file=output, width=40, height=20, force_terminal=True)
|
||||||
|
|
||||||
# Small terminal fallback
|
# Small terminal fallback
|
||||||
console.print(Align.center(Text("♟ SHELLMATE ♟", style="bold green")))
|
console.print(Align.center(Text("♟ SHELLMATE ♟", style="bold green")))
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
menu_table = Table(show_header=False, box=None)
|
menu_table = Table(show_header=False, box=None)
|
||||||
menu_table.add_column(justify="center")
|
menu_table.add_column(justify="center")
|
||||||
menu_table.add_row(Text("Welcome!", style="cyan"))
|
menu_table.add_row(Text("Welcome!", style="cyan"))
|
||||||
menu_table.add_row("[dim]1. Play[/dim]")
|
menu_table.add_row("[dim]1. Play[/dim]")
|
||||||
menu_table.add_row("[dim]q. Quit[/dim]")
|
menu_table.add_row("[dim]q. Quit[/dim]")
|
||||||
|
|
||||||
console.print(Align.center(menu_table))
|
console.print(Align.center(menu_table))
|
||||||
|
|
||||||
rendered = output.getvalue()
|
rendered = output.getvalue()
|
||||||
assert "SHELLMATE" in rendered
|
assert "SHELLMATE" in rendered
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ def test_markup_escape_special_chars():
|
|||||||
"""Test that usernames with special chars don't break markup."""
|
"""Test that usernames with special chars don't break markup."""
|
||||||
output = StringIO()
|
output = StringIO()
|
||||||
console = Console(file=output, width=80, height=24, force_terminal=True)
|
console = Console(file=output, width=80, height=24, force_terminal=True)
|
||||||
|
|
||||||
# Usernames that could break markup
|
# Usernames that could break markup
|
||||||
test_usernames = [
|
test_usernames = [
|
||||||
"normal_user",
|
"normal_user",
|
||||||
@@ -156,11 +156,11 @@ def test_markup_escape_special_chars():
|
|||||||
"[admin]",
|
"[admin]",
|
||||||
"test/path",
|
"test/path",
|
||||||
]
|
]
|
||||||
|
|
||||||
for username in test_usernames:
|
for username in test_usernames:
|
||||||
# Using Text() object safely escapes special characters
|
# Using Text() object safely escapes special characters
|
||||||
console.print(Text(f"Welcome, {username}!", style="cyan"))
|
console.print(Text(f"Welcome, {username}!", style="cyan"))
|
||||||
|
|
||||||
rendered = output.getvalue()
|
rendered = output.getvalue()
|
||||||
assert "normal_user" in rendered
|
assert "normal_user" in rendered
|
||||||
# Text() should have escaped the brackets safely
|
# Text() should have escaped the brackets safely
|
||||||
|
|||||||
Reference in New Issue
Block a user