Add TUI signup flow, quit/help/standings modals, and UI refinements

- Add signup with invite code support, remove guest login
- Add quit confirmation (q), help screen (h), standings tab
- Unified footer: [h]elp [q]uit | action text | [tab] standings
- Amber card highlighting persists through entire initial flip phase
- Player box border only highlights on turn (green) or knock (red)
- Play area gold border only during player's actual turn
- Game end returns to lobby create/join instead of login screen
- Lobby reset_to_pre_room for replayability without reconnecting
- Dynamic opponent layout fits all in one row when terminal is wide enough
- Hole emoji () in status bar, branded title with suits on connect screen
- DECK label spacing, Hole terminology in scoreboard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken 2026-02-24 20:14:04 -05:00
parent bfe29bb665
commit 13e98d330a
11 changed files with 557 additions and 112 deletions

View File

@ -65,6 +65,28 @@ class GameClient:
self._username = data["user"]["username"]
return data
async def register(
self, username: str, password: str, invite_code: str = "", email: str = ""
) -> dict:
"""Register a new account via HTTP and store JWT token."""
payload: dict = {"username": username, "password": password}
if invite_code:
payload["invite_code"] = invite_code
if email:
payload["email"] = email
async with httpx.AsyncClient(verify=self.use_tls) as http:
resp = await http.post(
f"{self.http_base}/api/auth/register",
json=payload,
)
if resp.status_code != 200:
detail = resp.json().get("detail", "Registration 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)

View File

@ -1,70 +1,159 @@
"""Connection screen: server URL + optional login form."""
"""Connection screen: login or sign up form."""
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Container, Horizontal
from textual.containers import Container, Horizontal, Vertical
from textual.screen import Screen
from textual.widgets import Button, Input, Static
_TITLE = (
"⛳🏌️ [bold]GolfCards.club[/bold] "
"[bold #aaaaaa]♠[/bold #aaaaaa]"
"[bold #cc0000]♥[/bold #cc0000]"
"[bold #aaaaaa]♣[/bold #aaaaaa]"
"[bold #cc0000]♦[/bold #cc0000]"
)
class ConnectScreen(Screen):
"""Initial screen for connecting to the server."""
"""Initial screen for logging in or signing up."""
def __init__(self):
super().__init__()
self._mode: str = "login" # "login" or "signup"
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 Static(_TITLE, id="connect-title")
# Login form
with Vertical(id="login-form"):
yield Static("Log in to play")
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 Button("Login", id="btn-login", variant="primary")
yield Button(
"No account? [bold cyan]Sign Up[/bold cyan]",
id="btn-toggle-signup",
variant="default",
)
# Signup form
with Vertical(id="signup-form"):
yield Static("Create an account")
yield Input(placeholder="Invite Code", id="input-invite-code")
yield Input(placeholder="Username", id="input-signup-username")
yield Input(placeholder="Email (optional)", id="input-signup-email")
yield Input(
placeholder="Password (min 8 chars)",
password=True,
id="input-signup-password",
)
with Horizontal(id="signup-buttons"):
yield Button("Sign Up", id="btn-signup", variant="primary")
yield Button(
"Have an account? [bold cyan]Log In[/bold cyan]",
id="btn-toggle-login",
variant="default",
)
yield Static("", id="connect-status")
def on_mount(self) -> None:
self._update_form_visibility()
def _update_form_visibility(self) -> None:
try:
self.query_one("#login-form").display = self._mode == "login"
self.query_one("#signup-form").display = self._mode == "signup"
except Exception:
pass
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)
if event.button.id == "btn-login":
self._do_login()
elif event.button.id == "btn-signup":
self._do_signup()
elif event.button.id == "btn-toggle-signup":
self._mode = "signup"
self._set_status("")
self._update_form_visibility()
elif event.button.id == "btn-toggle-login":
self._mode = "login"
self._set_status("")
self._update_form_visibility()
def key_escape(self) -> None:
"""Escape goes back to login if on signup form."""
if self._mode == "signup":
self._mode = "login"
self._set_status("")
self._update_form_visibility()
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)
self._do_login()
elif event.input.id == "input-signup-password":
self._do_signup()
def _connect(self, login: bool) -> None:
self._set_status("Connecting...")
def _do_login(self) -> None:
self._set_status("Logging in...")
self._disable_buttons()
self.run_worker(self._do_connect(login), exclusive=True)
self.run_worker(self._login_flow(), exclusive=True)
async def _do_connect(self, login: bool) -> None:
def _do_signup(self) -> None:
self._set_status("Signing up...")
self._disable_buttons()
self.run_worker(self._signup_flow(), exclusive=True)
async def _login_flow(self) -> 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}")
await self._connect_ws()
except Exception as e:
self._set_status(f"[red]{e}[/red]")
self._enable_buttons()
self._set_status("Connecting to WebSocket...")
async def _signup_flow(self) -> None:
client = self.app.client
try:
invite = self.query_one("#input-invite-code", Input).value.strip()
username = self.query_one("#input-signup-username", Input).value.strip()
email = self.query_one("#input-signup-email", Input).value.strip()
password = self.query_one("#input-signup-password", Input).value
if not username or not password:
self._set_status("Username and password required")
self._enable_buttons()
return
if len(password) < 8:
self._set_status("Password must be at least 8 characters")
self._enable_buttons()
return
await client.register(username, password, invite_code=invite, email=email)
self._set_status(f"Account created! Welcome, {client.username}")
await self._connect_ws()
except Exception as e:
self._set_status(f"[red]{e}[/red]")
self._enable_buttons()
async def _connect_ws(self) -> None:
client = self.app.client
self._set_status("Connecting...")
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)

