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 # Check for round over
if room.game.phase == GamePhase.ROUND_OVER: if room.game.phase == GamePhase.ROUND_OVER:
scores = [ 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 for p in room.game.players
] ]
# Build rankings # Build rankings
@ -765,6 +765,7 @@ async def broadcast_game_state(room: Room):
await player.websocket.send_json({ await player.websocket.send_json({
"type": "round_over", "type": "round_over",
"scores": scores, "scores": scores,
"finisher_id": room.game.finisher_id,
"round": room.game.current_round, "round": room.game.current_round,
"total_rounds": room.game.num_rounds, "total_rounds": room.game.num_rounds,
"rankings": { "rankings": {

View File

@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
import time
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.message import Message from textual.message import Message
from textual.widgets import Static from textual.widgets import Static
@ -42,6 +40,7 @@ class GolfApp(App):
BINDINGS = [ BINDINGS = [
("escape", "esc_pressed", ""), ("escape", "esc_pressed", ""),
("q", "quit_app", ""),
] ]
def __init__(self, server: str, use_tls: bool = True): def __init__(self, server: str, use_tls: bool = True):
@ -49,7 +48,6 @@ class GolfApp(App):
self.client = GameClient(server, use_tls) self.client = GameClient(server, use_tls)
self.client._app = self self.client._app = self
self.player_id: str | None = None self.player_id: str | None = None
self._last_escape: float = 0.0
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield KeymapBar(id="keymap-bar") yield KeymapBar(id="keymap-bar")
@ -75,16 +73,34 @@ class GolfApp(App):
handler(msg) handler(msg)
def action_esc_pressed(self) -> None: def action_esc_pressed(self) -> None:
"""Single escape goes back, double-escape quits.""" """Escape goes back — delegated to the active screen."""
now = time.monotonic() handler = getattr(self.screen, "handle_escape", None)
if now - self._last_escape < 0.5: 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() self.exit()
else: else:
self._last_escape = now from tui_client.screens.confirm import ConfirmScreen
# Let the active screen handle single escape self.push_screen(
handler = getattr(self.screen, "handle_escape", None) ConfirmScreen("Quit GolfCards?"),
if handler: callback=self._on_quit_confirm,
handler() )
def _on_quit_confirm(self, confirmed: bool) -> None:
if confirmed:
self.exit()
def _update_keymap(self) -> None: def _update_keymap(self) -> None:
"""Update the keymap bar based on current screen.""" """Update the keymap bar based on current screen."""
@ -93,23 +109,18 @@ class GolfApp(App):
if keymap: if keymap:
text = keymap text = keymap
elif screen_name == "ConnectScreen": elif screen_name == "ConnectScreen":
text = "[Tab] Navigate [Enter] Submit [Esc][Esc] Quit" text = "[Tab] Navigate [Enter] Submit [q] Quit"
elif screen_name == "LobbyScreen": 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: else:
text = "[Esc][Esc] Quit" text = "[q] Quit"
try: try:
self.query_one("#keymap-bar", KeymapBar).update(text) self.query_one("#keymap-bar", KeymapBar).update(text)
except Exception: except Exception:
pass pass
def set_keymap(self, text: str) -> None: def set_keymap(self, text: str) -> None:
"""Allow screens to update the keymap bar dynamically. """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"
try: try:
self.query_one("#keymap-bar", KeymapBar).update(text) self.query_one("#keymap-bar", KeymapBar).update(text)
except Exception: 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 = ( _TITLE = (
"⛳🏌️ [bold]GolfCards.club[/bold] " "⛳🏌️ [bold]GolfCards.club[/bold] "
"[bold #aaaaaa]♠[/bold #aaaaaa]" "[bold #aaaaaa]♠[/bold #aaaaaa]"
"[bold #cc0000]♥[/bold #cc0000]" "[bold #cc0000]♥[/bold #cc0000]"
"[bold #aaaaaa]♣[/bold #aaaaaa]" "[bold #aaaaaa]♣[/bold #aaaaaa]"
@ -64,7 +64,7 @@ class ConnectScreen(Screen):
with Horizontal(classes="screen-footer"): with Horizontal(classes="screen-footer"):
yield Static("", id="connect-footer-left", classes="screen-footer-left") 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: def on_mount(self) -> None:
self._update_form_visibility() self._update_form_visibility()
@ -170,7 +170,7 @@ class ConnectScreen(Screen):
client.save_session() client.save_session()
self._set_status("Connected!") self._set_status("Connected!")
from tui_client.screens.lobby import LobbyScreen from tui_client.screens.lobby import LobbyScreen
self.app.push_screen(LobbyScreen()) self.app.switch_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

@ -9,45 +9,13 @@ from textual.screen import ModalScreen, Screen
from textual.widgets import Button, Static from textual.widgets import Button, Static
from tui_client.models import GameState, PlayerData 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.hand import HandWidget
from tui_client.widgets.play_area import PlayAreaWidget from tui_client.widgets.play_area import PlayAreaWidget
from tui_client.widgets.scoreboard import ScoreboardScreen 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 = """\ _HELP_TEXT = """\
[bold]Keyboard Commands[/bold] [bold]Keyboard Commands[/bold]
@ -72,7 +40,7 @@ _HELP_TEXT = """\
\\[q] Quit / leave game \\[q] Quit / leave game
\\[h] This help screen \\[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", id="standings-title",
) )
yield Static(self._build_table(), id="standings-body") 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: def _build_table(self) -> str:
sorted_players = sorted(self._players, key=lambda p: p.total_score) 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}") lines.append(f" {i}. {p.name:<16} {score_str}")
return "\n".join(lines) 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: def action_close(self) -> None:
self.dismiss() self.dismiss()
@ -129,12 +91,6 @@ class HelpScreen(ModalScreen):
with Container(id="help-dialog"): with Container(id="help-dialog"):
yield Static(_HELP_TEXT, id="help-text") 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: def action_close(self) -> None:
self.dismiss() self.dismiss()
@ -185,9 +141,9 @@ class GameScreen(Screen):
yield Static("", id="local-hand-label") yield Static("", id="local-hand-label")
yield HandWidget(id="local-hand") yield HandWidget(id="local-hand")
with Horizontal(id="game-footer"): 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("", id="footer-center")
yield Static("\\[tab] standings", id="footer-right") yield Static("\\[q]uit", 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 ""
@ -256,6 +212,7 @@ class GameScreen(Screen):
scores = data.get("scores", []) scores = data.get("scores", [])
round_num = data.get("round", 1) round_num = data.get("round", 1)
total_rounds = data.get("total_rounds", 1) total_rounds = data.get("total_rounds", 1)
finisher_id = data.get("finisher_id")
self.app.push_screen( self.app.push_screen(
ScoreboardScreen( ScoreboardScreen(
@ -265,6 +222,7 @@ class GameScreen(Screen):
is_host=self._is_host, is_host=self._is_host,
round_num=round_num, round_num=round_num,
total_rounds=total_rounds, total_rounds=total_rounds,
finisher_id=finisher_id,
), ),
callback=self._on_scoreboard_dismiss, callback=self._on_scoreboard_dismiss,
) )
@ -478,7 +436,7 @@ class GameScreen(Screen):
msg = "End the game for everyone?" msg = "End the game for everyone?"
else: else:
msg = "Leave this game?" 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: def _on_quit_confirm(self, confirmed: bool) -> None:
if confirmed: if confirmed:

View File

@ -54,9 +54,14 @@ class LobbyScreen(Screen):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Container(id="lobby-container"): with Container(id="lobby-container"):
yield Static("[bold]GolfCards.club[/bold]", id="lobby-title") yield Static(
yield Static("", id="room-info") "⛳🏌️ [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 # Pre-room: join/create
with Vertical(id="pre-room"): with Vertical(id="pre-room"):
yield Input(placeholder="Room code (leave blank to create new)", id="input-room-code") 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 # In-room: player list + controls + settings
with Vertical(id="in-room"): with Vertical(id="in-room"):
yield Static("", id="room-info")
yield Static("[bold]Players[/bold]", id="player-list-label") yield Static("[bold]Players[/bold]", id="player-list-label")
yield Static("", id="player-list") yield Static("", id="player-list")
@ -198,7 +204,7 @@ class LobbyScreen(Screen):
with Horizontal(classes="screen-footer"): # Outside lobby-container with Horizontal(classes="screen-footer"): # Outside lobby-container
yield Static("\\[esc] back", id="lobby-footer-left", classes="screen-footer-left") 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: def on_mount(self) -> None:
self._update_visibility() self._update_visibility()
@ -237,9 +243,9 @@ class LobbyScreen(Screen):
try: try:
left = self.query_one("#lobby-footer-left", Static) left = self.query_one("#lobby-footer-left", Static)
if self._in_room: if self._in_room:
left.update("\\[esc] leave") left.update("\\[esc] leave room")
else: else:
left.update("\\[esc] back") left.update("\\[esc] log out")
except Exception: except Exception:
pass pass
@ -247,21 +253,47 @@ class LobbyScreen(Screen):
self._update_footer() self._update_footer()
try: try:
if self._in_room and self._is_host: 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: 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: 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: except Exception:
pass pass
def handle_escape(self) -> None: 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._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.run_worker(self._send("leave_game"))
self.reset_to_pre_room() 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: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-create": if event.button.id == "btn-create":
@ -332,7 +364,7 @@ class LobbyScreen(Screen):
@staticmethod @staticmethod
def _render_deck_preview(preset_name: str) -> str: def _render_deck_preview(preset_name: str) -> str:
"""Render mini card-back swatches for a deck color preset.""" """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"]) colors = DECK_PRESETS.get(preset_name, ["red", "blue", "gold"])
# Show unique colors only (e.g. all-red shows one wider swatch) # Show unique colors only (e.g. all-red shows one wider swatch)
@ -341,11 +373,40 @@ class LobbyScreen(Screen):
if c not in seen: if c not in seen:
seen.append(c) seen.append(c)
bc = BORDER_COLOR
parts: list[str] = [] parts: list[str] = []
for color_name in seen: for color_name in seen:
hex_color = BACK_COLORS.get(color_name, BACK_COLORS["red"]) hc = BACK_COLORS.get(color_name, BACK_COLORS["red"])
parts.append(f"[{hex_color}]░░░[/]") parts.append(
return " ".join(parts) 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: def _add_random_cpu(self) -> None:
"""Add a random CPU (server picks the profile).""" """Add a random CPU (server picks the profile)."""
@ -439,7 +500,7 @@ class LobbyScreen(Screen):
self.app.player_id = self._player_id self.app.player_id = self._player_id
self._is_host = True self._is_host = True
self._in_room = 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._set_status("Add CPU opponents, then start when ready.")
self._update_visibility() self._update_visibility()
self._update_keymap() self._update_keymap()
@ -449,7 +510,7 @@ class LobbyScreen(Screen):
self._player_id = data.get("player_id", "") self._player_id = data.get("player_id", "")
self.app.player_id = self._player_id self.app.player_id = self._player_id
self._in_room = True 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._set_status("Waiting for host to start the game.")
self._update_visibility() self._update_visibility()
self._update_keymap() self._update_keymap()

View File

@ -29,7 +29,7 @@ class SplashScreen(Screen):
with Horizontal(classes="screen-footer"): with Horizontal(classes="screen-footer"):
yield Static("", classes="screen-footer-left") 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: def on_mount(self) -> None:
self.run_worker(self._check_session(), exclusive=True) self.run_worker(self._check_session(), exclusive=True)

View File

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

View File

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

View File

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