Add TUI lobby settings, clickable cards, and UI polish

- Lobby: collapsible Game Settings, House Rules, Deck Style sections
- Lobby: CPU profile picker via [+], random CPU via [?], remove via [-]
- Lobby: all settings (rounds, decks, flip mode, house rules, deck colors)
  sent to server on start_game instead of hardcoded defaults
- Game: clickable cards (hand positions, deck, discard pile)
- Game: immediate visual feedback on initial card flips
- Game: action bar shows escaped keyboard hints (Keyboard: Choose [d]eck...)
- Game: play area uses fixed-width rounded box instead of horizontal lines
- Game: position numbers on card top-left corner (replacing ┌) on all states
- Game: deck color preview swatches next to style dropdown
- Fix opponent box height mismatch when match connectors present
- Rebrand to GolfCards.club
- Add spacing between status bar/opponents and above local hand

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken 2026-02-24 19:23:27 -05:00
parent e601c3eac4
commit bfe29bb665
19 changed files with 2601 additions and 0 deletions

20
tui_client/pyproject.toml Normal file
View File

@ -0,0 +1,20 @@
[project]
name = "golf-tui"
version = "0.1.0"
description = "Terminal client for the Golf card game"
requires-python = ">=3.11"
dependencies = [
"textual>=0.47.0",
"websockets>=12.0",
"httpx>=0.25.0",
]
[project.scripts]
golf-tui = "tui_client.__main__:main"
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]

View File

@ -0,0 +1 @@
"""TUI client for the Golf card game."""

View File

@ -0,0 +1,59 @@
"""Entry point: python -m tui_client [--server HOST] [--no-tls]
Reads defaults from ~/.config/golf-tui.conf (create with --save-config).
"""
import argparse
import sys
from tui_client.config import load_config, save_config, CONFIG_PATH
def main():
cfg = load_config()
parser = argparse.ArgumentParser(description="Golf Card Game TUI Client")
parser.add_argument(
"--server",
default=cfg.get("server", "golfcards.club"),
help=f"Server host[:port] (default: {cfg.get('server', 'golfcards.club')})",
)
parser.add_argument(
"--no-tls",
action="store_true",
default=cfg.get("tls", "true").lower() != "true",
help="Use ws:// and http:// instead of wss:// and https://",
)
parser.add_argument(
"--debug",
action="store_true",
help="Enable debug logging to tui_debug.log",
)
parser.add_argument(
"--save-config",
action="store_true",
help=f"Save current options as defaults to {CONFIG_PATH}",
)
args = parser.parse_args()
if args.save_config:
save_config({
"server": args.server,
"tls": str(not args.no_tls).lower(),
})
print(f"Config saved to {CONFIG_PATH}")
print(f" server = {args.server}")
print(f" tls = {str(not args.no_tls).lower()}")
return
if args.debug:
import logging
logging.basicConfig(level=logging.DEBUG, filename="tui_debug.log")
from tui_client.app import GolfApp
app = GolfApp(server=args.server, use_tls=not args.no_tls)
app.run()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,115 @@
"""Main Textual App for the Golf TUI client."""
from __future__ import annotations
import time
from textual.app import App, ComposeResult
from textual.message import Message
from textual.widgets import Static
from tui_client.client import GameClient
class ServerMessage(Message):
"""A message received from the game server."""
def __init__(self, data: dict) -> None:
super().__init__()
self.msg_type: str = data.get("type", "")
self.data: dict = data
class KeymapBar(Static):
"""Bottom bar showing available keys for the current context."""
DEFAULT_CSS = """
KeymapBar {
dock: bottom;
height: 1;
background: #1a1a2e;
color: #888888;
padding: 0 1;
}
"""
class GolfApp(App):
"""Golf Card Game TUI Application."""
TITLE = "GolfCards.club"
CSS_PATH = "styles.tcss"
BINDINGS = [
("escape", "esc_pressed", ""),
]
def __init__(self, server: str, use_tls: bool = True):
super().__init__()
self.client = GameClient(server, use_tls)
self.client._app = self
self.player_id: str | None = None
self._last_escape: float = 0.0
def compose(self) -> ComposeResult:
yield KeymapBar(id="keymap-bar")
def on_mount(self) -> None:
from tui_client.screens.connect import ConnectScreen
self.push_screen(ConnectScreen())
self._update_keymap()
def on_screen_resume(self) -> None:
self._update_keymap()
def post_server_message(self, data: dict) -> None:
"""Called from GameClient listener to inject server messages."""
msg = ServerMessage(data)
self.call_later(self._route_server_message, msg)
def _route_server_message(self, msg: ServerMessage) -> None:
"""Forward a server message to the active screen."""
screen = self.screen
handler = getattr(screen, "on_server_message", None)
if handler:
handler(msg)
def action_esc_pressed(self) -> None:
"""Double-escape to hard quit. Single escape does nothing."""
now = time.monotonic()
if now - self._last_escape < 0.5:
self.exit()
else:
self._last_escape = now
def _update_keymap(self) -> None:
"""Update the keymap bar based on current screen."""
screen_name = self.screen.__class__.__name__
keymap = getattr(self.screen, "KEYMAP_HINT", None)
if keymap:
text = keymap
elif screen_name == "ConnectScreen":
text = "[Tab] Navigate [Enter] Submit [Esc Esc] Quit"
elif screen_name == "LobbyScreen":
text = "[Tab] Navigate [Enter] Submit [Esc Esc] Quit"
else:
text = "[Esc Esc] Quit"
try:
self.query_one("#keymap-bar", KeymapBar).update(text)
except Exception:
pass
def set_keymap(self, text: str) -> None:
"""Allow screens to update the keymap bar dynamically.
Always appends [Esc Esc] Quit on the right for discoverability.
"""
if "[Esc Esc]" not in text:
text = f"{text} [Esc Esc] Quit"
try:
self.query_one("#keymap-bar", KeymapBar).update(text)
except Exception:
pass
async def on_unmount(self) -> None:
await self.client.disconnect()

View File

@ -0,0 +1,114 @@
"""WebSocket + HTTP networking for the TUI client."""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Optional
import httpx
import websockets
from websockets.asyncio.client import ClientConnection
logger = logging.getLogger(__name__)
class GameClient:
"""Handles HTTP auth and WebSocket game communication."""
def __init__(self, host: str, use_tls: bool = True):
self.host = host
self.use_tls = use_tls
self._token: Optional[str] = None
self._ws: Optional[ClientConnection] = None
self._listener_task: Optional[asyncio.Task] = None
self._app = None # Set by GolfApp
self._username: Optional[str] = None
@property
def http_base(self) -> str:
scheme = "https" if self.use_tls else "http"
return f"{scheme}://{self.host}"
@property
def ws_url(self) -> str:
scheme = "wss" if self.use_tls else "ws"
url = f"{scheme}://{self.host}/ws"
if self._token:
url += f"?token={self._token}"
return url
@property
def is_authenticated(self) -> bool:
return self._token is not None
@property
def username(self) -> Optional[str]:
return self._username
async def login(self, username: str, password: str) -> dict:
"""Login via HTTP and store JWT token.
Returns the response dict on success, raises on failure.
"""
async with httpx.AsyncClient(verify=self.use_tls) as http:
resp = await http.post(
f"{self.http_base}/api/auth/login",
json={"username": username, "password": password},
)
if resp.status_code != 200:
detail = resp.json().get("detail", "Login failed")
raise ConnectionError(detail)
data = resp.json()
self._token = data["token"]
self._username = data["user"]["username"]
return data
async def connect(self) -> None:
"""Open WebSocket connection to the server."""
self._ws = await websockets.connect(self.ws_url)
self._listener_task = asyncio.create_task(self._listen())
async def disconnect(self) -> None:
"""Close WebSocket connection."""
if self._listener_task:
self._listener_task.cancel()
try:
await self._listener_task
except asyncio.CancelledError:
pass
self._listener_task = None
if self._ws:
await self._ws.close()
self._ws = None
async def send(self, msg_type: str, **kwargs) -> None:
"""Send a JSON message over WebSocket."""
if not self._ws:
raise ConnectionError("Not connected")
msg = {"type": msg_type, **kwargs}
logger.debug(f"TX: {msg}")
await self._ws.send(json.dumps(msg))
async def _listen(self) -> None:
"""Background task: read messages from WebSocket and post to app."""
try:
async for raw in self._ws:
try:
data = json.loads(raw)
logger.debug(f"RX: {data.get('type', '?')}")
if self._app:
self._app.post_server_message(data)
except json.JSONDecodeError:
logger.warning(f"Non-JSON message: {raw[:100]}")
except websockets.ConnectionClosed as e:
logger.info(f"WebSocket closed: {e}")
if self._app:
self._app.post_server_message({"type": "connection_closed", "reason": str(e)})
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(f"WebSocket listener error: {e}")
if self._app:
self._app.post_server_message({"type": "connection_error", "reason": str(e)})

View File