View File

@ -5,8 +5,8 @@ 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 textual.screen import ModalScreen, Screen
from textual.widgets import Button, Static
from tui_client.models import GameState, PlayerData
from tui_client.widgets.hand import HandWidget
@ -15,6 +15,130 @@ from tui_client.widgets.scoreboard import ScoreboardScreen
from tui_client.widgets.status_bar import StatusBarWidget
class ConfirmQuitScreen(ModalScreen[bool]):
"""Modal confirmation for quitting/leaving a game."""
BINDINGS = [
("y", "confirm", "Yes"),
("n", "cancel", "No"),
("escape", "cancel", "Cancel"),
]
def __init__(self, message: str) -> None:
super().__init__()
self._message = message
def compose(self) -> ComposeResult:
with Container(id="confirm-dialog"):
yield Static(self._message, id="confirm-message")
with Horizontal(id="confirm-buttons"):
yield Button("Yes [Y]", id="btn-yes", variant="error")
yield Button("No [N]", id="btn-no", variant="primary")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-yes":
self.dismiss(True)
else:
self.dismiss(False)
def action_confirm(self) -> None:
self.dismiss(True)
def action_cancel(self) -> None:
self.dismiss(False)
_HELP_TEXT = """\
[bold]Keyboard Commands[/bold]
[bold]Drawing[/bold]
\\[d] Draw from deck
\\[s] Pick from discard pile
[bold]Card Actions[/bold]
\\[1]-\\[6] Select card position
(flip, swap, or initial flip)
\\[x] Discard held card
\\[c] Cancel draw (from discard)
[bold]Special Actions[/bold]
\\[f] Flip a card (when enabled)
\\[p] Skip optional flip
\\[k] Knock early (when enabled)
[bold]Game Flow[/bold]
\\[n] Next hole
\\[tab] Standings
\\[q] Quit / leave game
\\[h] This help screen
[dim]Press any key or click to close[/dim]\
"""
class StandingsScreen(ModalScreen):
"""Modal overlay showing current game standings."""
BINDINGS = [
("escape", "close", "Close"),
("tab", "close", "Close"),
]
def __init__(self, players: list, current_round: int, total_rounds: int) -> None:
super().__init__()
self._players = players
self._current_round = current_round
self._total_rounds = total_rounds
def compose(self) -> ComposeResult:
with Container(id="standings-dialog"):
yield Static(
f"[bold]Standings — Hole {self._current_round}/{self._total_rounds}[/bold]",
id="standings-title",
)
yield Static(self._build_table(), id="standings-body")
yield Static("[dim]Press any key to close[/dim]", id="standings-hint")
def _build_table(self) -> str:
sorted_players = sorted(self._players, key=lambda p: p.total_score)
lines = []
for i, p in enumerate(sorted_players, 1):
score_str = f"{p.total_score:>4}"
lines.append(f" {i}. {p.name:<16} {score_str}")
return "\n".join(lines)
def on_key(self, event) -> None:
self.dismiss()
def on_click(self, event) -> None:
self.dismiss()
def action_close(self) -> None:
self.dismiss()
class HelpScreen(ModalScreen):
"""Modal help overlay showing all keyboard commands."""
BINDINGS = [
("escape", "close", "Close"),
("h", "close", "Close"),
]
def compose(self) -> ComposeResult:
with Container(id="help-dialog"):
yield Static(_HELP_TEXT, id="help-text")
def on_key(self, event) -> None:
self.dismiss()
def on_click(self, event) -> None:
self.dismiss()
def action_close(self) -> None:
self.dismiss()
class GameScreen(Screen):
"""Main game board with card display and keyboard controls."""
@ -33,6 +157,9 @@ class GameScreen(Screen):
("p", "skip_flip", "Skip flip"),
("k", "knock_early", "Knock early"),
("n", "next_round", "Next round"),
("q", "quit_game", "Quit game"),
("h", "show_help", "Help"),
("tab", "show_standings", "Standings"),
]
def __init__(self, initial_state: dict, is_host: bool = False):
@ -55,7 +182,10 @@ class GameScreen(Screen):
yield PlayAreaWidget(id="play-area")
yield Static("", id="local-hand-label")
yield HandWidget(id="local-hand")
yield Static("", id="action-bar")
with Horizontal(id="game-footer"):
yield Static("\\[h]elp \\[q]uit", id="footer-left")
yield Static("", id="footer-center")
yield Static("\\[tab] standings", id="footer-right")
def on_mount(self) -> None:
self._player_id = self.app.player_id or ""
@ -97,12 +227,12 @@ class GameScreen(Screen):
if source == "discard":
self._set_action(
f"Holding {rank}{suit} — Choose spot \[1] thru \[6] or \[c]ancel"
f"Holding {rank}{suit} — Choose spot \\[1] thru \\[6] or \\[c]ancel", active=True
)
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"
f"Holding {rank}{suit} — Choose spot \\[1] thru \\[6] or \\[x] to discard", active=True
)
self._set_keymap("[1-6] Swap [X] Discard")
@ -111,10 +241,10 @@ class GameScreen(Screen):
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_action("Flip a card \\[1] thru \\[6] or \\[p] to skip", active=True)
self._set_keymap("[1-6] Flip card [P] Skip")
else:
self._set_action("Keyboard: Flip a face-down card \[1] thru \[6]")
self._set_action("Flip a face-down card \\[1] thru \\[6]", active=True)
self._set_keymap("[1-6] Flip card")
def _handle_round_over(self, data: dict) -> None:
@ -125,7 +255,7 @@ class GameScreen(Screen):
self.app.push_screen(
ScoreboardScreen(
scores=scores,
title=f"Round {round_num} Complete",
title=f"Hole {round_num} Complete",
is_game_over=False,
is_host=self._is_host,
round_num=round_num,
@ -172,6 +302,10 @@ class GameScreen(Screen):
elif result == "lobby":
self.run_worker(self._send("leave_game"))
self.app.pop_screen()
# Reset lobby back to create/join state
lobby = self.app.screen
if hasattr(lobby, "reset_to_pre_room"):
lobby.reset_to_pre_room()
# ------------------------------------------------------------------
# Click handlers (from widget messages)
@ -256,9 +390,10 @@ class GameScreen(Screen):
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_current_turn=False,
is_knocker=False,
is_dealer=(me.id == self._state.dealer_id),
highlight=True,
)
needed = self._state.initial_flips
@ -272,7 +407,7 @@ class GameScreen(Screen):
self._initial_flip_positions = []
else:
self._set_action(
f"Keyboard: Choose {needed - selected} more card(s) to flip ({selected}/{needed})"
f"Choose {needed - selected} more card(s) to flip ({selected}/{needed})", active=True
)
def _do_flip(self, pos: int) -> None:
@ -299,7 +434,7 @@ class GameScreen(Screen):
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]")
self._set_action("Flip mode: select a face-down card [1-6]", active=True)
def action_skip_flip(self) -> None:
if self._awaiting_flip and self._can_flip_optional:
@ -317,9 +452,34 @@ class GameScreen(Screen):
if self._is_host and self._state.phase == "round_over":
self.run_worker(self._send("next_round"))
def action_show_help(self) -> None:
self.app.push_screen(HelpScreen())
def action_show_standings(self) -> None:
self.app.push_screen(StandingsScreen(
self._state.players,
self._state.current_round,
self._state.total_rounds,
))
def action_quit_game(self) -> None:
if self._is_host:
msg = "End the game for everyone?"
else:
msg = "Leave this game?"
self.app.push_screen(ConfirmQuitScreen(msg), callback=self._on_quit_confirm)
def _on_quit_confirm(self, confirmed: bool) -> None:
if confirmed:
self.action_leave_game()
def action_leave_game(self) -> None:
self.run_worker(self._send("leave_game"))
self.app.pop_screen()
# Reset lobby back to create/join state
lobby = self.app.screen
if hasattr(lobby, "reset_to_pre_room"):
lobby.reset_to_pre_room()
# ------------------------------------------------------------------
# Helpers
@ -340,9 +500,21 @@ class GameScreen(Screen):
except Exception as e:
self._set_action(f"[red]Send error: {e}[/red]")
def _set_action(self, text: str) -> None:
def _set_action(self, text: str, active: bool = False) -> None:
import re
try:
self.query_one("#action-bar", Static).update(text)
if active:
# Highlight bracketed keys and parenthesized counts in amber,
# rest in bold white
ac = "#ffaa00"
# Color \\[...] key hints and (...) counts
text = re.sub(
r"(\\?\[.*?\]|\([\d/]+\))",
rf"[bold {ac}]\1[/]",
text,
)
text = f"[bold white]{text}[/]"
self.query_one("#footer-center", Static).update(text)
except Exception:
pass
@ -361,6 +533,8 @@ class GameScreen(Screen):
# Play area
play_area = self.query_one("#play-area", PlayAreaWidget)
play_area.update_state(state, local_player_id=self._player_id)
is_active = self._is_my_turn() and not state.waiting_for_initial_flip
play_area.set_class(is_active, "my-turn")
# Local player hand (in bordered box with turn/knocker indicators)
me = self._get_local_player()
@ -368,12 +542,15 @@ class GameScreen(Screen):
self.query_one("#local-hand-label", Static).update("")
hand = self.query_one("#local-hand", HandWidget)
hand._is_local = True
# During initial flip, don't show current_turn borders (no one is "taking a turn")
show_turn = not state.waiting_for_initial_flip and me.id == state.current_player_id
hand.update_player(
me,
deck_colors=state.deck_colors,
is_current_turn=(me.id == state.current_player_id),
is_current_turn=show_turn,
is_knocker=(me.id == state.finisher_id and state.phase == "final_turn"),
is_dealer=(me.id == state.dealer_id),
highlight=state.waiting_for_initial_flip,
)
else:
self.query_one("#local-hand-label", Static).update("")
@ -413,12 +590,13 @@ class GameScreen(Screen):
cards, deck_colors=deck_colors, matched=matched,
)
opp_turn = not state.waiting_for_initial_flip and opp.id == state.current_player_id
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_current_turn=opp_turn,
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,
@ -426,17 +604,22 @@ class GameScreen(Screen):
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)))
# Each box is ~21-24 chars wide; use actual widths for accuracy
box_widths = [_visible_len(b[0]) if b else 22 for b in opp_blocks]
gap = " " if width < 120 else " "
gap_len = len(gap)
# Greedily fit as many as possible in one row
per_row = 0
row_width = 0
for bw in box_widths:
needed_width = bw if per_row == 0 else gap_len + bw
if row_width + needed_width <= width:
row_width += needed_width
per_row += 1
else:
gap = " "
per_row = max(1, (min(width, 120) - 4) // (opp_width + len(gap)))
break
per_row = max(1, per_row)
# Render in rows of per_row opponents
all_row_lines: list[str] = []
@ -469,15 +652,15 @@ class GameScreen(Screen):
state = self._state
if state.phase in ("round_over", "game_over"):
self._set_action("Keyboard: \[n]ext round")
self._set_keymap("[N] Next round")
self._set_action("\\[n]ext hole", active=True)
self._set_keymap("[N] Next hole")
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})"
f"Choose {needed} cards \\[1] thru \\[6] to flip ({selected}/{needed})", active=True
)
self._set_keymap("[1-6] Select card")
return
@ -496,23 +679,23 @@ class GameScreen(Screen):
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")
self._set_action("Choose spot \\[1] thru \\[6] or \\[x] to discard", active=True)
keys.append("[X] Discard")
else:
self._set_action("Keyboard: Choose spot \[1] thru \[6] or \[c]ancel")
self._set_action("Choose spot \\[1] thru \\[6] or \\[c]ancel", active=True)
keys.append("[C] Cancel")
self._set_keymap(" ".join(keys))
return
parts = ["Choose \[d]eck or di\[s]card pile"]
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")
parts.append("\\[f]lip a card")
keys.append("[F] Flip")
if state.knock_early:
parts.append("\[k]nock early")
parts.append("\\[k]nock early")
keys.append("[K] Knock")
self._set_action("Keyboard: " + " or ".join(parts))
self._set_action(" or ".join(parts), active=True)
self._set_keymap(" ".join(keys))
def _set_keymap(self, text: str) -> None:

View File

@ -83,7 +83,7 @@ class LobbyScreen(Screen):
with Vertical(id="host-settings"):
with Collapsible(title="Game Settings", collapsed=True, id="coll-game"):
with Horizontal(classes="setting-row"):
yield Label("Rounds")
yield Label("Holes")
yield Select(
[(str(v), v) for v in (1, 3, 9, 18)],
value=9,
@ -200,6 +200,23 @@ class LobbyScreen(Screen):
self._update_visibility()
self._update_keymap()
def reset_to_pre_room(self) -> None:
"""Reset lobby back to create/join state after leaving a game."""
self._room_code = None
self._player_id = None
self._is_host = False
self._players = []
self._in_room = False
self._set_room_info("")
self._set_status("")
try:
self.query_one("#input-room-code", Input).value = ""
self.query_one("#player-list", Static).update("")
except Exception:
pass
self._update_visibility()
self._update_keymap()
def _update_visibility(self) -> None:
try:
self.query_one("#pre-room").display = not self._in_room

View File

@ -32,16 +32,32 @@ ConnectScreen {
margin-bottom: 1;
}
#connect-buttons {
#login-form, #signup-form {
height: auto;
}
#signup-form {
display: none;
}
#connect-buttons, #signup-buttons {
height: 3;
align: center middle;
margin-top: 1;
}
#connect-buttons Button {
#connect-buttons Button, #signup-buttons Button {
margin: 0 1;
}
#btn-toggle-signup, #btn-toggle-login {
width: 100%;
margin-top: 1;
background: transparent;
border: none;
color: $text-muted;
}
#connect-status {
text-align: center;
color: $warning;
@ -234,17 +250,11 @@ GameScreen {
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;
#play-area.my-turn {
border: round #ffd700;
}
/* Local hand label */
#local-hand-label {
text-align: center;
@ -292,3 +302,107 @@ GameScreen {
align: center middle;
margin-top: 1;
}
/* Confirm quit dialog */
ConfirmQuitScreen {
align: center middle;
background: $surface 80%;
}
#confirm-dialog {
width: auto;
max-width: 48;
height: auto;
border: thick $error;
padding: 1 2;
background: $surface;
}
#confirm-message {
text-align: center;
width: 100%;
margin-bottom: 1;
}
#confirm-buttons {
height: 3;
align: center middle;
}
#confirm-buttons Button {
margin: 0 1;
}
/* Game footer: [h]elp <action> [tab] standings [q]uit */
#game-footer {
height: 1;
dock: bottom;
background: $surface-darken-1;
padding: 0 2;
}
#footer-left {
width: auto;
color: $text-muted;
}
#footer-center {
width: 1fr;
text-align: center;
content-align: center middle;
}
#footer-right {
width: auto;
color: $text-muted;
}
/* Help dialog */
HelpScreen {
align: center middle;
background: $surface 80%;
}
#help-dialog {
width: auto;
max-width: 48;
height: auto;
border: thick $primary;
padding: 1 2;
background: $surface;
}
#help-text {
width: 100%;
}
/* Standings dialog */
StandingsScreen {
align: center middle;
background: $surface 80%;
}
#standings-dialog {
width: auto;
max-width: 48;
height: auto;
border: thick $primary;
padding: 1 2;
background: $surface;
}
#standings-title {
text-align: center;
width: 100%;
margin-bottom: 1;
}
#standings-body {
width: 100%;
}
#standings-hint {
text-align: center;
width: 100%;
margin-top: 1;
}

