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"] self._username = data["user"]["username"]
return data 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: async def connect(self) -> None:
"""Open WebSocket connection to the server.""" """Open WebSocket connection to the server."""
self._ws = await websockets.connect(self.ws_url) 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 __future__ import annotations
from textual.app import ComposeResult 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.screen import Screen
from textual.widgets import Button, Input, Static 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): 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: def compose(self) -> ComposeResult:
with Container(id="connect-container"): with Container(id="connect-container"):
yield Static("GolfCards.club", id="connect-title") yield Static(_TITLE, id="connect-title")
yield Static("Login (optional - leave blank to play as guest)")
yield Input(placeholder="Username", id="input-username") # Login form
yield Input(placeholder="Password", password=True, id="input-password") with Vertical(id="login-form"):
with Horizontal(id="connect-buttons"): yield Static("Log in to play")
yield Button("Connect as Guest", id="btn-guest", variant="default") yield Input(placeholder="Username", id="input-username")
yield Button("Login & Connect", id="btn-login", variant="primary") yield Input(placeholder="Password", password=True, id="input-password")
with Horizontal(id="connect-buttons"):
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") 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: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-guest": if event.button.id == "btn-login":
self._connect(login=False) self._do_login()
elif event.button.id == "btn-login": elif event.button.id == "btn-signup":
self._connect(login=True) 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: def on_input_submitted(self, event: Input.Submitted) -> None:
# Enter key in password field triggers login
if event.input.id == "input-password": 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: def _do_login(self) -> None:
self._set_status("Connecting...") self._set_status("Logging in...")
self._disable_buttons() 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 client = self.app.client
try: try:
if login: username = self.query_one("#input-username", Input).value.strip()
username = self.query_one("#input-username", Input).value.strip() password = self.query_one("#input-password", Input).value
password = self.query_one("#input-password", Input).value if not username or not password:
if not username or not password: self._set_status("Username and password required")
self._set_status("Username and password required") self._enable_buttons()
self._enable_buttons() return
return await client.login(username, password)
self._set_status("Logging in...") self._set_status(f"Logged in as {client.username}")
await client.login(username, password) await self._connect_ws()
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: except Exception as e:
self._set_status(f"Error: {e}") self._set_status(f"[red]{e}[/red]")
self._enable_buttons() self._enable_buttons()
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!")
from tui_client.screens.lobby import LobbyScreen
self.app.push_screen(LobbyScreen())
def _set_status(self, text: str) -> None: def _set_status(self, text: str) -> None:
self.query_one("#connect-status", Static).update(text) 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.app import ComposeResult
from textual.containers import Container, Horizontal from textual.containers import Container, Horizontal
from textual.events import Resize from textual.events import Resize
from textual.screen import Screen from textual.screen import ModalScreen, Screen
from textual.widgets import Static from textual.widgets import Button, Static
from tui_client.models import GameState, PlayerData from tui_client.models import GameState, PlayerData
from tui_client.widgets.hand import HandWidget 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 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): class GameScreen(Screen):
"""Main game board with card display and keyboard controls.""" """Main game board with card display and keyboard controls."""
@ -33,6 +157,9 @@ class GameScreen(Screen):
("p", "skip_flip", "Skip flip"), ("p", "skip_flip", "Skip flip"),
("k", "knock_early", "Knock early"), ("k", "knock_early", "Knock early"),
("n", "next_round", "Next round"), ("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): def __init__(self, initial_state: dict, is_host: bool = False):
@ -55,7 +182,10 @@ class GameScreen(Screen):
yield PlayAreaWidget(id="play-area") yield PlayAreaWidget(id="play-area")
yield Static("", id="local-hand-label") yield Static("", id="local-hand-label")
yield HandWidget(id="local-hand") 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: def on_mount(self) -> None:
self._player_id = self.app.player_id or "" self._player_id = self.app.player_id or ""
@ -97,12 +227,12 @@ class GameScreen(Screen):
if source == "discard": if source == "discard":
self._set_action( 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") self._set_keymap("[1-6] Swap [C] Cancel")
else: else:
self._set_action( 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") self._set_keymap("[1-6] Swap [X] Discard")
@ -111,10 +241,10 @@ class GameScreen(Screen):
optional = data.get("optional", False) optional = data.get("optional", False)
self._can_flip_optional = optional self._can_flip_optional = optional
if 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") self._set_keymap("[1-6] Flip card [P] Skip")
else: 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") self._set_keymap("[1-6] Flip card")
def _handle_round_over(self, data: dict) -> None: def _handle_round_over(self, data: dict) -> None:
@ -125,7 +255,7 @@ class GameScreen(Screen):
self.app.push_screen( self.app.push_screen(
ScoreboardScreen( ScoreboardScreen(
scores=scores, scores=scores,
title=f"Round {round_num} Complete", title=f"Hole {round_num} Complete",
is_game_over=False, is_game_over=False,
is_host=self._is_host, is_host=self._is_host,
round_num=round_num, round_num=round_num,
@ -172,6 +302,10 @@ class GameScreen(Screen):
elif result == "lobby": elif result == "lobby":
self.run_worker(self._send("leave_game")) self.run_worker(self._send("leave_game"))
self.app.pop_screen() 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) # Click handlers (from widget messages)
@ -256,9 +390,10 @@ class GameScreen(Screen):
hand.update_player( hand.update_player(
me, me,
deck_colors=self._state.deck_colors, deck_colors=self._state.deck_colors,
is_current_turn=(me.id == self._state.current_player_id), is_current_turn=False,
is_knocker=(me.id == self._state.finisher_id and self._state.phase == "final_turn"), is_knocker=False,
is_dealer=(me.id == self._state.dealer_id), is_dealer=(me.id == self._state.dealer_id),
highlight=True,
) )
needed = self._state.initial_flips needed = self._state.initial_flips
@ -272,7 +407,7 @@ class GameScreen(Screen):
self._initial_flip_positions = [] self._initial_flip_positions = []
else: else:
self._set_action( 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: def _do_flip(self, pos: int) -> None:
@ -299,7 +434,7 @@ class GameScreen(Screen):
def action_flip_mode(self) -> None: def action_flip_mode(self) -> None:
if self._state.flip_as_action and self._is_my_turn() and not self._state.has_drawn_card: if self._state.flip_as_action and self._is_my_turn() and not self._state.has_drawn_card:
self._awaiting_flip = True 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: def action_skip_flip(self) -> None:
if self._awaiting_flip and self._can_flip_optional: 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": if self._is_host and self._state.phase == "round_over":
self.run_worker(self._send("next_round")) 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: def action_leave_game(self) -> None:
self.run_worker(self._send("leave_game")) self.run_worker(self._send("leave_game"))
self.app.pop_screen() 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 # Helpers
@ -340,9 +500,21 @@ class GameScreen(Screen):
except Exception as e: except Exception as e:
self._set_action(f"[red]Send error: {e}[/red]") 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: 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: except Exception:
pass pass
@ -361,6 +533,8 @@ class GameScreen(Screen):
# Play area # Play area
play_area = self.query_one("#play-area", PlayAreaWidget) play_area = self.query_one("#play-area", PlayAreaWidget)
play_area.update_state(state, local_player_id=self._player_id) 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) # Local player hand (in bordered box with turn/knocker indicators)
me = self._get_local_player() me = self._get_local_player()
@ -368,12 +542,15 @@ class GameScreen(Screen):
self.query_one("#local-hand-label", Static).update("") self.query_one("#local-hand-label", Static).update("")
hand = self.query_one("#local-hand", HandWidget) hand = self.query_one("#local-hand", HandWidget)
hand._is_local = True 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( hand.update_player(
me, me,
deck_colors=state.deck_colors, 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_knocker=(me.id == state.finisher_id and state.phase == "final_turn"),
is_dealer=(me.id == state.dealer_id), is_dealer=(me.id == state.dealer_id),
highlight=state.waiting_for_initial_flip,
) )
else: else:
self.query_one("#local-hand-label", Static).update("") self.query_one("#local-hand-label", Static).update("")
@ -413,12 +590,13 @@ class GameScreen(Screen):
cards, deck_colors=deck_colors, matched=matched, 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( box = render_player_box(
opp.name, opp.name,
score=opp.score, score=opp.score,
total_score=opp.total_score, total_score=opp.total_score,
content_lines=card_lines, 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_knocker=(opp.id == state.finisher_id and state.phase == "final_turn"),
is_dealer=(opp.id == state.dealer_id), is_dealer=(opp.id == state.dealer_id),
all_face_up=opp.all_face_up, all_face_up=opp.all_face_up,
@ -426,17 +604,22 @@ class GameScreen(Screen):
opp_blocks.append(box) opp_blocks.append(box)
# Determine how many opponents fit per row # Determine how many opponents fit per row
# Each box is ~21-24 chars wide # Each box is ~21-24 chars wide; use actual widths for accuracy
opp_width = 22 box_widths = [_visible_len(b[0]) if b else 22 for b in opp_blocks]
if width < 80: gap = " " if width < 120 else " "
per_row = 1 gap_len = len(gap)
gap = " "
elif width < 120: # Greedily fit as many as possible in one row
gap = " " per_row = 0
per_row = max(1, (width - 4) // (opp_width + len(gap))) row_width = 0
else: for bw in box_widths:
gap = " " needed_width = bw if per_row == 0 else gap_len + bw
per_row = max(1, (min(width, 120) - 4) // (opp_width + len(gap))) if row_width + needed_width <= width:
row_width += needed_width
per_row += 1
else:
break
per_row = max(1, per_row)
# Render in rows of per_row opponents # Render in rows of per_row opponents
all_row_lines: list[str] = [] all_row_lines: list[str] = []
@ -469,15 +652,15 @@ class GameScreen(Screen):
state = self._state state = self._state
if state.phase in ("round_over", "game_over"): if state.phase in ("round_over", "game_over"):
self._set_action("Keyboard: \[n]ext round") self._set_action("\\[n]ext hole", active=True)
self._set_keymap("[N] Next round") self._set_keymap("[N] Next hole")
return return
if state.waiting_for_initial_flip: if state.waiting_for_initial_flip:
needed = state.initial_flips needed = state.initial_flips
selected = len(self._initial_flip_positions) selected = len(self._initial_flip_positions)
self._set_action( 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") self._set_keymap("[1-6] Select card")
return return
@ -496,23 +679,23 @@ class GameScreen(Screen):
if state.has_drawn_card: if state.has_drawn_card:
keys = ["[1-6] Swap"] keys = ["[1-6] Swap"]
if state.can_discard: 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") keys.append("[X] Discard")
else: 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") keys.append("[C] Cancel")
self._set_keymap(" ".join(keys)) self._set_keymap(" ".join(keys))
return 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"] keys = ["[D] Draw", "[S] Pick discard"]
if state.flip_as_action: if state.flip_as_action:
parts.append("\[f]lip a card") parts.append("\\[f]lip a card")
keys.append("[F] Flip") keys.append("[F] Flip")
if state.knock_early: if state.knock_early:
parts.append("\[k]nock early") parts.append("\\[k]nock early")
keys.append("[K] Knock") keys.append("[K] Knock")
self._set_action("Keyboard: " + " or ".join(parts)) self._set_action(" or ".join(parts), active=True)
self._set_keymap(" ".join(keys)) self._set_keymap(" ".join(keys))
def _set_keymap(self, text: str) -> None: def _set_keymap(self, text: str) -> None:

View File

@ -83,7 +83,7 @@ class LobbyScreen(Screen):
with Vertical(id="host-settings"): with Vertical(id="host-settings"):
with Collapsible(title="Game Settings", collapsed=True, id="coll-game"): with Collapsible(title="Game Settings", collapsed=True, id="coll-game"):
with Horizontal(classes="setting-row"): with Horizontal(classes="setting-row"):
yield Label("Rounds") yield Label("Holes")
yield Select( yield Select(
[(str(v), v) for v in (1, 3, 9, 18)], [(str(v), v) for v in (1, 3, 9, 18)],
value=9, value=9,
@ -200,6 +200,23 @@ class LobbyScreen(Screen):
self._update_visibility() self._update_visibility()
self._update_keymap() 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: def _update_visibility(self) -> None:
try: try:
self.query_one("#pre-room").display = not self._in_room self.query_one("#pre-room").display = not self._in_room

View File

@ -32,16 +32,32 @@ ConnectScreen {
margin-bottom: 1; margin-bottom: 1;
} }
#connect-buttons { #login-form, #signup-form {
height: auto;
}
#signup-form {
display: none;
}
#connect-buttons, #signup-buttons {
height: 3; height: 3;
align: center middle; align: center middle;
margin-top: 1; margin-top: 1;
} }
#connect-buttons Button { #connect-buttons Button, #signup-buttons Button {
margin: 0 1; margin: 0 1;
} }
#btn-toggle-signup, #btn-toggle-login {
width: 100%;
margin-top: 1;
background: transparent;
border: none;
color: $text-muted;
}
#connect-status { #connect-status {
text-align: center; text-align: center;
color: $warning; color: $warning;
@ -234,17 +250,11 @@ GameScreen {
content-align: center middle; content-align: center middle;
} }
#action-bar { #play-area.my-turn {
height: auto; border: round #ffd700;
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 */
#local-hand-label { #local-hand-label {
text-align: center; text-align: center;
@ -292,3 +302,107 @@ GameScreen {
align: center middle; align: center middle;
margin-top: 1; 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 BORDER_COLOR = "#888888" # card border
EMPTY_COLOR = "#555555" # empty card slot EMPTY_COLOR = "#555555" # empty card slot
POSITION_COLOR = "#f0e68c" # pale yellow — distinct from suits and card backs 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: 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"]) 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.""" """Top border line, with position number replacing ┌ when present."""
if position is not None: 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}]{position}───┐[/{d}{color}]"
return f"[{d}{color}]┌───┐[/{d}{color}]" return f"[{d}{color}]┌───┐[/{d}{color}]"
@ -54,6 +58,7 @@ def render_card(
position: int | None = None, position: int | None = None,
deck_colors: list[str] | None = None, deck_colors: list[str] | None = None,
dim: bool = False, dim: bool = False,
highlight: bool = False,
) -> str: ) -> str:
"""Render a card as a 4-line Rich-markup string. """Render a card as a 4-line Rich-markup string.
@ -64,7 +69,7 @@ def render_card(
""" """
d = "dim " if dim else "" d = "dim " if dim else ""
bc = BORDER_COLOR bc = HIGHLIGHT_COLOR if highlight else BORDER_COLOR
# Empty slot # Empty slot
if card is None: if card is None:
@ -76,7 +81,7 @@ def render_card(
f"[{d}{c}]└───┘[/{d}{c}]" 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 # Face-down card with colored back
if not card.face_up: if not card.face_up:

View File

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

View File

@ -58,13 +58,15 @@ class PlayAreaWidget(Static):
def on_click(self, event: Click) -> None: def on_click(self, event: Click) -> None:
"""Map click to deck or discard column.""" """Map click to deck or discard column."""
x = event.x # Content is always 3 columns wide; account for centering within widget
# Layout: DECK (col 0..11) [HOLDING (col 12..23)] DISCARD (last col) content_width = 3 * _COL_WIDTH
if x < _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()) self.post_message(self.DeckClicked())
elif self._has_holding and x >= 2 * _COL_WIDTH: elif 2 * _COL_WIDTH <= x < 3 * _COL_WIDTH:
self.post_message(self.DiscardClicked())
elif not self._has_holding and x >= _COL_WIDTH:
self.post_message(self.DiscardClicked()) self.post_message(self.DiscardClicked())
def _refresh(self) -> None: def _refresh(self) -> None:
@ -114,7 +116,7 @@ class PlayAreaWidget(Static):
lines.append(row) lines.append(row)
# Labels row — always 3 columns # Labels row — always 3 columns
deck_label = f"DECK:{state.deck_remaining}" deck_label = f"DECK [dim]{state.deck_remaining}[/dim]"
discard_label = "DISCARD" discard_label = "DISCARD"
label = _pad_center(deck_label, _COL_WIDTH) label = _pad_center(deck_label, _COL_WIDTH)
if held_lines: if held_lines:

View File

@ -9,7 +9,6 @@ _BORDER_NORMAL = "#555555"
_BORDER_TURN_LOCAL = "#9ab973" # green — your turn _BORDER_TURN_LOCAL = "#9ab973" # green — your turn
_BORDER_TURN_OPPONENT = "#f4a460" # sandy orange — opponent's turn _BORDER_TURN_OPPONENT = "#f4a460" # sandy orange — opponent's turn
_BORDER_KNOCKER = "#ff6b35" # red-orange — went out _BORDER_KNOCKER = "#ff6b35" # red-orange — went out
_NAME_COLOR = "#e0e0e0" _NAME_COLOR = "#e0e0e0"

View File

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

View File

@ -34,15 +34,15 @@ class StatusBarWidget(Static):
parts = [] parts = []
# Round info # Round info
parts.append(f"Round {state.current_round}/{state.total_rounds}") parts.append(f" {state.current_round}/{state.total_rounds}")
# Phase # Phase
phase_display = { phase_display = {
"waiting": "Waiting", "waiting": "Waiting",
"initial_flip": "[bold white on #6a0dad] Flip Phase [/bold white on #6a0dad]", "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]", "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]", "game_over": "[bold white on #b8860b] Game Over [/bold white on #b8860b]",
}.get(state.phase, state.phase) }.get(state.phase, state.phase)
parts.append(phase_display) parts.append(phase_display)
@ -50,7 +50,7 @@ class StatusBarWidget(Static):
# Turn info (skip during initial flip - it's misleading) # 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 and state.players and state.phase != "initial_flip":
if state.current_player_id == self._player_id: 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: else:
for p in state.players: for p in state.players:
if p.id == state.current_player_id: if p.id == state.current_player_id:
@ -68,7 +68,7 @@ class StatusBarWidget(Static):
if state.active_rules: if state.active_rules:
parts.append(f"Rules: {', '.join(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: if self._extra:
text += f" {self._extra}" text += f" {self._extra}"