diff --git a/tui_client/src/tui_client/client.py b/tui_client/src/tui_client/client.py index 10dc6b0..19a2ba8 100644 --- a/tui_client/src/tui_client/client.py +++ b/tui_client/src/tui_client/client.py @@ -65,6 +65,28 @@ class GameClient: self._username = data["user"]["username"] return data + async def register( + self, username: str, password: str, invite_code: str = "", email: str = "" + ) -> dict: + """Register a new account via HTTP and store JWT token.""" + payload: dict = {"username": username, "password": password} + if invite_code: + payload["invite_code"] = invite_code + if email: + payload["email"] = email + async with httpx.AsyncClient(verify=self.use_tls) as http: + resp = await http.post( + f"{self.http_base}/api/auth/register", + json=payload, + ) + if resp.status_code != 200: + detail = resp.json().get("detail", "Registration failed") + raise ConnectionError(detail) + data = resp.json() + self._token = data["token"] + self._username = data["user"]["username"] + return data + async def connect(self) -> None: """Open WebSocket connection to the server.""" self._ws = await websockets.connect(self.ws_url) diff --git a/tui_client/src/tui_client/screens/connect.py b/tui_client/src/tui_client/screens/connect.py index 6160098..e6b4a03 100644 --- a/tui_client/src/tui_client/screens/connect.py +++ b/tui_client/src/tui_client/screens/connect.py @@ -1,70 +1,159 @@ -"""Connection screen: server URL + optional login form.""" +"""Connection screen: login or sign up form.""" from __future__ import annotations from textual.app import ComposeResult -from textual.containers import Container, Horizontal +from textual.containers import Container, Horizontal, Vertical from textual.screen import Screen from textual.widgets import Button, Input, Static +_TITLE = ( + "⛳🏌️ [bold]GolfCards.club[/bold] " + "[bold #aaaaaa]♠[/bold #aaaaaa]" + "[bold #cc0000]♥[/bold #cc0000]" + "[bold #aaaaaa]♣[/bold #aaaaaa]" + "[bold #cc0000]♦[/bold #cc0000]" +) + class ConnectScreen(Screen): - """Initial screen for connecting to the server.""" + """Initial screen for logging in or signing up.""" + + def __init__(self): + super().__init__() + self._mode: str = "login" # "login" or "signup" def compose(self) -> ComposeResult: with Container(id="connect-container"): - yield Static("GolfCards.club", id="connect-title") - yield Static("Login (optional - leave blank to play as guest)") - yield Input(placeholder="Username", id="input-username") - yield Input(placeholder="Password", password=True, id="input-password") - with Horizontal(id="connect-buttons"): - yield Button("Connect as Guest", id="btn-guest", variant="default") - yield Button("Login & Connect", id="btn-login", variant="primary") + yield Static(_TITLE, id="connect-title") + + # Login form + with Vertical(id="login-form"): + yield Static("Log in to play") + yield Input(placeholder="Username", id="input-username") + yield Input(placeholder="Password", password=True, id="input-password") + with Horizontal(id="connect-buttons"): + yield Button("Login", id="btn-login", variant="primary") + yield Button( + "No account? [bold cyan]Sign Up[/bold cyan]", + id="btn-toggle-signup", + variant="default", + ) + + # Signup form + with Vertical(id="signup-form"): + yield Static("Create an account") + yield Input(placeholder="Invite Code", id="input-invite-code") + yield Input(placeholder="Username", id="input-signup-username") + yield Input(placeholder="Email (optional)", id="input-signup-email") + yield Input( + placeholder="Password (min 8 chars)", + password=True, + id="input-signup-password", + ) + with Horizontal(id="signup-buttons"): + yield Button("Sign Up", id="btn-signup", variant="primary") + yield Button( + "Have an account? [bold cyan]Log In[/bold cyan]", + id="btn-toggle-login", + variant="default", + ) + yield Static("", id="connect-status") + def on_mount(self) -> None: + self._update_form_visibility() + + def _update_form_visibility(self) -> None: + try: + self.query_one("#login-form").display = self._mode == "login" + self.query_one("#signup-form").display = self._mode == "signup" + except Exception: + pass + def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "btn-guest": - self._connect(login=False) - elif event.button.id == "btn-login": - self._connect(login=True) + if event.button.id == "btn-login": + self._do_login() + elif event.button.id == "btn-signup": + self._do_signup() + elif event.button.id == "btn-toggle-signup": + self._mode = "signup" + self._set_status("") + self._update_form_visibility() + elif event.button.id == "btn-toggle-login": + self._mode = "login" + self._set_status("") + self._update_form_visibility() + + def key_escape(self) -> None: + """Escape goes back to login if on signup form.""" + if self._mode == "signup": + self._mode = "login" + self._set_status("") + self._update_form_visibility() def on_input_submitted(self, event: Input.Submitted) -> None: - # Enter key in password field triggers login if event.input.id == "input-password": - self._connect(login=True) + self._do_login() + elif event.input.id == "input-signup-password": + self._do_signup() - def _connect(self, login: bool) -> None: - self._set_status("Connecting...") + def _do_login(self) -> None: + self._set_status("Logging in...") self._disable_buttons() - self.run_worker(self._do_connect(login), exclusive=True) + self.run_worker(self._login_flow(), exclusive=True) - async def _do_connect(self, login: bool) -> None: + def _do_signup(self) -> None: + self._set_status("Signing up...") + self._disable_buttons() + self.run_worker(self._signup_flow(), exclusive=True) + + async def _login_flow(self) -> None: client = self.app.client - try: - if login: - username = self.query_one("#input-username", Input).value.strip() - password = self.query_one("#input-password", Input).value - if not username or not password: - self._set_status("Username and password required") - self._enable_buttons() - return - self._set_status("Logging in...") - await client.login(username, password) - self._set_status(f"Logged in as {client.username}") - - self._set_status("Connecting to WebSocket...") - await client.connect() - self._set_status("Connected!") - - # Move to lobby - from tui_client.screens.lobby import LobbyScreen - self.app.push_screen(LobbyScreen()) - + username = self.query_one("#input-username", Input).value.strip() + password = self.query_one("#input-password", Input).value + if not username or not password: + self._set_status("Username and password required") + self._enable_buttons() + return + await client.login(username, password) + self._set_status(f"Logged in as {client.username}") + await self._connect_ws() except Exception as e: - self._set_status(f"Error: {e}") + self._set_status(f"[red]{e}[/red]") self._enable_buttons() + async def _signup_flow(self) -> None: + client = self.app.client + try: + invite = self.query_one("#input-invite-code", Input).value.strip() + username = self.query_one("#input-signup-username", Input).value.strip() + email = self.query_one("#input-signup-email", Input).value.strip() + password = self.query_one("#input-signup-password", Input).value + if not username or not password: + self._set_status("Username and password required") + self._enable_buttons() + return + if len(password) < 8: + self._set_status("Password must be at least 8 characters") + self._enable_buttons() + return + await client.register(username, password, invite_code=invite, email=email) + self._set_status(f"Account created! Welcome, {client.username}") + await self._connect_ws() + except Exception as e: + self._set_status(f"[red]{e}[/red]") + self._enable_buttons() + + async def _connect_ws(self) -> None: + client = self.app.client + self._set_status("Connecting...") + await client.connect() + self._set_status("Connected!") + from tui_client.screens.lobby import LobbyScreen + self.app.push_screen(LobbyScreen()) + def _set_status(self, text: str) -> None: 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 527180c..34d0936 100644 --- a/tui_client/src/tui_client/screens/game.py +++ b/tui_client/src/tui_client/screens/game.py @@ -5,8 +5,8 @@ from __future__ import annotations from textual.app import ComposeResult from textual.containers import Container, Horizontal from textual.events import Resize -from textual.screen import Screen -from textual.widgets import Static +from textual.screen import ModalScreen, Screen +from textual.widgets import Button, Static from tui_client.models import GameState, PlayerData from tui_client.widgets.hand import HandWidget @@ -15,6 +15,130 @@ from tui_client.widgets.scoreboard import ScoreboardScreen from tui_client.widgets.status_bar import StatusBarWidget +class ConfirmQuitScreen(ModalScreen[bool]): + """Modal confirmation for quitting/leaving a game.""" + + BINDINGS = [ + ("y", "confirm", "Yes"), + ("n", "cancel", "No"), + ("escape", "cancel", "Cancel"), + ] + + def __init__(self, message: str) -> None: + super().__init__() + self._message = message + + def compose(self) -> ComposeResult: + with Container(id="confirm-dialog"): + yield Static(self._message, id="confirm-message") + with Horizontal(id="confirm-buttons"): + yield Button("Yes [Y]", id="btn-yes", variant="error") + yield Button("No [N]", id="btn-no", variant="primary") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-yes": + self.dismiss(True) + else: + self.dismiss(False) + + def action_confirm(self) -> None: + self.dismiss(True) + + def action_cancel(self) -> None: + self.dismiss(False) + + +_HELP_TEXT = """\ +[bold]Keyboard Commands[/bold] + +[bold]Drawing[/bold] + \\[d] Draw from deck + \\[s] Pick from discard pile + +[bold]Card Actions[/bold] + \\[1]-\\[6] Select card position + (flip, swap, or initial flip) + \\[x] Discard held card + \\[c] Cancel draw (from discard) + +[bold]Special Actions[/bold] + \\[f] Flip a card (when enabled) + \\[p] Skip optional flip + \\[k] Knock early (when enabled) + +[bold]Game Flow[/bold] + \\[n] Next hole + \\[tab] Standings + \\[q] Quit / leave game + \\[h] This help screen + +[dim]Press any key or click to close[/dim]\ +""" + + +class StandingsScreen(ModalScreen): + """Modal overlay showing current game standings.""" + + BINDINGS = [ + ("escape", "close", "Close"), + ("tab", "close", "Close"), + ] + + def __init__(self, players: list, current_round: int, total_rounds: int) -> None: + super().__init__() + self._players = players + self._current_round = current_round + self._total_rounds = total_rounds + + def compose(self) -> ComposeResult: + with Container(id="standings-dialog"): + yield Static( + f"[bold]Standings — Hole {self._current_round}/{self._total_rounds}[/bold]", + id="standings-title", + ) + yield Static(self._build_table(), id="standings-body") + yield Static("[dim]Press any key to close[/dim]", id="standings-hint") + + def _build_table(self) -> str: + sorted_players = sorted(self._players, key=lambda p: p.total_score) + lines = [] + for i, p in enumerate(sorted_players, 1): + score_str = f"{p.total_score:>4}" + lines.append(f" {i}. {p.name:<16} {score_str}") + return "\n".join(lines) + + def on_key(self, event) -> None: + self.dismiss() + + def on_click(self, event) -> None: + self.dismiss() + + def action_close(self) -> None: + self.dismiss() + + +class HelpScreen(ModalScreen): + """Modal help overlay showing all keyboard commands.""" + + BINDINGS = [ + ("escape", "close", "Close"), + ("h", "close", "Close"), + ] + + def compose(self) -> ComposeResult: + with Container(id="help-dialog"): + yield Static(_HELP_TEXT, id="help-text") + + def on_key(self, event) -> None: + self.dismiss() + + def on_click(self, event) -> None: + self.dismiss() + + def action_close(self) -> None: + self.dismiss() + + class GameScreen(Screen): """Main game board with card display and keyboard controls.""" @@ -33,6 +157,9 @@ class GameScreen(Screen): ("p", "skip_flip", "Skip flip"), ("k", "knock_early", "Knock early"), ("n", "next_round", "Next round"), + ("q", "quit_game", "Quit game"), + ("h", "show_help", "Help"), + ("tab", "show_standings", "Standings"), ] def __init__(self, initial_state: dict, is_host: bool = False): @@ -55,7 +182,10 @@ class GameScreen(Screen): yield PlayAreaWidget(id="play-area") yield Static("", id="local-hand-label") yield HandWidget(id="local-hand") - yield Static("", id="action-bar") + with Horizontal(id="game-footer"): + yield Static("\\[h]elp \\[q]uit", id="footer-left") + yield Static("", id="footer-center") + yield Static("\\[tab] standings", id="footer-right") def on_mount(self) -> None: self._player_id = self.app.player_id or "" @@ -97,12 +227,12 @@ class GameScreen(Screen): if source == "discard": self._set_action( - f"Holding {rank}{suit} — Choose spot \[1] thru \[6] or \[c]ancel" + f"Holding {rank}{suit} — Choose spot \\[1] thru \\[6] or \\[c]ancel", active=True ) self._set_keymap("[1-6] Swap [C] Cancel") else: self._set_action( - f"Holding {rank}{suit} — Choose spot \[1] thru \[6] or \[x] to discard" + f"Holding {rank}{suit} — Choose spot \\[1] thru \\[6] or \\[x] to discard", active=True ) self._set_keymap("[1-6] Swap [X] Discard") @@ -111,10 +241,10 @@ class GameScreen(Screen): optional = data.get("optional", False) self._can_flip_optional = optional if optional: - self._set_action("Keyboard: Flip a card \[1] thru \[6] or \[p] to skip") + self._set_action("Flip a card \\[1] thru \\[6] or \\[p] to skip", active=True) self._set_keymap("[1-6] Flip card [P] Skip") else: - self._set_action("Keyboard: Flip a face-down card \[1] thru \[6]") + self._set_action("Flip a face-down card \\[1] thru \\[6]", active=True) self._set_keymap("[1-6] Flip card") def _handle_round_over(self, data: dict) -> None: @@ -125,7 +255,7 @@ class GameScreen(Screen): self.app.push_screen( ScoreboardScreen( scores=scores, - title=f"Round {round_num} Complete", + title=f"Hole {round_num} Complete", is_game_over=False, is_host=self._is_host, round_num=round_num, @@ -172,6 +302,10 @@ class GameScreen(Screen): elif result == "lobby": self.run_worker(self._send("leave_game")) self.app.pop_screen() + # Reset lobby back to create/join state + lobby = self.app.screen + if hasattr(lobby, "reset_to_pre_room"): + lobby.reset_to_pre_room() # ------------------------------------------------------------------ # Click handlers (from widget messages) @@ -256,9 +390,10 @@ class GameScreen(Screen): hand.update_player( me, deck_colors=self._state.deck_colors, - is_current_turn=(me.id == self._state.current_player_id), - is_knocker=(me.id == self._state.finisher_id and self._state.phase == "final_turn"), + is_current_turn=False, + is_knocker=False, is_dealer=(me.id == self._state.dealer_id), + highlight=True, ) needed = self._state.initial_flips @@ -272,7 +407,7 @@ class GameScreen(Screen): self._initial_flip_positions = [] else: self._set_action( - f"Keyboard: Choose {needed - selected} more card(s) to flip ({selected}/{needed})" + f"Choose {needed - selected} more card(s) to flip ({selected}/{needed})", active=True ) def _do_flip(self, pos: int) -> None: @@ -299,7 +434,7 @@ class GameScreen(Screen): def action_flip_mode(self) -> None: if self._state.flip_as_action and self._is_my_turn() and not self._state.has_drawn_card: self._awaiting_flip = True - self._set_action("Flip mode: select a face-down card [1-6]") + self._set_action("Flip mode: select a face-down card [1-6]", active=True) def action_skip_flip(self) -> None: if self._awaiting_flip and self._can_flip_optional: @@ -317,9 +452,34 @@ class GameScreen(Screen): if self._is_host and self._state.phase == "round_over": self.run_worker(self._send("next_round")) + def action_show_help(self) -> None: + self.app.push_screen(HelpScreen()) + + def action_show_standings(self) -> None: + self.app.push_screen(StandingsScreen( + self._state.players, + self._state.current_round, + self._state.total_rounds, + )) + + def action_quit_game(self) -> None: + if self._is_host: + msg = "End the game for everyone?" + else: + msg = "Leave this game?" + self.app.push_screen(ConfirmQuitScreen(msg), callback=self._on_quit_confirm) + + def _on_quit_confirm(self, confirmed: bool) -> None: + if confirmed: + self.action_leave_game() + def action_leave_game(self) -> None: self.run_worker(self._send("leave_game")) self.app.pop_screen() + # Reset lobby back to create/join state + lobby = self.app.screen + if hasattr(lobby, "reset_to_pre_room"): + lobby.reset_to_pre_room() # ------------------------------------------------------------------ # Helpers @@ -340,9 +500,21 @@ class GameScreen(Screen): except Exception as e: self._set_action(f"[red]Send error: {e}[/red]") - def _set_action(self, text: str) -> None: + def _set_action(self, text: str, active: bool = False) -> None: + import re try: - self.query_one("#action-bar", Static).update(text) + if active: + # Highlight bracketed keys and parenthesized counts in amber, + # rest in bold white + ac = "#ffaa00" + # Color \\[...] key hints and (...) counts + text = re.sub( + r"(\\?\[.*?\]|\([\d/]+\))", + rf"[bold {ac}]\1[/]", + text, + ) + text = f"[bold white]{text}[/]" + self.query_one("#footer-center", Static).update(text) except Exception: pass @@ -361,6 +533,8 @@ class GameScreen(Screen): # Play area play_area = self.query_one("#play-area", PlayAreaWidget) play_area.update_state(state, local_player_id=self._player_id) + is_active = self._is_my_turn() and not state.waiting_for_initial_flip + play_area.set_class(is_active, "my-turn") # Local player hand (in bordered box with turn/knocker indicators) me = self._get_local_player() @@ -368,12 +542,15 @@ class GameScreen(Screen): self.query_one("#local-hand-label", Static).update("") hand = self.query_one("#local-hand", HandWidget) hand._is_local = True + # During initial flip, don't show current_turn borders (no one is "taking a turn") + show_turn = not state.waiting_for_initial_flip and me.id == state.current_player_id hand.update_player( me, deck_colors=state.deck_colors, - is_current_turn=(me.id == state.current_player_id), + is_current_turn=show_turn, is_knocker=(me.id == state.finisher_id and state.phase == "final_turn"), is_dealer=(me.id == state.dealer_id), + highlight=state.waiting_for_initial_flip, ) else: self.query_one("#local-hand-label", Static).update("") @@ -413,12 +590,13 @@ class GameScreen(Screen): cards, deck_colors=deck_colors, matched=matched, ) + opp_turn = not state.waiting_for_initial_flip and opp.id == state.current_player_id box = render_player_box( opp.name, score=opp.score, total_score=opp.total_score, content_lines=card_lines, - is_current_turn=(opp.id == state.current_player_id), + is_current_turn=opp_turn, is_knocker=(opp.id == state.finisher_id and state.phase == "final_turn"), is_dealer=(opp.id == state.dealer_id), all_face_up=opp.all_face_up, @@ -426,17 +604,22 @@ class GameScreen(Screen): opp_blocks.append(box) # Determine how many opponents fit per row - # Each box is ~21-24 chars wide - opp_width = 22 - if width < 80: - per_row = 1 - gap = " " - elif width < 120: - gap = " " - per_row = max(1, (width - 4) // (opp_width + len(gap))) - else: - gap = " " - per_row = max(1, (min(width, 120) - 4) // (opp_width + len(gap))) + # Each box is ~21-24 chars wide; use actual widths for accuracy + box_widths = [_visible_len(b[0]) if b else 22 for b in opp_blocks] + gap = " " if width < 120 else " " + gap_len = len(gap) + + # Greedily fit as many as possible in one row + per_row = 0 + row_width = 0 + for bw in box_widths: + needed_width = bw if per_row == 0 else gap_len + bw + if row_width + needed_width <= width: + row_width += needed_width + per_row += 1 + else: + break + per_row = max(1, per_row) # Render in rows of per_row opponents all_row_lines: list[str] = [] @@ -469,15 +652,15 @@ class GameScreen(Screen): state = self._state if state.phase in ("round_over", "game_over"): - self._set_action("Keyboard: \[n]ext round") - self._set_keymap("[N] Next round") + self._set_action("\\[n]ext hole", active=True) + self._set_keymap("[N] Next hole") return if state.waiting_for_initial_flip: needed = state.initial_flips selected = len(self._initial_flip_positions) self._set_action( - f"Keyboard: Choose {needed} cards \[1] thru \[6] to flip ({selected}/{needed})" + f"Choose {needed} cards \\[1] thru \\[6] to flip ({selected}/{needed})", active=True ) self._set_keymap("[1-6] Select card") return @@ -496,23 +679,23 @@ class GameScreen(Screen): if state.has_drawn_card: keys = ["[1-6] Swap"] if state.can_discard: - self._set_action("Keyboard: Choose spot \[1] thru \[6] or \[x] to discard") + self._set_action("Choose spot \\[1] thru \\[6] or \\[x] to discard", active=True) keys.append("[X] Discard") else: - self._set_action("Keyboard: Choose spot \[1] thru \[6] or \[c]ancel") + self._set_action("Choose spot \\[1] thru \\[6] or \\[c]ancel", active=True) keys.append("[C] Cancel") self._set_keymap(" ".join(keys)) return - parts = ["Choose \[d]eck or di\[s]card pile"] + parts = ["Choose \\[d]eck or di\\[s]card pile"] keys = ["[D] Draw", "[S] Pick discard"] if state.flip_as_action: - parts.append("\[f]lip a card") + parts.append("\\[f]lip a card") keys.append("[F] Flip") if state.knock_early: - parts.append("\[k]nock early") + parts.append("\\[k]nock early") keys.append("[K] Knock") - self._set_action("Keyboard: " + " or ".join(parts)) + self._set_action(" or ".join(parts), active=True) self._set_keymap(" ".join(keys)) def _set_keymap(self, text: str) -> None: diff --git a/tui_client/src/tui_client/screens/lobby.py b/tui_client/src/tui_client/screens/lobby.py index 5f5f067..dc57424 100644 --- a/tui_client/src/tui_client/screens/lobby.py +++ b/tui_client/src/tui_client/screens/lobby.py @@ -83,7 +83,7 @@ class LobbyScreen(Screen): with Vertical(id="host-settings"): with Collapsible(title="Game Settings", collapsed=True, id="coll-game"): with Horizontal(classes="setting-row"): - yield Label("Rounds") + yield Label("Holes") yield Select( [(str(v), v) for v in (1, 3, 9, 18)], value=9, @@ -200,6 +200,23 @@ class LobbyScreen(Screen): self._update_visibility() self._update_keymap() + def reset_to_pre_room(self) -> None: + """Reset lobby back to create/join state after leaving a game.""" + self._room_code = None + self._player_id = None + self._is_host = False + self._players = [] + self._in_room = False + self._set_room_info("") + self._set_status("") + try: + self.query_one("#input-room-code", Input).value = "" + self.query_one("#player-list", Static).update("") + except Exception: + pass + self._update_visibility() + self._update_keymap() + def _update_visibility(self) -> None: try: self.query_one("#pre-room").display = not self._in_room diff --git a/tui_client/src/tui_client/styles.tcss b/tui_client/src/tui_client/styles.tcss index 65f7716..80e36ee 100644 --- a/tui_client/src/tui_client/styles.tcss +++ b/tui_client/src/tui_client/styles.tcss @@ -32,16 +32,32 @@ ConnectScreen { margin-bottom: 1; } -#connect-buttons { +#login-form, #signup-form { + height: auto; +} + +#signup-form { + display: none; +} + +#connect-buttons, #signup-buttons { height: 3; align: center middle; margin-top: 1; } -#connect-buttons Button { +#connect-buttons Button, #signup-buttons Button { margin: 0 1; } +#btn-toggle-signup, #btn-toggle-login { + width: 100%; + margin-top: 1; + background: transparent; + border: none; + color: $text-muted; +} + #connect-status { text-align: center; color: $warning; @@ -234,17 +250,11 @@ GameScreen { content-align: center middle; } -#action-bar { - height: auto; - min-height: 1; - max-height: 3; - dock: bottom; - background: $surface-darken-1; - padding: 0 2; - text-align: center; - content-align: center middle; +#play-area.my-turn { + border: round #ffd700; } + /* Local hand label */ #local-hand-label { text-align: center; @@ -292,3 +302,107 @@ GameScreen { align: center middle; margin-top: 1; } + +/* Confirm quit dialog */ +ConfirmQuitScreen { + align: center middle; + background: $surface 80%; +} + +#confirm-dialog { + width: auto; + max-width: 48; + height: auto; + border: thick $error; + padding: 1 2; + background: $surface; +} + +#confirm-message { + text-align: center; + width: 100%; + margin-bottom: 1; +} + +#confirm-buttons { + height: 3; + align: center middle; +} + +#confirm-buttons Button { + margin: 0 1; +} + +/* Game footer: [h]elp [tab] standings [q]uit */ +#game-footer { + height: 1; + dock: bottom; + background: $surface-darken-1; + padding: 0 2; +} + +#footer-left { + width: auto; + color: $text-muted; +} + +#footer-center { + width: 1fr; + text-align: center; + content-align: center middle; +} + +#footer-right { + width: auto; + color: $text-muted; +} + +/* Help dialog */ +HelpScreen { + align: center middle; + background: $surface 80%; +} + +#help-dialog { + width: auto; + max-width: 48; + height: auto; + border: thick $primary; + padding: 1 2; + background: $surface; +} + +#help-text { + width: 100%; +} + +/* Standings dialog */ +StandingsScreen { + align: center middle; + background: $surface 80%; +} + +#standings-dialog { + width: auto; + max-width: 48; + height: auto; + border: thick $primary; + padding: 1 2; + background: $surface; +} + +#standings-title { + text-align: center; + width: 100%; + margin-bottom: 1; +} + +#standings-body { + width: 100%; +} + +#standings-hint { + text-align: center; + width: 100%; + margin-top: 1; +} diff --git a/tui_client/src/tui_client/widgets/card.py b/tui_client/src/tui_client/widgets/card.py index 71a0f3b..e4e48e5 100644 --- a/tui_client/src/tui_client/widgets/card.py +++ b/tui_client/src/tui_client/widgets/card.py @@ -30,6 +30,7 @@ JOKER_COLOR = "#9b59b6" # purple BORDER_COLOR = "#888888" # card border EMPTY_COLOR = "#555555" # empty card slot POSITION_COLOR = "#f0e68c" # pale yellow — distinct from suits and card backs +HIGHLIGHT_COLOR = "#ffaa00" # bright amber — initial flip / attention def _back_color_for_card(card: CardData, deck_colors: list[str] | None = None) -> str: @@ -41,9 +42,12 @@ def _back_color_for_card(card: CardData, deck_colors: list[str] | None = None) - return BACK_COLORS.get(name, BACK_COLORS["red"]) -def _top_border(position: int | None, d: str, color: str) -> str: +def _top_border(position: int | None, d: str, color: str, highlight: bool = False) -> str: """Top border line, with position number replacing ┌ when present.""" if position is not None: + if highlight: + hc = HIGHLIGHT_COLOR + return f"[bold {hc}]{position}[/][{d}{color}]───┐[/{d}{color}]" return f"[{d}{color}]{position}───┐[/{d}{color}]" return f"[{d}{color}]┌───┐[/{d}{color}]" @@ -54,6 +58,7 @@ def render_card( position: int | None = None, deck_colors: list[str] | None = None, dim: bool = False, + highlight: bool = False, ) -> str: """Render a card as a 4-line Rich-markup string. @@ -64,7 +69,7 @@ def render_card( └───┘ └───┘ └───┘ """ d = "dim " if dim else "" - bc = BORDER_COLOR + bc = HIGHLIGHT_COLOR if highlight else BORDER_COLOR # Empty slot if card is None: @@ -76,7 +81,7 @@ def render_card( f"[{d}{c}]└───┘[/{d}{c}]" ) - top = _top_border(position, d, bc) + top = _top_border(position, d, bc, highlight=highlight) # Face-down card with colored back if not card.face_up: diff --git a/tui_client/src/tui_client/widgets/hand.py b/tui_client/src/tui_client/widgets/hand.py index 9e00546..de39ab5 100644 --- a/tui_client/src/tui_client/widgets/hand.py +++ b/tui_client/src/tui_client/widgets/hand.py @@ -69,6 +69,7 @@ def _render_card_lines( is_local: bool = False, deck_colors: list[str] | None = None, matched: list[bool] | None = None, + highlight: bool = False, ) -> list[str]: """Render the 2x3 card grid as a list of text lines (no box). @@ -94,6 +95,7 @@ def _render_card_lines( position=pos, deck_colors=deck_colors, dim=matched[idx], + highlight=highlight, ) card_lines = text.split("\n") while len(row_line_parts) < len(card_lines): @@ -131,6 +133,8 @@ class HandWidget(Static): self._is_knocker: bool = False self._is_dealer: bool = False self._has_connector: bool = False + self._highlight: bool = False + self._box_width: int = 0 def update_player( self, @@ -140,6 +144,7 @@ class HandWidget(Static): is_current_turn: bool = False, is_knocker: bool = False, is_dealer: bool = False, + highlight: bool = False, ) -> None: self._player = player if deck_colors is not None: @@ -147,6 +152,7 @@ class HandWidget(Static): self._is_current_turn = is_current_turn self._is_knocker = is_knocker self._is_dealer = is_dealer + self._highlight = highlight self._refresh() def on_mount(self) -> None: @@ -154,9 +160,14 @@ class HandWidget(Static): def on_click(self, event: Click) -> None: """Map click coordinates to card position (0-5).""" - if not self._is_local: + if not self._is_local or not self._box_width: return + # The content is centered in the widget — compute the x offset + x_offset = max(0, (self.size.width - self._box_width) // 2) + x = event.x - x_offset + y = event.y + # Box layout: # Line 0: top border # Lines 1-4: row 0 cards (4 lines each) @@ -166,8 +177,6 @@ class HandWidget(Static): # # Content x: │ then cards at x offsets 2, 8, 14 (each 5 wide, 1 gap) - x, y = event.x, event.y - # Determine column from x (content starts at x=2 inside box) # Card 0: x 2-6, Card 1: x 8-12, Card 2: x 14-18 col = -1 @@ -202,7 +211,7 @@ class HandWidget(Static): self.update("") return - from tui_client.widgets.player_box import render_player_box + from tui_client.widgets.player_box import _visible_len, render_player_box cards = self._player.cards matched = _check_column_match(cards) @@ -213,6 +222,7 @@ class HandWidget(Static): is_local=self._is_local, deck_colors=self._deck_colors, matched=matched, + highlight=self._highlight, ) box_lines = render_player_box( @@ -227,4 +237,8 @@ class HandWidget(Static): all_face_up=self._player.all_face_up, ) + # Store box width for click coordinate mapping + if box_lines: + self._box_width = _visible_len(box_lines[0]) + self.update("\n".join(box_lines)) diff --git a/tui_client/src/tui_client/widgets/play_area.py b/tui_client/src/tui_client/widgets/play_area.py index 141f9bd..8febd2e 100644 --- a/tui_client/src/tui_client/widgets/play_area.py +++ b/tui_client/src/tui_client/widgets/play_area.py @@ -58,13 +58,15 @@ class PlayAreaWidget(Static): def on_click(self, event: Click) -> None: """Map click to deck or discard column.""" - x = event.x - # Layout: DECK (col 0..11) [HOLDING (col 12..23)] DISCARD (last col) - if x < _COL_WIDTH: + # Content is always 3 columns wide; account for centering within widget + content_width = 3 * _COL_WIDTH + x_offset = max(0, (self.content_size.width - content_width) // 2) + x = event.x - x_offset + + # Layout: DECK (col 0..11) | HOLDING (col 12..23) | DISCARD (col 24..35) + if 0 <= x < _COL_WIDTH: self.post_message(self.DeckClicked()) - elif self._has_holding and x >= 2 * _COL_WIDTH: - self.post_message(self.DiscardClicked()) - elif not self._has_holding and x >= _COL_WIDTH: + elif 2 * _COL_WIDTH <= x < 3 * _COL_WIDTH: self.post_message(self.DiscardClicked()) def _refresh(self) -> None: @@ -114,7 +116,7 @@ class PlayAreaWidget(Static): lines.append(row) # Labels row — always 3 columns - deck_label = f"DECK:{state.deck_remaining}" + deck_label = f"DECK [dim]{state.deck_remaining}[/dim]" discard_label = "DISCARD" label = _pad_center(deck_label, _COL_WIDTH) if held_lines: diff --git a/tui_client/src/tui_client/widgets/player_box.py b/tui_client/src/tui_client/widgets/player_box.py index 4d5856c..d1d8f13 100644 --- a/tui_client/src/tui_client/widgets/player_box.py +++ b/tui_client/src/tui_client/widgets/player_box.py @@ -9,7 +9,6 @@ _BORDER_NORMAL = "#555555" _BORDER_TURN_LOCAL = "#9ab973" # green — your turn _BORDER_TURN_OPPONENT = "#f4a460" # sandy orange — opponent's turn _BORDER_KNOCKER = "#ff6b35" # red-orange — went out - _NAME_COLOR = "#e0e0e0" diff --git a/tui_client/src/tui_client/widgets/scoreboard.py b/tui_client/src/tui_client/widgets/scoreboard.py index 2a22c59..b8e1726 100644 --- a/tui_client/src/tui_client/widgets/scoreboard.py +++ b/tui_client/src/tui_client/widgets/scoreboard.py @@ -14,7 +14,7 @@ class ScoreboardScreen(ModalScreen[str]): def __init__( self, scores: list[dict], - title: str = "Round Over", + title: str = "Hole Over", is_game_over: bool = False, is_host: bool = False, round_num: int = 1, @@ -44,7 +44,7 @@ class ScoreboardScreen(ModalScreen[str]): table = self.query_one("#scoreboard-table", DataTable) if self._is_game_over: - table.add_columns("Rank", "Player", "Total", "Rounds Won") + table.add_columns("Rank", "Player", "Total", "Holes Won") for i, s in enumerate(self._scores, 1): table.add_row( str(i), @@ -53,7 +53,7 @@ class ScoreboardScreen(ModalScreen[str]): str(s.get("rounds_won", 0)), ) else: - table.add_columns("Player", "Round Score", "Total", "Rounds Won") + table.add_columns("Player", "Hole Score", "Total", "Holes Won") for s in self._scores: table.add_row( s.get("name", "?"), diff --git a/tui_client/src/tui_client/widgets/status_bar.py b/tui_client/src/tui_client/widgets/status_bar.py index 3ed59a1..528e885 100644 --- a/tui_client/src/tui_client/widgets/status_bar.py +++ b/tui_client/src/tui_client/widgets/status_bar.py @@ -34,15 +34,15 @@ class StatusBarWidget(Static): parts = [] # Round info - parts.append(f"Round {state.current_round}/{state.total_rounds}") + parts.append(f"⛳ {state.current_round}/{state.total_rounds}") # Phase phase_display = { "waiting": "Waiting", "initial_flip": "[bold white on #6a0dad] Flip Phase [/bold white on #6a0dad]", - "playing": "Playing", + "playing": "", "final_turn": "[bold white on #c62828] FINAL TURN [/bold white on #c62828]", - "round_over": "[white on #555555] Round Over [/white on #555555]", + "round_over": "[white on #555555] Hole Over [/white on #555555]", "game_over": "[bold white on #b8860b] Game Over [/bold white on #b8860b]", }.get(state.phase, state.phase) parts.append(phase_display) @@ -50,7 +50,7 @@ class StatusBarWidget(Static): # Turn info (skip during initial flip - it's misleading) if state.current_player_id and state.players and state.phase != "initial_flip": if state.current_player_id == self._player_id: - parts.append("[bold white on #2e7d32] YOUR TURN [/bold white on #2e7d32]") + parts.append("[bold #111111 on #ffd700] YOUR TURN! [/bold #111111 on #ffd700]") else: for p in state.players: if p.id == state.current_player_id: @@ -68,7 +68,7 @@ class StatusBarWidget(Static): if state.active_rules: parts.append(f"Rules: {', '.join(state.active_rules)}") - text = " │ ".join(parts) + text = " │ ".join(p for p in parts if p) if self._extra: text += f" {self._extra}"