diff --git a/server/main.py b/server/main.py index e855281..9358bf0 100644 --- a/server/main.py +++ b/server/main.py @@ -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": { diff --git a/tui_client/src/tui_client/app.py b/tui_client/src/tui_client/app.py index 503c1e0..584cb4a 100644 --- a/tui_client/src/tui_client/app.py +++ b/tui_client/src/tui_client/app.py @@ -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: diff --git a/tui_client/src/tui_client/screens/confirm.py b/tui_client/src/tui_client/screens/confirm.py new file mode 100644 index 0000000..4ccc1e4 --- /dev/null +++ b/tui_client/src/tui_client/screens/confirm.py @@ -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) diff --git a/tui_client/src/tui_client/screens/connect.py b/tui_client/src/tui_client/screens/connect.py index ae8838d..101cd89 100644 --- a/tui_client/src/tui_client/screens/connect.py +++ b/tui_client/src/tui_client/screens/connect.py @@ -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) diff --git a/tui_client/src/tui_client/screens/game.py b/tui_client/src/tui_client/screens/game.py index 2ea8412..113e662 100644 --- a/tui_client/src/tui_client/screens/game.py +++ b/tui_client/src/tui_client/screens/game.py @@ -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: diff --git a/tui_client/src/tui_client/screens/lobby.py b/tui_client/src/tui_client/screens/lobby.py index 3915aa0..0536fed 100644 --- a/tui_client/src/tui_client/screens/lobby.py +++ b/tui_client/src/tui_client/screens/lobby.py @@ -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() diff --git a/tui_client/src/tui_client/screens/splash.py b/tui_client/src/tui_client/screens/splash.py index 11bad3f..4650e06 100644 --- a/tui_client/src/tui_client/screens/splash.py +++ b/tui_client/src/tui_client/screens/splash.py @@ -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) diff --git a/tui_client/src/tui_client/styles.tcss b/tui_client/src/tui_client/styles.tcss index b44ad97..b516780 100644 --- a/tui_client/src/tui_client/styles.tcss +++ b/tui_client/src/tui_client/styles.tcss @@ -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%; } diff --git a/tui_client/src/tui_client/widgets/player_box.py b/tui_client/src/tui_client/widgets/player_box.py index ed109ab..22511f4 100644 --- a/tui_client/src/tui_client/widgets/player_box.py +++ b/tui_client/src/tui_client/widgets/player_box.py @@ -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 diff --git a/tui_client/src/tui_client/widgets/scoreboard.py b/tui_client/src/tui_client/widgets/scoreboard.py index b8e1726..7d8e50b 100644 --- a/tui_client/src/tui_client/widgets/scoreboard.py +++ b/tui_client/src/tui_client/widgets/scoreboard.py @@ -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: