Overhaul TUI navigation, quit handling, and scoreboard tags

- Replace [esc][esc] quit with [q] quit globally (immediate on login,
  confirmation prompt elsewhere)
- [esc] is now consistently "back": signup→login, lobby→log out (with
  confirm), in-room host→leave (with confirm), in-room guest→leave
- Extract ConfirmScreen to shared screens/confirm.py
- Move dealer Ⓓ indicator to bottom-left corner of player box border
- Scoreboard now tags OUT (went out first) and  (lowest score)
- Send finisher_id and player id in round_over server message
- Room code moved inside in-room section with amber border
- Lobby title uses branded 🏌️ GolfCards.club ♠♥♣♦
- Amber borders and dark green backgrounds on login/lobby containers
- Deck preview renders actual card-back shapes (▓▒▓/▒▓▒)
- Help/standings panels close only with [esc], hint updated
- Game footer: s[⇥]andings [h]elp on left, [q]uit on right

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken 2026-02-25 21:41:45 -05:00
parent b1d3aa7b77
commit dfb3397dcb
10 changed files with 207 additions and 112 deletions

View File

@ -756,7 +756,7 @@ async def broadcast_game_state(room: Room):
# Check for round over
if room.game.phase == GamePhase.ROUND_OVER:
scores = [
{"name": p.name, "score": p.score, "total": p.total_score, "rounds_won": p.rounds_won}
{"id": p.id, "name": p.name, "score": p.score, "total": p.total_score, "rounds_won": p.rounds_won}
for p in room.game.players
]
# Build rankings
@ -765,6 +765,7 @@ async def broadcast_game_state(room: Room):
await player.websocket.send_json({
"type": "round_over",
"scores": scores,
"finisher_id": room.game.finisher_id,
"round": room.game.current_round,
"total_rounds": room.game.num_rounds,
"rankings": {

View File

@ -2,8 +2,6 @@
from __future__ import annotations
import time
from textual.app import App, ComposeResult
from textual.message import Message
from textual.widgets import Static
@ -42,6 +40,7 @@ class GolfApp(App):
BINDINGS = [
("escape", "esc_pressed", ""),
("q", "quit_app", ""),
]
def __init__(self, server: str, use_tls: bool = True):
@ -49,7 +48,6 @@ class GolfApp(App):
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")
@ -75,16 +73,34 @@ class GolfApp(App):
handler(msg)
def action_esc_pressed(self) -> None:
"""Single escape goes back, double-escape quits."""
now = time.monotonic()
if now - self._last_escape < 0.5:
"""Escape goes back — delegated to the active screen."""
handler = getattr(self.screen, "handle_escape", None)
if handler:
handler()
def action_quit_app(self) -> None:
"""[q] quits the app. Immediate on login, confirmation elsewhere."""
# Don't capture q when typing in input fields
focused = self.focused
if focused and hasattr(focused, "value"):
return
# Don't handle here on game screen (game has its own q binding)
if self.screen.__class__.__name__ == "GameScreen":
return
screen_name = self.screen.__class__.__name__
if screen_name == "ConnectScreen":
self.exit()
else:
self._last_escape = now
# Let the active screen handle single escape
handler = getattr(self.screen, "handle_escape", None)
if handler:
handler()
from tui_client.screens.confirm import ConfirmScreen
self.push_screen(
ConfirmScreen("Quit GolfCards?"),
callback=self._on_quit_confirm,
)
def _on_quit_confirm(self, confirmed: bool) -> None:
if confirmed:
self.exit()
def _update_keymap(self) -> None:
"""Update the keymap bar based on current screen."""
@ -93,23 +109,18 @@ class GolfApp(App):
if keymap:
text = keymap
elif screen_name == "ConnectScreen":
text = "[Tab] Navigate [Enter] Submit [Esc][Esc] Quit"
text = "[Tab] Navigate [Enter] Submit [q] Quit"
elif screen_name == "LobbyScreen":
text = "[Esc] Back [Tab] Navigate [Enter] Submit [Esc][Esc] Quit"
text = "[Esc] Back [Tab] Navigate [Enter] Create/Join [q] Quit"
else:
text = "[Esc][Esc] Quit"
text = "[q] 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]" not in text.replace("[Esc][Esc]", ""):
text = f"{text} [Esc][Esc] Quit"
"""Allow screens to update the keymap bar dynamically."""
try:
self.query_one("#keymap-bar", KeymapBar).update(text)
except Exception:

View File

@ -0,0 +1,41 @@
"""Reusable confirmation dialog."""
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, Static
class ConfirmScreen(ModalScreen[bool]):
"""Modal confirmation prompt. Dismisses with True/False."""
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)

View File

@ -9,7 +9,7 @@ from textual.widgets import Button, Input, Static
_TITLE = (
"⛳🏌️ [bold]GolfCards.club[/bold] "
"⛳🏌️ [bold]GolfCards.club[/bold] "
"[bold #aaaaaa]♠[/bold #aaaaaa]"
"[bold #cc0000]♥[/bold #cc0000]"
"[bold #aaaaaa]♣[/bold #aaaaaa]"
@ -64,7 +64,7 @@ class ConnectScreen(Screen):
with Horizontal(classes="screen-footer"):
yield Static("", id="connect-footer-left", classes="screen-footer-left")
yield Static("\\[esc]\\[esc] quit", id="connect-footer-right", classes="screen-footer-right")
yield Static("\\[q] quit", id="connect-footer-right", classes="screen-footer-right")
def on_mount(self) -> None:
self._update_form_visibility()
@ -170,7 +170,7 @@ class ConnectScreen(Screen):
client.save_session()
self._set_status("Connected!")
from tui_client.screens.lobby import LobbyScreen
self.app.push_screen(LobbyScreen())
self.app.switch_screen(LobbyScreen())
def _set_status(self, text: str) -> None:
self.query_one("#connect-status", Static).update(text)

View File

@ -9,45 +9,13 @@ from textual.screen import ModalScreen, Screen
from textual.widgets import Button, Static
from tui_client.models import GameState, PlayerData
from tui_client.screens.confirm import ConfirmScreen
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 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]
@ -72,7 +40,7 @@ _HELP_TEXT = """\
\\[q] Quit / leave game
\\[h] This help screen
[dim]Press any key or click to close[/dim]\
[dim]\\[esc] to close[/dim]\
"""
@ -97,7 +65,7 @@ class StandingsScreen(ModalScreen):
id="standings-title",
)
yield Static(self._build_table(), id="standings-body")
yield Static("[dim]Press any key to close[/dim]", id="standings-hint")
yield Static("[dim]\\[esc] to close[/dim]", id="standings-hint")
def _build_table(self) -> str:
sorted_players = sorted(self._players, key=lambda p: p.total_score)
@ -107,12 +75,6 @@ class StandingsScreen(ModalScreen):
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()
@ -129,12 +91,6 @@ class HelpScreen(ModalScreen):
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()
@ -185,9 +141,9 @@ class GameScreen(Screen):
yield Static("", id="local-hand-label")
yield HandWidget(id="local-hand")
with Horizontal(id="game-footer"):
yield Static("\\[h]elp \\[q]uit", id="footer-left")
yield Static("s\\[⇥]andings \\[h]elp", id="footer-left")
yield Static("", id="footer-center")
yield Static("\\[tab] standings", id="footer-right")
yield Static("\\[q]uit", id="footer-right")
def on_mount(self) -> None:
self._player_id = self.app.player_id or ""
@ -256,6 +212,7 @@ class GameScreen(Screen):
scores = data.get("scores", [])
round_num = data.get("round", 1)
total_rounds = data.get("total_rounds", 1)
finisher_id = data.get("finisher_id")
self.app.push_screen(
ScoreboardScreen(
@ -265,6 +222,7 @@ class GameScreen(Screen):
is_host=self._is_host,
round_num=round_num,
total_rounds=total_rounds,
finisher_id=finisher_id,
),
callback=self._on_scoreboard_dismiss,
)
@ -478,7 +436,7 @@ class GameScreen(Screen):
msg = "End the game for everyone?"
else:
msg = "Leave this game?"
self.app.push_screen(ConfirmQuitScreen(msg), callback=self._on_quit_confirm)
self.app.push_screen(ConfirmScreen(msg), callback=self._on_quit_confirm)
def _on_quit_confirm(self, confirmed: bool) -> None:
if confirmed:

View File

@ -54,9 +54,14 @@ class LobbyScreen(Screen):
def compose(self) -> ComposeResult:
with Container(id="lobby-container"):
yield Static("[bold]GolfCards.club[/bold]", id="lobby-title")
yield Static("", id="room-info")
yield Static(
"⛳🏌️ [bold]GolfCards.club[/bold] "
"[bold #aaaaaa]♠[/bold #aaaaaa]"
"[bold #cc0000]♥[/bold #cc0000]"
"[bold #aaaaaa]♣[/bold #aaaaaa]"
"[bold #cc0000]♦[/bold #cc0000]",
id="lobby-title",
)
# Pre-room: join/create
with Vertical(id="pre-room"):
yield Input(placeholder="Room code (leave blank to create new)", id="input-room-code")
@ -66,6 +71,7 @@ class LobbyScreen(Screen):
# In-room: player list + controls + settings
with Vertical(id="in-room"):
yield Static("", id="room-info")
yield Static("[bold]Players[/bold]", id="player-list-label")
yield Static("", id="player-list")
@ -198,7 +204,7 @@ class LobbyScreen(Screen):
with Horizontal(classes="screen-footer"): # Outside lobby-container
yield Static("\\[esc] back", id="lobby-footer-left", classes="screen-footer-left")
yield Static("\\[esc]\\[esc] quit", id="lobby-footer-right", classes="screen-footer-right")
yield Static("\\[q] quit", id="lobby-footer-right", classes="screen-footer-right")
def on_mount(self) -> None:
self._update_visibility()
@ -237,9 +243,9 @@ class LobbyScreen(Screen):
try:
left = self.query_one("#lobby-footer-left", Static)
if self._in_room:
left.update("\\[esc] leave")
left.update("\\[esc] leave room")
else:
left.update("\\[esc] back")
left.update("\\[esc] log out")
except Exception:
pass
@ -247,21 +253,47 @@ class LobbyScreen(Screen):
self._update_footer()
try:
if self._in_room and self._is_host:
self.app.set_keymap("[Esc] Leave [+] Add CPU [] Remove [Enter] Start [Esc][Esc] Quit")
self.app.set_keymap("[Esc] Leave [+] Add CPU [] Remove [Enter] Start [q] Quit")
elif self._in_room:
self.app.set_keymap("[Esc] Leave Waiting for host... [Esc][Esc] Quit")
self.app.set_keymap("[Esc] Leave Waiting for host... [q] Quit")
else:
self.app.set_keymap("[Esc] Back [Tab] Navigate [Enter] Create/Join [Esc][Esc] Quit")
self.app.set_keymap("[Esc] Log out [Tab] Navigate [Enter] Create/Join [q] Quit")
except Exception:
pass
def handle_escape(self) -> None:
"""Single escape: leave room → pre-room, or pre-room → back to connect."""
"""Single escape: leave room (with confirm if host), or log out."""
if self._in_room:
if self._is_host:
from tui_client.screens.confirm import ConfirmScreen
self.app.push_screen(
ConfirmScreen("End the game for everyone?"),
callback=self._on_leave_confirm,
)
else:
self.run_worker(self._send("leave_game"))
self.reset_to_pre_room()
else:
from tui_client.screens.confirm import ConfirmScreen
self.app.push_screen(
ConfirmScreen("Log out and return to login?"),
callback=self._on_logout_confirm,
)
def _on_leave_confirm(self, confirmed: bool) -> None:
if confirmed:
self.run_worker(self._send("leave_game"))
self.reset_to_pre_room()
else:
self.app.pop_screen()
def _on_logout_confirm(self, confirmed: bool) -> None:
if confirmed:
from tui_client.client import GameClient
from tui_client.screens.connect import ConnectScreen
GameClient.clear_session()
self.app.client._token = None
self.app.client._username = None
self.app.switch_screen(ConnectScreen())
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-create":
@ -332,7 +364,7 @@ class LobbyScreen(Screen):
@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
from tui_client.widgets.card import BACK_COLORS, BORDER_COLOR
colors = DECK_PRESETS.get(preset_name, ["red", "blue", "gold"])
# Show unique colors only (e.g. all-red shows one wider swatch)
@ -341,11 +373,40 @@ class LobbyScreen(Screen):
if c not in seen:
seen.append(c)
bc = BORDER_COLOR
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)
hc = BACK_COLORS.get(color_name, BACK_COLORS["red"])
parts.append(
f"[{bc}]┌───┐[/{bc}] "
)
line1 = "".join(parts)
parts2: list[str] = []
for color_name in seen:
hc = BACK_COLORS.get(color_name, BACK_COLORS["red"])
parts2.append(
f"[{bc}]│[/{bc}][{hc}]▓▒▓[/{hc}][{bc}]│[/{bc}] "
)
line2 = "".join(parts2)
parts3: list[str] = []
for color_name in seen:
hc = BACK_COLORS.get(color_name, BACK_COLORS["red"])
parts3.append(
f"[{bc}]│[/{bc}][{hc}]▒▓▒[/{hc}][{bc}]│[/{bc}] "
)
line3 = "".join(parts3)
parts4: list[str] = []
for color_name in seen:
hc = BACK_COLORS.get(color_name, BACK_COLORS["red"])
parts4.append(
f"[{bc}]└───┘[/{bc}] "
)
line4 = "".join(parts4)
return f"{line1}\n{line2}\n{line3}\n{line4}"
def _add_random_cpu(self) -> None:
"""Add a random CPU (server picks the profile)."""
@ -439,7 +500,7 @@ class LobbyScreen(Screen):
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_room_info(f"Room Code: [bold]{self._room_code}[/bold] (You are host)")
self._set_status("Add CPU opponents, then start when ready.")
self._update_visibility()
self._update_keymap()
@ -449,7 +510,7 @@ class LobbyScreen(Screen):
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_room_info(f"Room Code: [bold]{self._room_code}[/bold]")
self._set_status("Waiting for host to start the game.")
self._update_visibility()
self._update_keymap()