@ -0,0 +1,41 @@
"""User configuration for the TUI client.
Config file: ~/.config/golf-tui.conf
Example contents:
server = golfcards.club
tls = true
"""
from __future__ import annotations
import os
from pathlib import Path
CONFIG_PATH = Path(os.environ.get("GOLF_TUI_CONFIG", "~/.config/golf-tui.conf")).expanduser()
DEFAULTS = {
"server": "golfcards.club",
"tls": "true",
}
def load_config() -> dict[str, str]:
"""Load config from file, falling back to defaults."""
cfg = dict(DEFAULTS)
if CONFIG_PATH.exists():
for line in CONFIG_PATH.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, value = line.partition("=")
cfg[key.strip().lower()] = value.strip()
return cfg
def save_config(cfg: dict[str, str]) -> None:
"""Write config to file."""
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
lines = [f"{k} = {v}" for k, v in sorted(cfg.items())]
CONFIG_PATH.write_text("\n".join(lines) + "\n")

View File

@ -0,0 +1,133 @@
"""Data models for the TUI client."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class CardData:
"""A single card as received from the server."""
suit: Optional[str] = None # "hearts", "diamonds", "clubs", "spades"
rank: Optional[str] = None # "A", "2".."10", "J", "Q", "K", "★"
face_up: bool = False
deck_id: Optional[int] = None
@classmethod
def from_dict(cls, d: dict) -> CardData:
return cls(
suit=d.get("suit"),
rank=d.get("rank"),
face_up=d.get("face_up", False),
deck_id=d.get("deck_id"),
)
@property
def display_suit(self) -> str:
"""Unicode suit symbol."""
return {
"hearts": "\u2665",
"diamonds": "\u2666",
"clubs": "\u2663",
"spades": "\u2660",
}.get(self.suit or "", "")
@property
def display_rank(self) -> str:
if self.rank == "10":
return "10"
return self.rank or ""
@property
def is_red(self) -> bool:
return self.suit in ("hearts", "diamonds")
@property
def is_joker(self) -> bool:
return self.rank == "\u2605"
@dataclass
class PlayerData:
"""A player as received in game state."""
id: str = ""
name: str = ""
cards: list[CardData] = field(default_factory=list)
score: Optional[int] = None
total_score: int = 0
rounds_won: int = 0
all_face_up: bool = False
@classmethod
def from_dict(cls, d: dict) -> PlayerData:
return cls(
id=d.get("id", ""),
name=d.get("name", ""),
cards=[CardData.from_dict(c) for c in d.get("cards", [])],
score=d.get("score"),
total_score=d.get("total_score", 0),
rounds_won=d.get("rounds_won", 0),
all_face_up=d.get("all_face_up", False),
)
@dataclass
class GameState:
"""Full game state from the server."""
phase: str = "waiting"
players: list[PlayerData] = field(default_factory=list)
current_player_id: Optional[str] = None
dealer_id: Optional[str] = None
discard_top: Optional[CardData] = None
deck_remaining: int = 0
current_round: int = 1
total_rounds: int = 1
has_drawn_card: bool = False
drawn_card: Optional[CardData] = None
drawn_player_id: Optional[str] = None
can_discard: bool = True
waiting_for_initial_flip: bool = False
initial_flips: int = 2
flip_on_discard: bool = False
flip_mode: str = "never"
flip_is_optional: bool = False
flip_as_action: bool = False
knock_early: bool = False
finisher_id: Optional[str] = None
card_values: dict = field(default_factory=dict)
active_rules: list = field(default_factory=list)
deck_colors: list[str] = field(default_factory=lambda: ["red", "blue", "gold"])
@classmethod
def from_dict(cls, d: dict) -> GameState:
discard = d.get("discard_top")
drawn = d.get("drawn_card")
return cls(
phase=d.get("phase", "waiting"),
players=[PlayerData.from_dict(p) for p in d.get("players", [])],
current_player_id=d.get("current_player_id"),
dealer_id=d.get("dealer_id"),
discard_top=CardData.from_dict(discard) if discard else None,
deck_remaining=d.get("deck_remaining", 0),
current_round=d.get("current_round", 1),
total_rounds=d.get("total_rounds", 1),
has_drawn_card=d.get("has_drawn_card", False),
drawn_card=CardData.from_dict(drawn) if drawn else None,
drawn_player_id=d.get("drawn_player_id"),
can_discard=d.get("can_discard", True),
waiting_for_initial_flip=d.get("waiting_for_initial_flip", False),
initial_flips=d.get("initial_flips", 2),
flip_on_discard=d.get("flip_on_discard", False),
flip_mode=d.get("flip_mode", "never"),
flip_is_optional=d.get("flip_is_optional", False),
flip_as_action=d.get("flip_as_action", False),
knock_early=d.get("knock_early", False),
finisher_id=d.get("finisher_id"),
card_values=d.get("card_values", {}),
active_rules=d.get("active_rules", []),
deck_colors=d.get("deck_colors", ["red", "blue", "gold"]),
)

View File

@ -0,0 +1 @@
"""Screen modules for the TUI client."""

View File

@ -0,0 +1,77 @@
"""Connection screen: server URL + optional login form."""
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Container, Horizontal
from textual.screen import Screen
from textual.widgets import Button, Input, Static
class ConnectScreen(Screen):
"""Initial screen for connecting to the server."""
def compose(self) -> ComposeResult:
with Container(id="connect-container"):
yield Static("GolfCards.club", id="connect-title")
yield Static("Login (optional - leave blank to play as guest)")
yield Input(placeholder="Username", id="input-username")
yield Input(placeholder="Password", password=True, id="input-password")
with Horizontal(id="connect-buttons"):
yield Button("Connect as Guest", id="btn-guest", variant="default")
yield Button("Login & Connect", id="btn-login", variant="primary")
yield Static("", id="connect-status")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-guest":
self._connect(login=False)
elif event.button.id == "btn-login":
self._connect(login=True)
def on_input_submitted(self, event: Input.Submitted) -> None:
# Enter key in password field triggers login
if event.input.id == "input-password":
self._connect(login=True)
def _connect(self, login: bool) -> None:
self._set_status("Connecting...")
self._disable_buttons()
self.run_worker(self._do_connect(login), exclusive=True)
async def _do_connect(self, login: bool) -> None:
client = self.app.client
try:
if login:
username = self.query_one("#input-username", Input).value.strip()
password = self.query_one("#input-password", Input).value
if not username or not password:
self._set_status("Username and password required")
self._enable_buttons()
return
self._set_status("Logging in...")
await client.login(username, password)
self._set_status(f"Logged in as {client.username}")
self._set_status("Connecting to WebSocket...")
await client.connect()
self._set_status("Connected!")
# Move to lobby
from tui_client.screens.lobby import LobbyScreen
self.app.push_screen(LobbyScreen())
except Exception as e:
self._set_status(f"Error: {e}")
self._enable_buttons()
def _set_status(self, text: str) -> None:
self.query_one("#connect-status", Static).update(text)
def _disable_buttons(self) -> None:
for btn in self.query("Button"):
btn.disabled = True
def _enable_buttons(self) -> None:
for btn in self.query("Button"):
btn.disabled = False

View File

@ -0,0 +1,522 @@
"""Main game board screen with keyboard actions and message dispatch."""
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Container, Horizontal
from textual.events import Resize
from textual.screen import Screen
from textual.widgets import Static
from tui_client.models import GameState, PlayerData
from tui_client.widgets.hand import HandWidget
from tui_client.widgets.play_area import PlayAreaWidget
from tui_client.widgets.scoreboard import ScoreboardScreen
from tui_client.widgets.status_bar import StatusBarWidget
class GameScreen(Screen):
"""Main game board with card display and keyboard controls."""
BINDINGS = [
("d", "draw_deck", "Draw from deck"),
("s", "pick_discard", "Pick from discard"),
("1", "select_1", "Position 1"),
("2", "select_2", "Position 2"),
("3", "select_3", "Position 3"),
("4", "select_4", "Position 4"),
("5", "select_5", "Position 5"),
("6", "select_6", "Position 6"),
("x", "discard_held", "Discard held card"),
("c", "cancel_draw", "Cancel draw"),
("f", "flip_mode", "Flip card"),
("p", "skip_flip", "Skip flip"),
("k", "knock_early", "Knock early"),
("n", "next_round", "Next round"),
]
def __init__(self, initial_state: dict, is_host: bool = False):
super().__init__()
self._state = GameState.from_dict(initial_state)
self._is_host = is_host
self._player_id: str = ""
self._awaiting_flip = False
self._awaiting_initial_flip = False
self._initial_flip_positions: list[int] = []
self._can_flip_optional = False
self._term_width: int = 80
self._term_height: int = 24
def compose(self) -> ComposeResult:
yield StatusBarWidget(id="status-bar")
with Container(id="game-content"):
yield Static("", id="opponents-area")
with Horizontal(id="play-area-row"):
yield PlayAreaWidget(id="play-area")
yield Static("", id="local-hand-label")
yield HandWidget(id="local-hand")
yield Static("", id="action-bar")
def on_mount(self) -> None:
self._player_id = self.app.player_id or ""
self._term_width = self.app.size.width
self._term_height = self.app.size.height
self._full_refresh()
def on_resize(self, event: Resize) -> None:
self._term_width = event.size.width
self._term_height = event.size.height
self._full_refresh()
def on_server_message(self, event) -> None:
"""Dispatch server messages to handlers."""
handler = getattr(self, f"_handle_{event.msg_type}", None)
if handler:
handler(event.data)
# ------------------------------------------------------------------
# Server message handlers
# ------------------------------------------------------------------
def _handle_game_state(self, data: dict) -> None:
state_data = data.get("game_state", data)
self._state = GameState.from_dict(state_data)
self._full_refresh()
def _handle_your_turn(self, data: dict) -> None:
self._awaiting_flip = False
self._refresh_action_bar()
def _handle_card_drawn(self, data: dict) -> None:
from tui_client.models import CardData
card = CardData.from_dict(data.get("card", {}))
source = data.get("source", "deck")
rank = card.display_rank
suit = card.display_suit
if source == "discard":
self._set_action(
f"Holding {rank}{suit} — Choose spot \[1] thru \[6] or \[c]ancel"
)
self._set_keymap("[1-6] Swap [C] Cancel")
else:
self._set_action(
f"Holding {rank}{suit} — Choose spot \[1] thru \[6] or \[x] to discard"
)
self._set_keymap("[1-6] Swap [X] Discard")
def _handle_can_flip(self, data: dict) -> None:
self._awaiting_flip = True
optional = data.get("optional", False)
self._can_flip_optional = optional
if optional:
self._set_action("Keyboard: Flip a card \[1] thru \[6] or \[p] to skip")
self._set_keymap("[1-6] Flip card [P] Skip")
else:
self._set_action("Keyboard: Flip a face-down card \[1] thru \[6]")
self._set_keymap("[1-6] Flip card")
def _handle_round_over(self, data: dict) -> None:
scores = data.get("scores", [])
round_num = data.get("round", 1)
total_rounds = data.get("total_rounds", 1)
self.app.push_screen(
ScoreboardScreen(
scores=scores,
title=f"Round {round_num} Complete",
is_game_over=False,
is_host=self._is_host,
round_num=round_num,
total_rounds=total_rounds,
),
callback=self._on_scoreboard_dismiss,
)
def _handle_game_over(self, data: dict) -> None:
scores = data.get("final_scores", [])
self.app.push_screen(
ScoreboardScreen(
scores=scores,
title="Game Over!",
is_game_over=True,
is_host=self._is_host,
),
callback=self._on_scoreboard_dismiss,
)
def _handle_round_started(self, data: dict) -> None:
state_data = data.get("game_state", data)
self._state = GameState.from_dict(state_data)
self._awaiting_flip = False
self._awaiting_initial_flip = False
self._initial_flip_positions = []
self._full_refresh()
def _handle_game_ended(self, data: dict) -> None:
reason = data.get("reason", "Game ended")
self._set_action(f"{reason}. Press Escape to return to lobby.")
def _handle_error(self, data: dict) -> None:
msg = data.get("message", "Unknown error")
self._set_action(f"[red]Error: {msg}[/red]")
def _handle_connection_closed(self, data: dict) -> None:
self._set_action("[red]Connection lost.[/red]")
def _on_scoreboard_dismiss(self, result: str | None) -> None:
if result == "next_round":
self.run_worker(self._send("next_round"))
elif result == "lobby":
self.run_worker(self._send("leave_game"))
self.app.pop_screen()
# ------------------------------------------------------------------
# Click handlers (from widget messages)
# ------------------------------------------------------------------
def on_hand_widget_card_clicked(self, event: HandWidget.CardClicked) -> None:
"""Handle click on a card in the local hand."""
self._select_position(event.position)
def on_play_area_widget_deck_clicked(self, event: PlayAreaWidget.DeckClicked) -> None:
"""Handle click on the deck."""
self.action_draw_deck()
def on_play_area_widget_discard_clicked(self, event: PlayAreaWidget.DiscardClicked) -> None:
"""Handle click on the discard pile."""
self.action_pick_discard()
# ------------------------------------------------------------------
# Keyboard actions
# ------------------------------------------------------------------
def action_draw_deck(self) -> None:
if not self._is_my_turn() or self._state.has_drawn_card:
return
self.run_worker(self._send("draw", source="deck"))
def action_pick_discard(self) -> None:
if not self._is_my_turn() or self._state.has_drawn_card:
return
if not self._state.discard_top:
return
self.run_worker(self._send("draw", source="discard"))
def action_select_1(self) -> None:
self._select_position(0)
def action_select_2(self) -> None:
self._select_position(1)
def action_select_3(self) -> None:
self._select_position(2)
def action_select_4(self) -> None:
self._select_position(3)
def action_select_5(self) -> None:
self._select_position(4)
def action_select_6(self) -> None:
self._select_position(5)
def _select_position(self, pos: int) -> None:
# Initial flip phase
if self._state.waiting_for_initial_flip:
self._handle_initial_flip_select(pos)
return
# Flip after discard
if self._awaiting_flip:
self._do_flip(pos)
return
# Swap with held card
if self._state.has_drawn_card and self._is_my_turn():
self.run_worker(self._send("swap", position=pos))
return
def _handle_initial_flip_select(self, pos: int) -> None:
if pos in self._initial_flip_positions:
return # already selected
# Reject already face-up cards
me = self._get_local_player()
if me and pos < len(me.cards) and me.cards[pos].face_up:
return
self._initial_flip_positions.append(pos)
# Immediately show the card as face-up locally for visual feedback
if me and pos < len(me.cards):
me.cards[pos].face_up = True
hand = self.query_one("#local-hand", HandWidget)
hand.update_player(
me,
deck_colors=self._state.deck_colors,
is_current_turn=(me.id == self._state.current_player_id),
is_knocker=(me.id == self._state.finisher_id and self._state.phase == "final_turn"),
is_dealer=(me.id == self._state.dealer_id),
)
needed = self._state.initial_flips
selected = len(self._initial_flip_positions)
if selected >= needed:
self.run_worker(
self._send("flip_initial", positions=self._initial_flip_positions)
)
self._awaiting_initial_flip = False
self._initial_flip_positions = []
else:
self._set_action(
f"Keyboard: Choose {needed - selected} more card(s) to flip ({selected}/{needed})"
)
def _do_flip(self, pos: int) -> None:
me = self._get_local_player()
if me and pos < len(me.cards) and me.cards[pos].face_up:
self._set_action("That card is already face-up! Pick a face-down card.")
return
self.run_worker(self._send("flip_card", position=pos))
self._awaiting_flip = False
def action_discard_held(self) -> None:
if not self._is_my_turn() or not self._state.has_drawn_card:
return
if not self._state.can_discard:
self._set_action("Can't discard a card drawn from discard. Swap or cancel.")
return
self.run_worker(self._send("discard"))
def action_cancel_draw(self) -> None:
if not self._is_my_turn() or not self._state.has_drawn_card:
return
self.run_worker(self._send("cancel_draw"))
def action_flip_mode(self) -> None:
if self._state.flip_as_action and self._is_my_turn() and not self._state.has_drawn_card:
self._awaiting_flip = True
self._set_action("Flip mode: select a face-down card [1-6]")
def action_skip_flip(self) -> None:
if self._awaiting_flip and self._can_flip_optional:
self.run_worker(self._send("skip_flip"))
self._awaiting_flip = False
def action_knock_early(self) -> None:
if not self._is_my_turn() or self._state.has_drawn_card:
return
if not self._state.knock_early:
return
self.run_worker(self._send("knock_early"))
def action_next_round(self) -> None:
if self._is_host and self._state.phase == "round_over":
self.run_worker(self._send("next_round"))
def action_leave_game(self) -> None:
self.run_worker(self._send("leave_game"))
self.app.pop_screen()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _is_my_turn(self) -> bool:
return self._state.current_player_id == self._player_id
def _get_local_player(self) -> PlayerData | None:
for p in self._state.players:
if p.id == self._player_id:
return p
return None
async def _send(self, msg_type: str, **kwargs) -> None:
try:
await self.app.client.send(msg_type, **kwargs)
except Exception as e:
self._set_action(f"[red]Send error: {e}[/red]")
def _set_action(self, text: str) -> None:
try:
self.query_one("#action-bar", Static).update(text)
except Exception:
pass
# ------------------------------------------------------------------
# Rendering
# ------------------------------------------------------------------
def _full_refresh(self) -> None:
"""Refresh all widgets from current game state."""
state = self._state
# Status bar
status = self.query_one("#status-bar", StatusBarWidget)
status.update_state(state, self._player_id)
# Play area
play_area = self.query_one("#play-area", PlayAreaWidget)
play_area.update_state(state, local_player_id=self._player_id)
# Local player hand (in bordered box with turn/knocker indicators)
me = self._get_local_player()
if me:
self.query_one("#local-hand-label", Static).update("")
hand = self.query_one("#local-hand", HandWidget)
hand._is_local = True
hand.update_player(
me,
deck_colors=state.deck_colors,
is_current_turn=(me.id == state.current_player_id),
is_knocker=(me.id == state.finisher_id and state.phase == "final_turn"),
is_dealer=(me.id == state.dealer_id),
)
else:
self.query_one("#local-hand-label", Static).update("")
# Opponents - bordered boxes in a single Static
opponents = [p for p in state.players if p.id != self._player_id]
self._render_opponents(opponents)
# Action bar
self._refresh_action_bar()
def _render_opponents(self, opponents: list[PlayerData]) -> None:
"""Render all opponent hands as bordered boxes into the opponents area.
Adapts layout based on terminal width:
- Narrow (<80): stack opponents vertically
- Medium (80-119): 2-3 side-by-side with moderate spacing
- Wide (120+): all side-by-side with generous spacing
"""
if not opponents:
self.query_one("#opponents-area", Static).update("")
return
from tui_client.widgets.hand import _check_column_match, _render_card_lines
from tui_client.widgets.player_box import _visible_len, render_player_box
state = self._state
deck_colors = state.deck_colors
width = self._term_width
# Build each opponent's boxed display
opp_blocks: list[list[str]] = []
for opp in opponents:
cards = opp.cards
matched = _check_column_match(cards)
card_lines = _render_card_lines(
cards, deck_colors=deck_colors, matched=matched,
)
box = render_player_box(
opp.name,
score=opp.score,
total_score=opp.total_score,
content_lines=card_lines,
is_current_turn=(opp.id == state.current_player_id),
is_knocker=(opp.id == state.finisher_id and state.phase == "final_turn"),
is_dealer=(opp.id == state.dealer_id),
all_face_up=opp.all_face_up,
)
opp_blocks.append(box)
# Determine how many opponents fit per row
# Each box is ~21-24 chars wide
opp_width = 22
if width < 80:
per_row = 1
gap = " "
elif width < 120:
gap = " "
per_row = max(1, (width - 4) // (opp_width + len(gap)))
else:
gap = " "
per_row = max(1, (min(width, 120) - 4) // (opp_width + len(gap)))
# Render in rows of per_row opponents
all_row_lines: list[str] = []
for chunk_start in range(0, len(opp_blocks), per_row):
chunk = opp_blocks[chunk_start : chunk_start + per_row]
if len(chunk) == 1:
all_row_lines.extend(chunk[0])
else:
max_height = max(len(b) for b in chunk)
# Pad shorter blocks with spaces matching each block's visible width
for b in chunk:
if b:
pad_width = _visible_len(b[0])
else:
pad_width = 0
while len(b) < max_height:
b.append(" " * pad_width)
for row_idx in range(max_height):
parts = [b[row_idx] for b in chunk]
all_row_lines.append(gap.join(parts))
if chunk_start + per_row < len(opp_blocks):
all_row_lines.append("")
self.query_one("#opponents-area", Static).update("\n".join(all_row_lines))
def _refresh_action_bar(self) -> None:
"""Update action bar and keymap based on current game state."""
state = self._state
if state.phase in ("round_over", "game_over"):
self._set_action("Keyboard: \[n]ext round")
self._set_keymap("[N] Next round")
return
if state.waiting_for_initial_flip:
needed = state.initial_flips
selected = len(self._initial_flip_positions)
self._set_action(
f"Keyboard: Choose {needed} cards \[1] thru \[6] to flip ({selected}/{needed})"
)
self._set_keymap("[1-6] Select card")
return
if not self._is_my_turn():
if state.current_player_id:
for p in state.players:
if p.id == state.current_player_id:
self._set_action(f"Waiting for {p.name}...")
self._set_keymap("Waiting...")
return
self._set_action("Waiting...")
self._set_keymap("Waiting...")
return
if state.has_drawn_card:
keys = ["[1-6] Swap"]
if state.can_discard:
self._set_action("Keyboard: Choose spot \[1] thru \[6] or \[x] to discard")
keys.append("[X] Discard")
else:
self._set_action("Keyboard: Choose spot \[1] thru \[6] or \[c]ancel")
keys.append("[C] Cancel")
self._set_keymap(" ".join(keys))
return
parts = ["Choose \[d]eck or di\[s]card pile"]
keys = ["[D] Draw", "[S] Pick discard"]
if state.flip_as_action:
parts.append("\[f]lip a card")
keys.append("[F] Flip")
if state.knock_early:
parts.append("\[k]nock early")
keys.append("[K] Knock")
self._set_action("Keyboard: " + " or ".join(parts))
self._set_keymap(" ".join(keys))
def _set_keymap(self, text: str) -> None:
try:
self.app.set_keymap(text)
except Exception:
pass

View File

@ -0,0 +1,445 @@
"""Lobby screen: create/join room, add CPUs, configure, start game."""
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.screen import Screen
from textual.widgets import (
Button,
Collapsible,
Input,
Label,
OptionList,
Select,
Static,
Switch,
)
from textual.widgets.option_list import Option
DECK_PRESETS = {
"classic": ["red", "blue", "gold"],
"ninja": ["green", "purple", "orange"],
"ocean": ["blue", "teal", "cyan"],
"forest": ["green", "gold", "brown"],
"sunset": ["orange", "red", "purple"],
"berry": ["purple", "pink", "red"],
"neon": ["pink", "cyan", "green"],
"royal": ["purple", "gold", "red"],
"earth": ["brown", "green", "gold"],
"all-red": ["red", "red", "red"],
"all-blue": ["blue", "blue", "blue"],
"all-green": ["green", "green", "green"],
}
class LobbyScreen(Screen):
"""Room creation, joining, and pre-game configuration."""
BINDINGS = [
("plus_sign", "add_cpu", "Add CPU"),
("equals_sign", "add_cpu", "Add CPU"),
("hyphen_minus", "remove_cpu", "Remove CPU"),
("enter", "start_or_create", "Start/Create"),
]
def __init__(self):
super().__init__()
self._room_code: str | None = None
self._player_id: str | None = None
self._is_host: bool = False
self._players: list[dict] = []
self._in_room: bool = False
def compose(self) -> ComposeResult:
with Container(id="lobby-container"):
yield Static("[bold]GolfCards.club[/bold]", id="lobby-title")
yield Static("", id="room-info")
# Pre-room: join/create
with Vertical(id="pre-room"):
yield Input(placeholder="Room code (leave blank to create new)", id="input-room-code")
with Horizontal(id="pre-room-buttons"):
yield Button("Create Room", id="btn-create", variant="primary")
yield Button("Join Room", id="btn-join", variant="default")
# In-room: player list + controls + settings
with Vertical(id="in-room"):
yield Static("[bold]Players[/bold]", id="player-list-label")
yield Static("", id="player-list")
# CPU controls: compact [+] [-]
with Horizontal(id="cpu-controls"):
yield Label("CPU:", id="cpu-label")
yield Button("+", id="btn-cpu-add", variant="default")
yield Button("", id="btn-cpu-remove", variant="warning")
yield Button("?", id="btn-cpu-random", variant="default")
# CPU profile picker (hidden by default)
yield OptionList(id="cpu-profile-list")
# Host settings (collapsible sections)
with Vertical(id="host-settings"):
with Collapsible(title="Game Settings", collapsed=True, id="coll-game"):
with Horizontal(classes="setting-row"):
yield Label("Rounds")
yield Select(
[(str(v), v) for v in (1, 3, 9, 18)],
value=9,
id="sel-rounds",
allow_blank=False,
)
with Horizontal(classes="setting-row"):
yield Label("Decks")
yield Select(
[(str(v), v) for v in (1, 2, 3)],
value=1,
id="sel-decks",
allow_blank=False,
)
with Horizontal(classes="setting-row"):
yield Label("Initial Flips")
yield Select(
[(str(v), v) for v in (0, 1, 2)],
value=2,
id="sel-initial-flips",
allow_blank=False,
)
with Horizontal(classes="setting-row"):
yield Label("Flip Mode")
yield Select(
[("Never", "never"), ("Always", "always"), ("Endgame", "endgame")],
value="never",
id="sel-flip-mode",
allow_blank=False,
)
with Collapsible(title="House Rules", collapsed=True, id="coll-rules"):
# Joker variant
with Horizontal(classes="setting-row"):
yield Label("Jokers")
yield Select(
[
("None", "none"),
("Standard (2)", "standard"),
("Lucky Swing (5)", "lucky_swing"),
("Eagle Eye (+2/4)", "eagle_eye"),
],
value="none",
id="sel-jokers",
allow_blank=False,
)
# Scoring rules
yield Static("[bold]Scoring[/bold]", classes="rules-header")
with Horizontal(classes="rule-row"):
yield Label("Super Kings (K = 2)")
yield Switch(id="sw-super_kings")
with Horizontal(classes="rule-row"):
yield Label("Ten Penny (10 = 1)")
yield Switch(id="sw-ten_penny")
with Horizontal(classes="rule-row"):
yield Label("One-Eyed Jacks (J♥/J♠ = 0)")
yield Switch(id="sw-one_eyed_jacks")
with Horizontal(classes="rule-row"):
yield Label("Negative Pairs Keep Value")
yield Switch(id="sw-negative_pairs_keep_value")
with Horizontal(classes="rule-row"):
yield Label("Four of a Kind (20)")
yield Switch(id="sw-four_of_a_kind")
# Knock & Endgame
yield Static("[bold]Knock & Endgame[/bold]", classes="rules-header")
with Horizontal(classes="rule-row"):
yield Label("Knock Penalty (+10)")
yield Switch(id="sw-knock_penalty")
with Horizontal(classes="rule-row"):
yield Label("Knock Bonus (5)")
yield Switch(id="sw-knock_bonus")
with Horizontal(classes="rule-row"):
yield Label("Knock Early")
yield Switch(id="sw-knock_early")
with Horizontal(classes="rule-row"):
yield Label("Flip as Action")
yield Switch(id="sw-flip_as_action")
# Bonuses & Penalties
yield Static("[bold]Bonuses & Penalties[/bold]", classes="rules-header")
with Horizontal(classes="rule-row"):
yield Label("Underdog Bonus (3)")
yield Switch(id="sw-underdog_bonus")
with Horizontal(classes="rule-row"):
yield Label("Tied Shame (+5)")
yield Switch(id="sw-tied_shame")
with Horizontal(classes="rule-row"):
yield Label("Blackjack (21→0)")
yield Switch(id="sw-blackjack")
with Horizontal(classes="rule-row"):
yield Label("Wolfpack")
yield Switch(id="sw-wolfpack")
with Collapsible(title="Deck Style", collapsed=True, id="coll-deck"):
with Horizontal(classes="setting-row"):
yield Select(
[(name.replace("-", " ").title(), name) for name in DECK_PRESETS],
value="classic",
id="sel-deck-style",
allow_blank=False,
)
yield Static(
self._render_deck_preview("classic"),
id="deck-preview",
)
yield Button("Start Game", id="btn-start", variant="success")
yield Static("", id="lobby-status")
def on_mount(self) -> None:
self._update_visibility()
self._update_keymap()
def _update_visibility(self) -> None:
try:
self.query_one("#pre-room").display = not self._in_room
self.query_one("#in-room").display = self._in_room
# Host-only controls
self.query_one("#cpu-controls").display = self._in_room and self._is_host
self.query_one("#host-settings").display = self._in_room and self._is_host
self.query_one("#btn-start").display = self._in_room and self._is_host
except Exception:
pass
def _update_keymap(self) -> None:
try:
if self._in_room and self._is_host:
self.app.set_keymap("[+] Add CPU [] Remove CPU [Enter] Start Game [Esc Esc] Quit")
elif self._in_room:
self.app.set_keymap("Waiting for host to start... [Esc Esc] Quit")
else:
self.app.set_keymap("[Tab] Navigate [Enter] Create/Join [Esc Esc] Quit")
except Exception:
pass
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-create":
self._create_room()
elif event.button.id == "btn-join":
self._join_room()
elif event.button.id == "btn-cpu-add":
self._show_cpu_picker()
elif event.button.id == "btn-cpu-remove":
self._remove_cpu()
elif event.button.id == "btn-cpu-random":
self._add_random_cpu()
elif event.button.id == "btn-start":
self._start_game()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "input-room-code":
code = event.value.strip()
if code:
self._join_room()
else:
self._create_room()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
if event.option_list.id == "cpu-profile-list":
profile_name = str(event.option.id) if event.option.id else ""
self.run_worker(self._send("add_cpu", profile_name=profile_name))
event.option_list.display = False
def on_select_changed(self, event: Select.Changed) -> None:
if event.select.id == "sel-deck-style" and event.value is not None:
try:
preview = self.query_one("#deck-preview", Static)
preview.update(self._render_deck_preview(str(event.value)))
except Exception:
pass
def action_add_cpu(self) -> None:
if self._in_room and self._is_host:
self._show_cpu_picker()
def action_remove_cpu(self) -> None:
if self._in_room and self._is_host:
self._remove_cpu()
def action_start_or_create(self) -> None:
if self._in_room and self._is_host:
self._start_game()
elif not self._in_room:
code = self.query_one("#input-room-code", Input).value.strip()
if code:
self._join_room()
else:
self._create_room()
def _create_room(self) -> None:
player_name = self.app.client.username or "Player"
self.run_worker(self._send("create_room", player_name=player_name))
def _join_room(self) -> None:
code = self.query_one("#input-room-code", Input).value.strip().upper()
if not code:
self._set_status("Enter a room code to join")
return
player_name = self.app.client.username or "Player"
self.run_worker(self._send("join_room", room_code=code, player_name=player_name))
@staticmethod
def _render_deck_preview(preset_name: str) -> str:
"""Render mini card-back swatches for a deck color preset."""
from tui_client.widgets.card import BACK_COLORS
colors = DECK_PRESETS.get(preset_name, ["red", "blue", "gold"])
# Show unique colors only (e.g. all-red shows one wider swatch)
seen: list[str] = []
for c in colors:
if c not in seen:
seen.append(c)
parts: list[str] = []
for color_name in seen:
hex_color = BACK_COLORS.get(color_name, BACK_COLORS["red"])
parts.append(f"[{hex_color}]░░░[/]")
return " ".join(parts)
def _add_random_cpu(self) -> None:
"""Add a random CPU (server picks the profile)."""
self.run_worker(self._send("add_cpu"))
def _show_cpu_picker(self) -> None:
"""Request CPU profiles from server and show picker."""
self.run_worker(self._send("get_cpu_profiles"))
def _handle_cpu_profiles(self, data: dict) -> None:
"""Populate and show the CPU profile option list."""
profiles = data.get("profiles", [])
option_list = self.query_one("#cpu-profile-list", OptionList)
option_list.clear_options()
for p in profiles:
name = p.get("name", "?")
style = p.get("style", "")
option_list.add_option(Option(f"{name}{style}", id=name))
option_list.display = True
option_list.focus()
def _remove_cpu(self) -> None:
self.run_worker(self._send("remove_cpu"))
def _collect_settings(self) -> dict:
"""Read all Select/Switch values and return kwargs for start_game."""
settings: dict = {}
try:
settings["rounds"] = self.query_one("#sel-rounds", Select).value
settings["decks"] = self.query_one("#sel-decks", Select).value
settings["initial_flips"] = self.query_one("#sel-initial-flips", Select).value
settings["flip_mode"] = self.query_one("#sel-flip-mode", Select).value
except Exception:
settings.setdefault("rounds", 9)
settings.setdefault("decks", 1)
settings.setdefault("initial_flips", 2)
settings.setdefault("flip_mode", "never")
# Joker variant → booleans
try:
joker_mode = self.query_one("#sel-jokers", Select).value
except Exception:
joker_mode = "none"
settings["use_jokers"] = joker_mode != "none"
settings["lucky_swing"] = joker_mode == "lucky_swing"
settings["eagle_eye"] = joker_mode == "eagle_eye"
# Boolean house rules from switches
rule_ids = [
"super_kings", "ten_penny", "one_eyed_jacks",
"negative_pairs_keep_value", "four_of_a_kind",
"knock_penalty", "knock_bonus", "knock_early", "flip_as_action",
"underdog_bonus", "tied_shame", "blackjack", "wolfpack",
]
for rule_id in rule_ids:
try:
settings[rule_id] = self.query_one(f"#sw-{rule_id}", Switch).value
except Exception:
settings[rule_id] = False
# Deck colors from preset
try:
preset = self.query_one("#sel-deck-style", Select).value
settings["deck_colors"] = DECK_PRESETS.get(preset, ["red", "blue", "gold"])
except Exception:
settings["deck_colors"] = ["red", "blue", "gold"]
return settings
def _start_game(self) -> None:
self._set_status("Starting game...")
settings = self._collect_settings()
self.run_worker(self._send("start_game", **settings))
async def _send(self, msg_type: str, **kwargs) -> None:
try:
await self.app.client.send(msg_type, **kwargs)
except Exception as e:
self._set_status(f"Error: {e}")
def on_server_message(self, event) -> None:
handler = getattr(self, f"_handle_{event.msg_type}", None)
if handler:
handler(event.data)
def _handle_room_created(self, data: dict) -> None:
self._room_code = data.get("room_code", "")
self._player_id = data.get("player_id", "")
self.app.player_id = self._player_id
self._is_host = True
self._in_room = True
self._set_room_info(f"Room [bold]{self._room_code}[/bold] (You are host)")
self._set_status("Add CPU opponents, then start when ready.")
self._update_visibility()
self._update_keymap()
def _handle_room_joined(self, data: dict) -> None:
self._room_code = data.get("room_code", "")
self._player_id = data.get("player_id", "")
self.app.player_id = self._player_id
self._in_room = True
self._set_room_info(f"Room [bold]{self._room_code}[/bold]")
self._set_status("Waiting for host to start the game.")
self._update_visibility()
self._update_keymap()
def _handle_player_joined(self, data: dict) -> None:
self._players = data.get("players", [])
self._refresh_player_list()
def _handle_game_started(self, data: dict) -> None:
from tui_client.screens.game import GameScreen
game_state = data.get("game_state", {})
self.app.push_screen(GameScreen(game_state, self._is_host))
def _handle_error(self, data: dict) -> None:
self._set_status(f"[red]Error: {data.get('message', 'Unknown error')}[/red]")
def _refresh_player_list(self) -> None:
lines = []
for i, p in enumerate(self._players, 1):
name = p.get("name", "?")
tags = []
if p.get("is_host"):
tags.append("[bold cyan]Host[/bold cyan]")
if p.get("is_cpu"):
tags.append("[yellow]CPU[/yellow]")
suffix = f" {' '.join(tags)}" if tags else ""
lines.append(f" {i}. {name}{suffix}")
self.query_one("#player-list", Static).update("\n".join(lines) if lines else " (empty)")
def _set_room_info(self, text: str) -> None:
self.query_one("#room-info", Static).update(text)
def _set_status(self, text: str) -> None:
self.query_one("#lobby-status", Static).update(text)

View File

@ -0,0 +1,294 @@
/* Base app styles */
Screen {
background: $surface;
}
/* Connect screen */
ConnectScreen {
align: center middle;
}
#connect-container {
width: 80%;
max-width: 64;
min-width: 40;
height: auto;
border: thick $primary;
padding: 1 2;
}
#connect-container Static {
text-align: center;
width: 100%;
}
#connect-title {
text-style: bold;
color: $text;
margin-bottom: 1;
}
#connect-container Input {
margin-bottom: 1;
}
#connect-buttons {
height: 3;
align: center middle;
margin-top: 1;
}
#connect-buttons Button {
margin: 0 1;
}
#connect-status {
text-align: center;
color: $warning;
margin-top: 1;
height: 1;
}
/* Lobby screen */
LobbyScreen {
align: center middle;
}
#lobby-container {
width: 80%;
max-width: 72;
min-width: 40;
height: auto;
border: thick $primary;
padding: 1 2;
}
#lobby-title {
text-style: bold;
text-align: center;
width: 100%;
margin-bottom: 1;
}
#room-info {
text-align: center;
height: auto;
margin-bottom: 1;
}
/* Pre-room: join/create controls */
#pre-room {
height: auto;
}
#input-room-code {
margin-bottom: 1;
}
#pre-room-buttons {
height: 3;
align: center middle;
}
#pre-room-buttons Button {
margin: 0 1;
}
/* In-room: player list + controls */
#in-room {
height: auto;
}
#player-list-label {
margin-bottom: 0;
}
#player-list {
height: auto;
min-height: 3;
max-height: 12;
border: tall $primary;
padding: 0 1;
margin-bottom: 1;
}
/* CPU controls: compact [+] [-] */
#cpu-controls {
height: 3;
align: center middle;
}
#cpu-controls Button {
min-width: 5;
margin: 0 1;
}
#cpu-label {
padding: 1 1 0 0;
}
#cpu-profile-list {
height: auto;
max-height: 12;
border: tall $accent;
margin-bottom: 1;
display: none;
}
/* Host settings */
#host-settings {
height: auto;
margin-top: 1;
}
.setting-row {
height: 3;
align: left middle;
}
.setting-row Label {
width: 1fr;
padding: 1 1 0 0;
}
.setting-row Select {
width: 24;
}
#deck-preview {
width: auto;
padding: 1 1 0 1;
}
.rule-row {
height: 3;
align: left middle;
}
.rule-row Label {
width: 1fr;
padding: 1 1 0 0;
}
.rule-row Switch {
width: auto;
}
.rules-header {
margin-top: 1;
margin-bottom: 0;
}
#btn-start {
width: 100%;
margin-top: 1;
}
#lobby-status {
text-align: center;
color: $warning;
height: auto;
margin-top: 1;
}
/* Game screen */
GameScreen {
align: center top;
layout: vertical;
}
#game-content {
width: 100%;
max-width: 120;
height: 100%;
layout: vertical;
}
#status-bar {
height: 1;
dock: top;
background: $primary;
color: $text;
padding: 0 2;
}
#opponents-area {
height: auto;
max-height: 50%;
padding: 1 2 0 2;
text-align: center;
content-align: center middle;
}
#play-area-row {
height: auto;
align: center middle;
}
#play-area {
height: auto;
width: auto;
padding: 0 2;
border: round $primary-lighten-2;
text-align: center;
content-align: center middle;
}
#action-bar {
height: auto;
min-height: 1;
max-height: 3;
dock: bottom;
background: $surface-darken-1;
padding: 0 2;
text-align: center;
content-align: center middle;
}
/* Local hand label */
#local-hand-label {
text-align: center;
height: 1;
}
/* Local hand widget */
#local-hand {
height: auto;
margin-top: 1;
text-align: center;
content-align: center middle;
}
/* Scoreboard overlay */
#scoreboard-overlay {
align: center middle;
background: $surface 80%;
}
#scoreboard-container {
width: 80%;
max-width: 64;
min-width: 40;
height: auto;
max-height: 80%;
border: thick $primary;
padding: 1 2;
background: $surface;
}
#scoreboard-title {
text-style: bold;
text-align: center;
margin-bottom: 1;
}
#scoreboard-table {
width: 100%;
height: auto;
}
#scoreboard-buttons {
height: 3;
align: center middle;
margin-top: 1;
}

View File

@ -0,0 +1 @@
"""Widget modules for the TUI client."""

View File

@ -0,0 +1,161 @@
"""Single card widget using Unicode box-drawing with Rich color markup."""
from __future__ import annotations
from textual.widgets import Static
from tui_client.models import CardData
# Web UI card back colors mapped to terminal hex equivalents
BACK_COLORS: dict[str, str] = {
"red": "#c41e3a",
"blue": "#2e5cb8",
"green": "#228b22",
"gold": "#daa520",
"purple": "#6a0dad",
"teal": "#008b8b",
"pink": "#db7093",
"slate": "#4a5568",
"orange": "#e67e22",
"cyan": "#00bcd4",
"brown": "#8b4513",
"yellow": "#daa520",
}
# Face-up card text colors (matching web UI)
SUIT_RED = "#c0392b" # hearts, diamonds
SUIT_BLACK = "#e0e0e0" # clubs, spades (light for dark terminal bg)
JOKER_COLOR = "#9b59b6" # purple
BORDER_COLOR = "#888888" # card border
EMPTY_COLOR = "#555555" # empty card slot
POSITION_COLOR = "#f0e68c" # pale yellow — distinct from suits and card backs
def _back_color_for_card(card: CardData, deck_colors: list[str] | None = None) -> str:
"""Get the hex color for a face-down card's back based on deck_id."""
if deck_colors and card.deck_id is not None and card.deck_id < len(deck_colors):
name = deck_colors[card.deck_id]
else:
name = "red"
return BACK_COLORS.get(name, BACK_COLORS["red"])
def _top_border(position: int | None, d: str, color: str) -> str:
"""Top border line, with position number replacing ┌ when present."""
if position is not None:
return f"[{d}{color}]{position}───┐[/{d}{color}]"
return f"[{d}{color}]┌───┐[/{d}{color}]"
def render_card(
card: CardData | None,
selected: bool = False,
position: int | None = None,
deck_colors: list[str] | None = None,
dim: bool = False,
) -> str:
"""Render a card as a 4-line Rich-markup string.
Face-up: Face-down: Empty:
1
A
"""
d = "dim " if dim else ""
bc = BORDER_COLOR
# Empty slot
if card is None:
c = EMPTY_COLOR
return (
f"[{d}{c}]┌───┐[/{d}{c}]\n"
f"[{d}{c}]│ │[/{d}{c}]\n"
f"[{d}{c}]│ │[/{d}{c}]\n"
f"[{d}{c}]└───┘[/{d}{c}]"
)
top = _top_border(position, d, bc)
# Face-down card with colored back
if not card.face_up:
back = _back_color_for_card(card, deck_colors)
return (
f"{top}\n"
f"[{d}{bc}]│[/{d}{bc}][{d}{back}]░░░[/{d}{back}][{d}{bc}]│[/{d}{bc}]\n"
f"[{d}{bc}]│[/{d}{bc}][{d}{back}]░░░[/{d}{back}][{d}{bc}]│[/{d}{bc}]\n"
f"[{d}{bc}]└───┘[/{d}{bc}]"
)
# Joker
if card.is_joker:
jc = JOKER_COLOR
icon = "🐉" if card.suit == "hearts" else "👹"
return (
f"{top}\n"
f"[{d}{bc}]│[/{d}{bc}][{d}{jc}] {icon}[/{d}{jc}][{d}{bc}]│[/{d}{bc}]\n"
f"[{d}{bc}]│[/{d}{bc}][{d}{jc}]JKR[/{d}{jc}][{d}{bc}]│[/{d}{bc}]\n"
f"[{d}{bc}]└───┘[/{d}{bc}]"
)
# Face-up normal card
fc = SUIT_RED if card.is_red else SUIT_BLACK
rank = card.display_rank
suit = card.display_suit
rank_line = f"{rank:^3}"
suit_line = f"{suit:^3}"
return (
f"{top}\n"
f"[{d}{bc}]│[/{d}{bc}][{d}{fc}]{rank_line}[/{d}{fc}][{d}{bc}]│[/{d}{bc}]\n"
f"[{d}{bc}]│[/{d}{bc}][{d}{fc}]{suit_line}[/{d}{fc}][{d}{bc}]│[/{d}{bc}]\n"
f"[{d}{bc}]└───┘[/{d}{bc}]"
)
class CardWidget(Static):
"""A single card display widget."""
def __init__(
self,
card: CardData | None = None,
selected: bool = False,
position: int | None = None,
matched: bool = False,
deck_colors: list[str] | None = None,
**kwargs,
):
super().__init__(**kwargs)
self._card = card
self._selected = selected
self._position = position
self._matched = matched
self._deck_colors = deck_colors
def on_mount(self) -> None:
self._refresh_display()
def update_card(
self,
card: CardData | None,
selected: bool = False,
matched: bool = False,
deck_colors: list[str] | None = None,
) -> None:
self._card = card
self._selected = selected
self._matched = matched
if deck_colors is not None:
self._deck_colors = deck_colors
self._refresh_display()
def _refresh_display(self) -> None:
text = render_card(
self._card,
self._selected,
self._position,
deck_colors=self._deck_colors,
dim=self._matched,
)
self.update(text)

