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:
parent
b1d3aa7b77
commit
dfb3397dcb
@ -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": {
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
41
tui_client/src/tui_client/screens/confirm.py
Normal file
41
tui_client/src/tui_client/screens/confirm.py
Normal 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)
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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%;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user