View File

@ -30,6 +30,7 @@ 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
HIGHLIGHT_COLOR = "#ffaa00" # bright amber — initial flip / attention
def _back_color_for_card(card: CardData, deck_colors: list[str] | None = None) -> str:
@ -41,9 +42,12 @@ def _back_color_for_card(card: CardData, deck_colors: list[str] | None = None) -
return BACK_COLORS.get(name, BACK_COLORS["red"])
def _top_border(position: int | None, d: str, color: str) -> str:
def _top_border(position: int | None, d: str, color: str, highlight: bool = False) -> str:
"""Top border line, with position number replacing ┌ when present."""
if position is not None:
if highlight:
hc = HIGHLIGHT_COLOR
return f"[bold {hc}]{position}[/][{d}{color}]───┐[/{d}{color}]"
return f"[{d}{color}]{position}───┐[/{d}{color}]"
return f"[{d}{color}]┌───┐[/{d}{color}]"
@ -54,6 +58,7 @@ def render_card(
position: int | None = None,
deck_colors: list[str] | None = None,
dim: bool = False,
highlight: bool = False,
) -> str:
"""Render a card as a 4-line Rich-markup string.
@ -64,7 +69,7 @@ def render_card(
"""
d = "dim " if dim else ""
bc = BORDER_COLOR
bc = HIGHLIGHT_COLOR if highlight else BORDER_COLOR
# Empty slot
if card is None:
@ -76,7 +81,7 @@ def render_card(
f"[{d}{c}]└───┘[/{d}{c}]"
)
top = _top_border(position, d, bc)
top = _top_border(position, d, bc, highlight=highlight)
# Face-down card with colored back
if not card.face_up:

View File

@ -69,6 +69,7 @@ def _render_card_lines(
is_local: bool = False,
deck_colors: list[str] | None = None,
matched: list[bool] | None = None,
highlight: bool = False,
) -> list[str]:
"""Render the 2x3 card grid as a list of text lines (no box).
@ -94,6 +95,7 @@ def _render_card_lines(
position=pos,
deck_colors=deck_colors,
dim=matched[idx],
highlight=highlight,
)
card_lines = text.split("\n")
while len(row_line_parts) < len(card_lines):
@ -131,6 +133,8 @@ class HandWidget(Static):
self._is_knocker: bool = False
self._is_dealer: bool = False
self._has_connector: bool = False
self._highlight: bool = False
self._box_width: int = 0
def update_player(
self,
@ -140,6 +144,7 @@ class HandWidget(Static):
is_current_turn: bool = False,
is_knocker: bool = False,
is_dealer: bool = False,
highlight: bool = False,
) -> None:
self._player = player
if deck_colors is not None:
@ -147,6 +152,7 @@ class HandWidget(Static):
self._is_current_turn = is_current_turn
self._is_knocker = is_knocker
self._is_dealer = is_dealer
self._highlight = highlight
self._refresh()
def on_mount(self) -> None:
@ -154,9 +160,14 @@ class HandWidget(Static):
def on_click(self, event: Click) -> None:
"""Map click coordinates to card position (0-5)."""
if not self._is_local:
if not self._is_local or not self._box_width:
return
# The content is centered in the widget — compute the x offset
x_offset = max(0, (self.size.width - self._box_width) // 2)
x = event.x - x_offset
y = event.y
# Box layout:
# Line 0: top border
# Lines 1-4: row 0 cards (4 lines each)
@ -166,8 +177,6 @@ class HandWidget(Static):
#
# 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
@ -202,7 +211,7 @@ class HandWidget(Static):
self.update("")
return
from tui_client.widgets.player_box import render_player_box
from tui_client.widgets.player_box import _visible_len, render_player_box
cards = self._player.cards
matched = _check_column_match(cards)
@ -213,6 +222,7 @@ class HandWidget(Static):
is_local=self._is_local,
deck_colors=self._deck_colors,
matched=matched,
highlight=self._highlight,
)
box_lines = render_player_box(
@ -227,4 +237,8 @@ class HandWidget(Static):
all_face_up=self._player.all_face_up,
)
# Store box width for click coordinate mapping
if box_lines:
self._box_width = _visible_len(box_lines[0])
self.update("\n".join(box_lines))

View File

@ -58,13 +58,15 @@ class PlayAreaWidget(Static):
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:
# Content is always 3 columns wide; account for centering within widget
content_width = 3 * _COL_WIDTH
x_offset = max(0, (self.content_size.width - content_width) // 2)
x = event.x - x_offset
# Layout: DECK (col 0..11) | HOLDING (col 12..23) | DISCARD (col 24..35)
if 0 <= 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:
elif 2 * _COL_WIDTH <= x < 3 * _COL_WIDTH:
self.post_message(self.DiscardClicked())
def _refresh(self) -> None:
@ -114,7 +116,7 @@ class PlayAreaWidget(Static):
lines.append(row)
# Labels row — always 3 columns
deck_label = f"DECK:{state.deck_remaining}"
deck_label = f"DECK [dim]{state.deck_remaining}[/dim]"
discard_label = "DISCARD"
label = _pad_center(deck_label, _COL_WIDTH)
if held_lines:

View File

@ -9,7 +9,6 @@ _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"

View File

@ -14,7 +14,7 @@ class ScoreboardScreen(ModalScreen[str]):
def __init__(
self,
scores: list[dict],
title: str = "Round Over",
title: str = "Hole Over",
is_game_over: bool = False,
is_host: bool = False,
round_num: int = 1,
@ -44,7 +44,7 @@ class ScoreboardScreen(ModalScreen[str]):
table = self.query_one("#scoreboard-table", DataTable)
if self._is_game_over:
table.add_columns("Rank", "Player", "Total", "Rounds Won")
table.add_columns("Rank", "Player", "Total", "Holes Won")
for i, s in enumerate(self._scores, 1):
table.add_row(
str(i),
@ -53,7 +53,7 @@ class ScoreboardScreen(ModalScreen[str]):
str(s.get("rounds_won", 0)),
)
else:
table.add_columns("Player", "Round Score", "Total", "Rounds Won")
table.add_columns("Player", "Hole Score", "Total", "Holes Won")
for s in self._scores:
table.add_row(
s.get("name", "?"),

View File

@ -34,15 +34,15 @@ class StatusBarWidget(Static):
parts = []
# Round info
parts.append(f"Round {state.current_round}/{state.total_rounds}")
parts.append(f" {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",
"playing": "",
"final_turn": "[bold white on #c62828] FINAL TURN [/bold white on #c62828]",
"round_over": "[white on #555555] Round Over [/white on #555555]",
"round_over": "[white on #555555] Hole 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)
@ -50,7 +50,7 @@ class StatusBarWidget(Static):
# 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]")
parts.append("[bold #111111 on #ffd700] YOUR TURN! [/bold #111111 on #ffd700]")
else:
for p in state.players:
if p.id == state.current_player_id:
@ -68,7 +68,7 @@ class StatusBarWidget(Static):
if state.active_rules:
parts.append(f"Rules: {', '.join(state.active_rules)}")
text = "".join(parts)
text = "".join(p for p in parts if p)
if self._extra:
text += f" {self._extra}"