View File

@ -0,0 +1,230 @@
"""2x3 card grid for one player's hand."""
from __future__ import annotations
from textual.events import Click
from textual.message import Message
from textual.widgets import Static
from tui_client.models import CardData, PlayerData
from tui_client.widgets.card import render_card
# Color for the match connector lines (muted green — pairs cancel to 0)
_MATCH_COLOR = "#6a9955"
def _check_column_match(cards: list[CardData]) -> list[bool]:
"""Check which cards are in matched columns (both face-up, same rank).
Cards layout: [0][1][2]
[3][4][5]
Columns: (0,3), (1,4), (2,5)
"""
matched = [False] * 6
if len(cards) < 6:
return matched
for col in range(3):
top = cards[col]
bot = cards[col + 3]
if (
top.face_up
and bot.face_up
and top.rank is not None
and top.rank == bot.rank
):
matched[col] = True
matched[col + 3] = True
return matched
def _build_match_connector(matched: list[bool]) -> str | None:
"""Build a connector line with ║ ║ under each matched column.
Card layout: 5-char card, 1-char gap, repeated.
Positions: 01234 5 6789A B CDEFG
card0 card1 card2
For each matched column, place at offsets 1 and 3 within the card span.
"""
has_any = matched[0] or matched[1] or matched[2]
if not has_any:
return None
# Build a 17-char line (3 cards * 5 + 2 gaps)
chars = list(" " * 17)
for col in range(3):
if matched[col]: # top card matched implies column matched
base = col * 6 # card start position (0, 6, 12)
chars[base + 1] = ""
chars[base + 3] = ""
line = "".join(chars)
return f"[{_MATCH_COLOR}]{line}[/]"
def _render_card_lines(
cards: list[CardData],
*,
is_local: bool = False,
deck_colors: list[str] | None = None,
matched: list[bool] | None = None,
) -> list[str]:
"""Render the 2x3 card grid as a list of text lines (no box).
Inserts a connector line with between rows for matched columns.
"""
if matched is None:
matched = _check_column_match(cards)
lines: list[str] = []
for row_idx, row_start in enumerate((0, 3)):
# Insert connector between row 0 and row 1
if row_idx == 1:
connector = _build_match_connector(matched)
if connector:
lines.append(connector)
row_line_parts: list[list[str]] = []
for i in range(3):
idx = row_start + i
card = cards[idx] if idx < len(cards) else None
pos = idx + 1 if is_local else None
text = render_card(
card,
position=pos,
deck_colors=deck_colors,
dim=matched[idx],
)
card_lines = text.split("\n")
while len(row_line_parts) < len(card_lines):
row_line_parts.append([])
for ln_idx, ln in enumerate(card_lines):
row_line_parts[ln_idx].append(ln)
for parts in row_line_parts:
lines.append(" ".join(parts))
return lines
class HandWidget(Static):
"""Displays a player's 2x3 card grid as rich text, wrapped in a player box."""
class CardClicked(Message):
"""Posted when a card position is clicked in the local hand."""
def __init__(self, position: int) -> None:
super().__init__()
self.position = position
def __init__(
self,
player: PlayerData | None = None,
is_local: bool = False,
deck_colors: list[str] | None = None,
**kwargs,
):
super().__init__(**kwargs)
self._player = player
self._is_local = is_local
self._deck_colors = deck_colors
# State flags for the player box
self._is_current_turn: bool = False
self._is_knocker: bool = False
self._is_dealer: bool = False
self._has_connector: bool = False
def update_player(
self,
player: PlayerData,
deck_colors: list[str] | None = None,
*,
is_current_turn: bool = False,
is_knocker: bool = False,
is_dealer: bool = False,
) -> None:
self._player = player
if deck_colors is not None:
self._deck_colors = deck_colors
self._is_current_turn = is_current_turn
self._is_knocker = is_knocker
self._is_dealer = is_dealer
self._refresh()
def on_mount(self) -> None:
self._refresh()
def on_click(self, event: Click) -> None:
"""Map click coordinates to card position (0-5)."""
if not self._is_local:
return
# Box layout:
# Line 0: top border
# Lines 1-4: row 0 cards (4 lines each)
# Line 5 (optional): match connector
# Lines 5-8 or 6-9: row 1 cards
# Last line: bottom border
#
# Content x: │ <space> then cards at x offsets 2, 8, 14 (each 5 wide, 1 gap)
x, y = event.x, event.y
# Determine column from x (content starts at x=2 inside box)
# Card 0: x 2-6, Card 1: x 8-12, Card 2: x 14-18
col = -1
if 2 <= x <= 6:
col = 0
elif 8 <= x <= 12:
col = 1
elif 14 <= x <= 18:
col = 2
if col < 0:
return
# Determine row from y
# y=0: top border, y=1..4: row 0 cards, then optional connector, then row 1
row = -1
if 1 <= y <= 4:
row = 0
else:
row1_start = 6 if self._has_connector else 5
if row1_start <= y <= row1_start + 3:
row = 1
if row < 0:
return
position = row * 3 + col
self.post_message(self.CardClicked(position))
def _refresh(self) -> None:
if not self._player or not self._player.cards:
self.update("")
return
from tui_client.widgets.player_box import render_player_box
cards = self._player.cards
matched = _check_column_match(cards)
self._has_connector = any(matched[:3])
card_lines = _render_card_lines(
cards,
is_local=self._is_local,
deck_colors=self._deck_colors,
matched=matched,
)
box_lines = render_player_box(
self._player.name,
score=self._player.score,
total_score=self._player.total_score,
content_lines=card_lines,
is_current_turn=self._is_current_turn,
is_knocker=self._is_knocker,
is_dealer=self._is_dealer,
is_local=self._is_local,
all_face_up=self._player.all_face_up,
)
self.update("\n".join(box_lines))

View File

@ -0,0 +1,128 @@
"""Deck + discard + held card area."""
from __future__ import annotations
import re
from dataclasses import replace
from textual.events import Click
from textual.message import Message
from textual.widgets import Static
from tui_client.models import CardData, GameState
from tui_client.widgets.card import render_card
# Fixed column width for each card section (card is 5 wide)
_COL_WIDTH = 12
# Lime green for the held card highlight
_HOLDING_COLOR = "#80ff00"
def _pad_center(text: str, width: int) -> str:
"""Center-pad a plain or Rich-markup string to *width* visible chars."""
visible = re.sub(r"\[.*?\]", "", text)
pad = max(0, width - len(visible))
left = pad // 2
right = pad - left
return " " * left + text + " " * right
class PlayAreaWidget(Static):
"""Displays the deck, discard pile, and held card.
Layout order: DECK [HOLDING] DISCARD
HOLDING only appears when the player has drawn a card.
"""
class DeckClicked(Message):
"""Posted when the deck is clicked."""
class DiscardClicked(Message):
"""Posted when the discard pile is clicked."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._state: GameState | None = None
self._local_player_id: str = ""
self._has_holding: bool = False
def update_state(self, state: GameState, local_player_id: str = "") -> None:
self._state = state
if local_player_id:
self._local_player_id = local_player_id
self._refresh()
def on_mount(self) -> None:
self._refresh()
def on_click(self, event: Click) -> None:
"""Map click to deck or discard column."""
x = event.x
# Layout: DECK (col 0..11) [HOLDING (col 12..23)] DISCARD (last col)
if x < _COL_WIDTH:
self.post_message(self.DeckClicked())
elif self._has_holding and x >= 2 * _COL_WIDTH:
self.post_message(self.DiscardClicked())
elif not self._has_holding and x >= _COL_WIDTH:
self.post_message(self.DiscardClicked())
def _refresh(self) -> None:
if not self._state:
self.update("")
return
state = self._state
# Deck card (face-down)
deck_card = CardData(face_up=False, deck_id=0)
deck_text = render_card(deck_card, deck_colors=state.deck_colors)
deck_lines = deck_text.split("\n")
# Discard card
discard_text = render_card(state.discard_top, deck_colors=state.deck_colors)
discard_lines = discard_text.split("\n")
# Held card — force face_up so render_card shows the face
# Only show when it's the local player holding
held_lines = None
local_holding = (
state.has_drawn_card
and state.drawn_card
and state.drawn_player_id == self._local_player_id
)
if local_holding:
revealed = replace(state.drawn_card, face_up=True)
held_text = render_card(revealed, deck_colors=state.deck_colors)
held_lines = held_text.split("\n")
self._has_holding = held_lines is not None
# Always render 3 columns so the box stays a fixed width
num_card_lines = max(len(deck_lines), len(discard_lines))
lines = []
for i in range(num_card_lines):
d = deck_lines[i] if i < len(deck_lines) else " "
c = discard_lines[i] if i < len(discard_lines) else " "
row = _pad_center(d, _COL_WIDTH)
if held_lines:
h = held_lines[i] if i < len(held_lines) else " "
row += _pad_center(h, _COL_WIDTH)
else:
row += " " * _COL_WIDTH
row += _pad_center(c, _COL_WIDTH)
lines.append(row)
# Labels row — always 3 columns
deck_label = f"DECK:{state.deck_remaining}"
discard_label = "DISCARD"
label = _pad_center(deck_label, _COL_WIDTH)
if held_lines:
holding_label = f"[bold {_HOLDING_COLOR}]HOLDING[/]"
label += _pad_center(holding_label, _COL_WIDTH)
else:
label += " " * _COL_WIDTH
label += _pad_center(discard_label, _COL_WIDTH)
lines.append(label)
self.update("\n".join(lines))

View File

@ -0,0 +1,115 @@
"""Bordered player container with name, score, and state indicators."""
from __future__ import annotations
import re
# Border colors matching web UI palette
_BORDER_NORMAL = "#555555"
_BORDER_TURN_LOCAL = "#9ab973" # green — your turn
_BORDER_TURN_OPPONENT = "#f4a460" # sandy orange — opponent's turn
_BORDER_KNOCKER = "#ff6b35" # red-orange — went out
_NAME_COLOR = "#e0e0e0"
def _visible_len(text: str) -> int:
"""Length of text with Rich markup tags stripped."""
return len(re.sub(r"\[.*?\]", "", text))
def render_player_box(
name: str,
score: int | None,
total_score: int,
content_lines: list[str],
*,
is_current_turn: bool = False,
is_knocker: bool = False,
is_dealer: bool = False,
is_local: bool = False,
all_face_up: bool = False,
) -> list[str]:
"""Render a bordered player container with name/score header.
Every line in the returned list has the same visible width (``box_width``).
Layout::
Name 15
A 7
4 5 Q
"""
# Pick border color based on state
if is_knocker:
bc = _BORDER_KNOCKER
elif is_current_turn and is_local:
bc = _BORDER_TURN_LOCAL
elif is_current_turn:
bc = _BORDER_TURN_OPPONENT
else:
bc = _BORDER_NORMAL
# Build display name
display_name = name
if is_dealer:
display_name = f"{display_name}"
if all_face_up:
display_name += ""
if is_knocker:
display_name += " OUT"
# Score text
score_text = f"{score}" if score is not None else f"{total_score}"
# Compute box width. Every line is exactly box_width visible chars.
# Content row: │ <space> <content> <pad> │ => box_width = vis(content) + 4
max_vis = max((_visible_len(line) for line in content_lines), default=17)
name_part = f" {display_name} "
score_part = f" {score_text} "
# Top row: ╭─ <name_part> <fill> <score_part> ─╮
# = 4 + len(name_part) + fill + len(score_part)
min_top = 4 + len(name_part) + 1 + len(score_part) # fill>=1
box_width = max(max_vis + 4, 21, min_top)
# Possibly truncate name if it still doesn't fit
fill_len = box_width - 4 - len(name_part) - len(score_part)
if fill_len < 1:
max_name = box_width - 4 - len(score_part) - 4
display_name = display_name[: max(3, max_name)] + ""
name_part = f" {display_name} "
fill_len = box_width - 4 - len(name_part) - len(score_part)
fill = "" * max(1, fill_len)
# Top border
top = (
f"[{bc}]╭─[/]"
f"[bold {_NAME_COLOR}]{name_part}[/]"
f"[{bc}]{fill}[/]"
f"[bold]{score_part}[/]"
f"[{bc}]─╮[/]"
)
result = [top]
# Content lines
inner = box_width - 2 # chars between │ and │
for line in content_lines:
vis_len = _visible_len(line)
right_pad = max(0, inner - 1 - vis_len)
result.append(
f"[{bc}]│[/] {line}{' ' * right_pad}[{bc}]│[/]"
)
# Bottom border
result.append(f"[{bc}]╰{'' * inner}╯[/]")
return result

View File

@ -0,0 +1,69 @@
"""Scoreboard overlay for round/game over."""
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Container, Horizontal
from textual.screen import ModalScreen
from textual.widgets import Button, DataTable, Static
class ScoreboardScreen(ModalScreen[str]):
"""Modal overlay showing round or game scores."""
def __init__(
self,
scores: list[dict],
title: str = "Round Over",
is_game_over: bool = False,
is_host: bool = False,
round_num: int = 1,
total_rounds: int = 1,
):
super().__init__()
self._scores = scores
self._title = title
self._is_game_over = is_game_over
self._is_host = is_host
self._round_num = round_num
self._total_rounds = total_rounds
def compose(self) -> ComposeResult:
with Container(id="scoreboard-container"):
yield Static(self._title, id="scoreboard-title")
yield DataTable(id="scoreboard-table")
with Horizontal(id="scoreboard-buttons"):
if self._is_game_over:
yield Button("Back to Lobby", id="btn-lobby", variant="primary")
elif self._is_host:
yield Button("Next Round", id="btn-next-round", variant="primary")
else:
yield Button("Waiting for host...", id="btn-waiting", disabled=True)
def on_mount(self) -> None:
table = self.query_one("#scoreboard-table", DataTable)
if self._is_game_over:
table.add_columns("Rank", "Player", "Total", "Rounds Won")
for i, s in enumerate(self._scores, 1):
table.add_row(
str(i),
s.get("name", "?"),
str(s.get("total", 0)),
str(s.get("rounds_won", 0)),
)
else:
table.add_columns("Player", "Round Score", "Total", "Rounds Won")
for s in self._scores:
table.add_row(
s.get("name", "?"),
str(s.get("score", 0)),
str(s.get("total", 0)),
str(s.get("rounds_won", 0)),
)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-next-round":
self.dismiss("next_round")
elif event.button.id == "btn-lobby":
self.dismiss("lobby")

View File

@ -0,0 +1,75 @@
"""Status bar showing phase, turn info, and action prompts."""
from __future__ import annotations
from textual.widgets import Static
from tui_client.models import GameState
class StatusBarWidget(Static):
"""Top status bar with round, phase, and turn info."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._state: GameState | None = None
self._player_id: str | None = None
self._extra: str = ""
def update_state(self, state: GameState, player_id: str | None = None) -> None:
self._state = state
self._player_id = player_id
self._refresh()
def set_extra(self, text: str) -> None:
self._extra = text
self._refresh()
def _refresh(self) -> None:
if not self._state:
self.update("Connecting...")
return
state = self._state
parts = []
# Round info
parts.append(f"Round {state.current_round}/{state.total_rounds}")
# Phase
phase_display = {
"waiting": "Waiting",
"initial_flip": "[bold white on #6a0dad] Flip Phase [/bold white on #6a0dad]",
"playing": "Playing",
"final_turn": "[bold white on #c62828] FINAL TURN [/bold white on #c62828]",
"round_over": "[white on #555555] Round Over [/white on #555555]",
"game_over": "[bold white on #b8860b] Game Over [/bold white on #b8860b]",
}.get(state.phase, state.phase)
parts.append(phase_display)
# Turn info (skip during initial flip - it's misleading)
if state.current_player_id and state.players and state.phase != "initial_flip":
if state.current_player_id == self._player_id:
parts.append("[bold white on #2e7d32] YOUR TURN [/bold white on #2e7d32]")
else:
for p in state.players:
if p.id == state.current_player_id:
parts.append(f"[white on #555555] {p.name}'s Turn [/white on #555555]")
break
# Finisher indicator
if state.finisher_id:
for p in state.players:
if p.id == state.finisher_id:
parts.append(f"[bold white on #b8860b] {p.name} finished! [/bold white on #b8860b]")
break
# Active rules
if state.active_rules:
parts.append(f"Rules: {', '.join(state.active_rules)}")
text = "".join(parts)
if self._extra:
text += f" {self._extra}"
self.update(text)