View File

@ -29,7 +29,7 @@ class SplashScreen(Screen):
with Horizontal(classes="screen-footer"):
yield Static("", classes="screen-footer-left")
yield Static("\\[esc]\\[esc] quit", classes="screen-footer-right")
yield Static("\\[q] quit", classes="screen-footer-right")
def on_mount(self) -> None:
self.run_worker(self._check_session(), exclusive=True)

View File

@ -24,7 +24,8 @@ ConnectScreen {
max-width: 64;
min-width: 40;
height: auto;
border: thick $primary;
border: thick #f4a460;
background: #0a2a1a;
padding: 1 2;
}
@ -103,7 +104,8 @@ LobbyScreen {
max-width: 72;
min-width: 40;
height: auto;
border: thick $primary;
border: thick #f4a460;
background: #0a2a1a;
padding: 1 2;
}
@ -118,6 +120,8 @@ LobbyScreen {
text-align: center;
height: auto;
margin-bottom: 1;
border: tall #f4a460;
padding: 0 1;
}
/* Pre-room: join/create controls */
@ -201,7 +205,9 @@ LobbyScreen {
#deck-preview {
width: auto;
height: auto;
padding: 1 1 0 1;
text-align: center;
}
.rule-row {
@ -333,7 +339,7 @@ ScoreboardScreen {
}
/* Confirm quit dialog */
ConfirmQuitScreen {
ConfirmScreen, ConfirmQuitScreen {
align: center middle;
background: $surface 80%;
}

View File

@ -57,8 +57,6 @@ def render_player_box(
# Build display name
display_name = name
if is_dealer:
display_name = f"{display_name}"
# Score text
score_val = f"{score}" if score is not None else f"{total_score}"
score_text = f"{score_val}"
@ -103,17 +101,17 @@ def render_player_box(
f"[{bc}]│[/] {line}{' ' * right_pad}[{bc}]│[/]"
)
# Bottom border
if is_knocker:
out_label = " OUT "
left_fill = 1
right_fill = inner - left_fill - len(out_label)
result.append(
f"[{bc}]╰{'' * left_fill}[/]"
f"[bold {bc}]{out_label}[/]"
f"[{bc}]{'' * max(1, right_fill)}╯[/]"
)
else:
result.append(f"[{bc}]╰{'' * inner}╯[/]")
# Bottom border — dealer Ⓓ on left, OUT on right
left_label = "" if is_dealer else ""
right_label = " OUT " if is_knocker else ""
mid_fill = max(1, inner - len(left_label) - len(right_label))
parts = f"[{bc}]╰[/]"
if left_label:
parts += f"[bold {bc}]{left_label}[/]"
parts += f"[{bc}]{'' * mid_fill}[/]"
if right_label:
parts += f"[bold {bc}]{right_label}[/]"
parts += f"[{bc}]╯[/]"
result.append(parts)
return result

View File

@ -19,6 +19,7 @@ class ScoreboardScreen(ModalScreen[str]):
is_host: bool = False,
round_num: int = 1,
total_rounds: int = 1,
finisher_id: str | None = None,
):
super().__init__()
self._scores = scores
@ -27,6 +28,7 @@ class ScoreboardScreen(ModalScreen[str]):
self._is_host = is_host
self._round_num = round_num
self._total_rounds = total_rounds
self._finisher_id = finisher_id
def compose(self) -> ComposeResult:
with Container(id="scoreboard-container"):
@ -43,6 +45,12 @@ class ScoreboardScreen(ModalScreen[str]):
def on_mount(self) -> None:
table = self.query_one("#scoreboard-table", DataTable)
# Find lowest hole score for tagging
if not self._is_game_over and self._scores:
min_score = min(s.get("score", 999) for s in self._scores)
else:
min_score = None
if self._is_game_over:
table.add_columns("Rank", "Player", "Total", "Holes Won")
for i, s in enumerate(self._scores, 1):
@ -53,13 +61,24 @@ class ScoreboardScreen(ModalScreen[str]):
str(s.get("rounds_won", 0)),
)
else:
table.add_columns("Player", "Hole Score", "Total", "Holes Won")
table.add_columns("Player", "Hole Score", "Total", "Holes Won", "")
for s in self._scores:
# Build tags
tags = []
pid = s.get("id")
score = s.get("score", 0)
if pid and pid == self._finisher_id:
tags.append("OUT")
if min_score is not None and score == min_score:
tags.append("")
tag_str = " ".join(tags)
table.add_row(
s.get("name", "?"),
str(s.get("score", 0)),
str(score),
str(s.get("total", 0)),
str(s.get("rounds_won", 0)),
tag_str,
)
def on_button_pressed(self, event: Button.Pressed) -> None: