From b1d3aa7b774252a674cfea9227176377eb9dee35 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 25 Feb 2026 19:35:03 -0500 Subject: [PATCH] Add session persistence, splash screen, and TUI polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save JWT token to ~/.config/golfcards/session.json after login so subsequent launches skip the login screen when the session is still valid. A new splash screen shows the token check status (SUCCESS / NONE FOUND / EXPIRED) before routing to lobby or login. Also: move OUT indicator to player box bottom border, remove checkmark, center scoreboard overlay, use alternating shade blocks (▓▒▓/▒▓▒) for card backs. Co-Authored-By: Claude Opus 4.6 --- tui_client/src/tui_client/app.py | 4 +- tui_client/src/tui_client/client.py | 60 +++++++++++++++ tui_client/src/tui_client/models.py | 23 ++++++ tui_client/src/tui_client/screens/connect.py | 18 +++++ tui_client/src/tui_client/screens/game.py | 77 ++++++++++++++++--- tui_client/src/tui_client/screens/lobby.py | 30 ++++++++ tui_client/src/tui_client/screens/splash.py | 74 ++++++++++++++++++ tui_client/src/tui_client/styles.tcss | 51 ++++++++++-- tui_client/src/tui_client/widgets/card.py | 43 +++++++---- tui_client/src/tui_client/widgets/hand.py | 68 ++++++---------- .../src/tui_client/widgets/play_area.py | 23 +++--- .../src/tui_client/widgets/player_box.py | 23 +++--- 12 files changed, 396 insertions(+), 98 deletions(-) create mode 100644 tui_client/src/tui_client/screens/splash.py diff --git a/tui_client/src/tui_client/app.py b/tui_client/src/tui_client/app.py index 140b199..503c1e0 100644 --- a/tui_client/src/tui_client/app.py +++ b/tui_client/src/tui_client/app.py @@ -55,8 +55,8 @@ class GolfApp(App): yield KeymapBar(id="keymap-bar") def on_mount(self) -> None: - from tui_client.screens.connect import ConnectScreen - self.push_screen(ConnectScreen()) + from tui_client.screens.splash import SplashScreen + self.push_screen(SplashScreen()) self._update_keymap() def on_screen_resume(self) -> None: diff --git a/tui_client/src/tui_client/client.py b/tui_client/src/tui_client/client.py index 19a2ba8..22a6504 100644 --- a/tui_client/src/tui_client/client.py +++ b/tui_client/src/tui_client/client.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import json import logging +from pathlib import Path from typing import Optional import httpx @@ -13,6 +14,9 @@ from websockets.asyncio.client import ClientConnection logger = logging.getLogger(__name__) +_SESSION_DIR = Path.home() / ".config" / "golfcards" +_SESSION_FILE = _SESSION_DIR / "session.json" + class GameClient: """Handles HTTP auth and WebSocket game communication.""" @@ -47,6 +51,62 @@ class GameClient: def username(self) -> Optional[str]: return self._username + def save_session(self) -> None: + """Persist token and server info to disk.""" + if not self._token: + return + _SESSION_DIR.mkdir(parents=True, exist_ok=True) + data = { + "host": self.host, + "use_tls": self.use_tls, + "token": self._token, + "username": self._username, + } + _SESSION_FILE.write_text(json.dumps(data)) + + @staticmethod + def load_session() -> dict | None: + """Load saved session from disk, or None if not found.""" + if not _SESSION_FILE.exists(): + return None + try: + return json.loads(_SESSION_FILE.read_text()) + except (json.JSONDecodeError, OSError): + return None + + @staticmethod + def clear_session() -> None: + """Delete saved session file.""" + try: + _SESSION_FILE.unlink(missing_ok=True) + except OSError: + pass + + async def verify_token(self) -> bool: + """Check if the current token is still valid via /api/auth/me.""" + if not self._token: + return False + try: + async with httpx.AsyncClient(verify=self.use_tls) as http: + resp = await http.get( + f"{self.http_base}/api/auth/me", + headers={"Authorization": f"Bearer {self._token}"}, + ) + if resp.status_code == 200: + data = resp.json() + self._username = data.get("username", self._username) + return True + return False + except Exception: + return False + + def restore_session(self, session: dict) -> None: + """Restore client state from a saved session dict.""" + self.host = session["host"] + self.use_tls = session["use_tls"] + self._token = session["token"] + self._username = session.get("username") + async def login(self, username: str, password: str) -> dict: """Login via HTTP and store JWT token. diff --git a/tui_client/src/tui_client/models.py b/tui_client/src/tui_client/models.py index 2e5a25b..0dd6dda 100644 --- a/tui_client/src/tui_client/models.py +++ b/tui_client/src/tui_client/models.py @@ -61,6 +61,29 @@ class PlayerData: rounds_won: int = 0 all_face_up: bool = False + # Standard card values for visible score calculation + _CARD_VALUES = { + 'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, + '7': 7, '8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2, + } + + @property + def visible_score(self) -> int: + """Compute score from face-up cards, zeroing matched columns.""" + if len(self.cards) < 6: + return 0 + values = [0] * 6 + for i, c in enumerate(self.cards): + if c.face_up and c.rank: + values[i] = self._CARD_VALUES.get(c.rank, 0) + # Zero out matched columns (same rank, both face-up) + for col in range(3): + top, bot = self.cards[col], self.cards[col + 3] + if top.face_up and bot.face_up and top.rank and top.rank == bot.rank: + values[col] = 0 + values[col + 3] = 0 + return sum(values) + @classmethod def from_dict(cls, d: dict) -> PlayerData: return cls( diff --git a/tui_client/src/tui_client/screens/connect.py b/tui_client/src/tui_client/screens/connect.py index a3cc4a1..ae8838d 100644 --- a/tui_client/src/tui_client/screens/connect.py +++ b/tui_client/src/tui_client/screens/connect.py @@ -7,6 +7,7 @@ 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]" @@ -61,8 +62,13 @@ class ConnectScreen(Screen): yield Static("", id="connect-status") + 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") + def on_mount(self) -> None: self._update_form_visibility() + self._update_footer() def _update_form_visibility(self) -> None: try: @@ -70,6 +76,17 @@ class ConnectScreen(Screen): self.query_one("#signup-form").display = self._mode == "signup" except Exception: pass + self._update_footer() + + def _update_footer(self) -> None: + try: + left = self.query_one("#connect-footer-left", Static) + if self._mode == "signup": + left.update("\\[esc] back") + else: + left.update("") + except Exception: + pass def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "btn-login": @@ -150,6 +167,7 @@ class ConnectScreen(Screen): client = self.app.client self._set_status("Connecting...") await client.connect() + client.save_session() self._set_status("Connected!") from tui_client.screens.lobby import LobbyScreen self.app.push_screen(LobbyScreen()) diff --git a/tui_client/src/tui_client/screens/game.py b/tui_client/src/tui_client/screens/game.py index 34d0936..2ea8412 100644 --- a/tui_client/src/tui_client/screens/game.py +++ b/tui_client/src/tui_client/screens/game.py @@ -172,6 +172,8 @@ class GameScreen(Screen): self._initial_flip_positions: list[int] = [] self._can_flip_optional = False self._term_width: int = 80 + self._swap_flash: dict[str, int] = {} # player_id -> position of last swap + self._discard_flash: bool = False # discard pile just changed self._term_height: int = 24 def compose(self) -> ComposeResult: @@ -210,7 +212,10 @@ class GameScreen(Screen): def _handle_game_state(self, data: dict) -> None: state_data = data.get("game_state", data) - self._state = GameState.from_dict(state_data) + old_state = self._state + new_state = GameState.from_dict(state_data) + self._detect_swaps(old_state, new_state) + self._state = new_state self._full_refresh() def _handle_your_turn(self, data: dict) -> None: @@ -320,8 +325,14 @@ class GameScreen(Screen): self.action_draw_deck() def on_play_area_widget_discard_clicked(self, event: PlayAreaWidget.DiscardClicked) -> None: - """Handle click on the discard pile.""" - self.action_pick_discard() + """Handle click on the discard pile. + + If holding a card, discard it. Otherwise, draw from discard. + """ + if self._state and self._state.has_drawn_card: + self.action_discard_held() + else: + self.action_pick_discard() # ------------------------------------------------------------------ # Keyboard actions @@ -481,6 +492,46 @@ class GameScreen(Screen): if hasattr(lobby, "reset_to_pre_room"): lobby.reset_to_pre_room() + # ------------------------------------------------------------------ + # Swap/discard detection + # ------------------------------------------------------------------ + + def _detect_swaps(self, old: GameState, new: GameState) -> None: + """Compare old and new state to find which card positions changed.""" + if not old or not new or not old.players or not new.players: + return + + old_map = {p.id: p for p in old.players} + for np in new.players: + op = old_map.get(np.id) + if not op: + continue + for i, (oc, nc) in enumerate(zip(op.cards, np.cards)): + # Card changed: was face-down and now face-up with different rank, + # or rank/suit changed + if oc.rank != nc.rank or oc.suit != nc.suit: + if nc.face_up: + self._swap_flash[np.id] = i + break + + # Detect discard change (new discard top differs from old) + if old.discard_top and new.discard_top: + if (old.discard_top.rank != new.discard_top.rank or + old.discard_top.suit != new.discard_top.suit): + self._discard_flash = True + elif not old.discard_top and new.discard_top: + self._discard_flash = True + + # Schedule flash clear after 2 seconds + if self._swap_flash or self._discard_flash: + self.set_timer(1.0, self._clear_flash) + + def _clear_flash(self) -> None: + """Clear swap/discard flash highlights and re-render.""" + self._swap_flash.clear() + self._discard_flash = False + self._full_refresh() + # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ @@ -532,7 +583,7 @@ class GameScreen(Screen): # Play area play_area = self.query_one("#play-area", PlayAreaWidget) - play_area.update_state(state, local_player_id=self._player_id) + play_area.update_state(state, local_player_id=self._player_id, discard_flash=self._discard_flash) is_active = self._is_my_turn() and not state.waiting_for_initial_flip play_area.set_class(is_active, "my-turn") @@ -551,6 +602,7 @@ class GameScreen(Screen): 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, + flash_position=self._swap_flash.get(me.id), ) else: self.query_one("#local-hand-label", Static).update("") @@ -588,25 +640,32 @@ class GameScreen(Screen): matched = _check_column_match(cards) card_lines = _render_card_lines( cards, deck_colors=deck_colors, matched=matched, + flash_position=self._swap_flash.get(opp.id), ) opp_turn = not state.waiting_for_initial_flip and opp.id == state.current_player_id + display_score = opp.score if opp.score is not None else opp.visible_score box = render_player_box( opp.name, - score=opp.score, + score=display_score, total_score=opp.total_score, content_lines=card_lines, 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, ) opp_blocks.append(box) # Determine how many opponents fit per row - # Each box is ~21-24 chars wide; use actual widths for accuracy + # Account for padding on the opponents-area widget (2 chars each side) + try: + opp_widget = self.query_one("#opponents-area", Static) + avail_width = opp_widget.content_size.width or (width - 4) + except Exception: + avail_width = width - 4 + box_widths = [_visible_len(b[0]) if b else 22 for b in opp_blocks] - gap = " " if width < 120 else " " + gap = " " if avail_width < 120 else " " gap_len = len(gap) # Greedily fit as many as possible in one row @@ -614,7 +673,7 @@ class GameScreen(Screen): 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: + if row_width + needed_width <= avail_width: row_width += needed_width per_row += 1 else: diff --git a/tui_client/src/tui_client/screens/lobby.py b/tui_client/src/tui_client/screens/lobby.py index d2dc474..3915aa0 100644 --- a/tui_client/src/tui_client/screens/lobby.py +++ b/tui_client/src/tui_client/screens/lobby.py @@ -196,9 +196,14 @@ class LobbyScreen(Screen): yield Static("", id="lobby-status") + 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") + def on_mount(self) -> None: self._update_visibility() self._update_keymap() + self._update_footer() def reset_to_pre_room(self) -> None: """Reset lobby back to create/join state after leaving a game.""" @@ -228,7 +233,18 @@ class LobbyScreen(Screen): except Exception: pass + def _update_footer(self) -> None: + try: + left = self.query_one("#lobby-footer-left", Static) + if self._in_room: + left.update("\\[esc] leave") + else: + left.update("\\[esc] back") + except Exception: + pass + def _update_keymap(self) -> None: + 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") @@ -441,6 +457,7 @@ class LobbyScreen(Screen): def _handle_player_joined(self, data: dict) -> None: self._players = data.get("players", []) self._refresh_player_list() + self._auto_adjust_decks() def _handle_game_started(self, data: dict) -> None: from tui_client.screens.game import GameScreen @@ -463,6 +480,19 @@ class LobbyScreen(Screen): lines.append(f" {i}. {name}{suffix}") self.query_one("#player-list", Static).update("\n".join(lines) if lines else " (empty)") + def _auto_adjust_decks(self) -> None: + """Auto-set decks to 2 when more than 3 players.""" + if not self._is_host: + return + try: + sel = self.query_one("#sel-decks", Select) + if len(self._players) > 3 and sel.value == 1: + sel.value = 2 + elif len(self._players) <= 3 and sel.value == 2: + sel.value = 1 + except Exception: + pass + def _set_room_info(self, text: str) -> None: self.query_one("#room-info", Static).update(text) diff --git a/tui_client/src/tui_client/screens/splash.py b/tui_client/src/tui_client/screens/splash.py new file mode 100644 index 0000000..11bad3f --- /dev/null +++ b/tui_client/src/tui_client/screens/splash.py @@ -0,0 +1,74 @@ +"""Splash screen: check for saved session token before showing login.""" + +from __future__ import annotations + +import asyncio + +from textual.app import ComposeResult +from textual.containers import Container, Horizontal +from textual.screen import Screen +from textual.widgets import Static + + +_TITLE = ( + "⛳🏌️ [bold]GolfCards.club[/bold] " + "[bold #aaaaaa]♠[/bold #aaaaaa]" + "[bold #cc0000]♥[/bold #cc0000]" + "[bold #aaaaaa]♣[/bold #aaaaaa]" + "[bold #cc0000]♦[/bold #cc0000]" +) + + +class SplashScreen(Screen): + """Shows session check status, then routes to lobby or login.""" + + def compose(self) -> ComposeResult: + with Container(id="connect-container"): + yield Static(_TITLE, id="connect-title") + yield Static("", id="splash-status") + + with Horizontal(classes="screen-footer"): + yield Static("", classes="screen-footer-left") + yield Static("\\[esc]\\[esc] quit", classes="screen-footer-right") + + def on_mount(self) -> None: + self.run_worker(self._check_session(), exclusive=True) + + async def _check_session(self) -> None: + from tui_client.client import GameClient + + status = self.query_one("#splash-status", Static) + status.update("Checking for session token...") + await asyncio.sleep(0.5) + + session = GameClient.load_session() + if not session: + status.update("Checking for session token... [bold yellow]NONE FOUND[/bold yellow]") + await asyncio.sleep(0.8) + self._go_to_login() + return + + client = self.app.client + client.restore_session(session) + if await client.verify_token(): + status.update(f"Checking for session token... [bold green]SUCCESS[/bold green]") + await asyncio.sleep(0.8) + await self._go_to_lobby() + else: + GameClient.clear_session() + status.update("Checking for session token... [bold red]EXPIRED[/bold red]") + await asyncio.sleep(0.8) + self._go_to_login() + + def _go_to_login(self) -> None: + from tui_client.screens.connect import ConnectScreen + + self.app.switch_screen(ConnectScreen()) + + async def _go_to_lobby(self) -> None: + client = self.app.client + await client.connect() + client.save_session() + from tui_client.screens.lobby import LobbyScreen + + self.app.switch_screen(LobbyScreen()) diff --git a/tui_client/src/tui_client/styles.tcss b/tui_client/src/tui_client/styles.tcss index 80e36ee..b44ad97 100644 --- a/tui_client/src/tui_client/styles.tcss +++ b/tui_client/src/tui_client/styles.tcss @@ -3,6 +3,17 @@ Screen { background: $surface; } +/* Splash screen */ +SplashScreen { + align: center middle; +} + +#splash-status { + text-align: center; + width: 100%; + margin-top: 1; +} + /* Connect screen */ ConnectScreen { align: center middle; @@ -65,6 +76,23 @@ ConnectScreen { height: 1; } +/* Screen footer bar (shared by connect + lobby) */ +.screen-footer { + dock: bottom; + width: 100%; + height: 1; + background: #1a1a2e; + color: #888888; + padding: 0 1; +} +.screen-footer-left { + width: auto; +} +.screen-footer-right { + width: 1fr; + text-align: right; +} + /* Lobby screen */ LobbyScreen { align: center middle; @@ -231,7 +259,7 @@ GameScreen { #opponents-area { height: auto; max-height: 50%; - padding: 1 2 0 2; + padding: 1 2 1 2; text-align: center; content-align: center middle; } @@ -270,7 +298,7 @@ GameScreen { } /* Scoreboard overlay */ -#scoreboard-overlay { +ScoreboardScreen { align: center middle; background: $surface 80%; } @@ -284,6 +312,7 @@ GameScreen { border: thick $primary; padding: 1 2; background: $surface; + align: center middle; } #scoreboard-title { @@ -293,7 +322,7 @@ GameScreen { } #scoreboard-table { - width: 100%; + width: auto; height: auto; } @@ -364,9 +393,9 @@ HelpScreen { } #help-dialog { - width: auto; - max-width: 48; + width: 48; height: auto; + max-height: 80%; border: thick $primary; padding: 1 2; background: $surface; @@ -374,6 +403,7 @@ HelpScreen { #help-text { width: 100%; + height: auto; } /* Standings dialog */ @@ -383,9 +413,9 @@ StandingsScreen { } #standings-dialog { - width: auto; - max-width: 48; + width: 48; height: auto; + max-height: 80%; border: thick $primary; padding: 1 2; background: $surface; @@ -399,6 +429,13 @@ StandingsScreen { #standings-body { width: 100%; + height: auto; +} + +#standings-hint { + width: 100%; + height: 1; + margin-top: 1; } #standings-hint { diff --git a/tui_client/src/tui_client/widgets/card.py b/tui_client/src/tui_client/widgets/card.py index e4e48e5..d5986a2 100644 --- a/tui_client/src/tui_client/widgets/card.py +++ b/tui_client/src/tui_client/widgets/card.py @@ -24,13 +24,14 @@ BACK_COLORS: dict[str, str] = { } # Face-up card text colors (matching web UI) -SUIT_RED = "#c0392b" # hearts, diamonds -SUIT_BLACK = "#e0e0e0" # clubs, spades (light for dark terminal bg) +SUIT_RED = "#ff4444" # hearts, diamonds — bright red +SUIT_BLACK = "#ffffff" # clubs, spades — white for dark terminal bg 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 +FLASH_COLOR = "#00ffff" # bright cyan — swap/discard flash def _back_color_for_card(card: CardData, deck_colors: list[str] | None = None) -> str: @@ -59,38 +60,49 @@ def render_card( deck_colors: list[str] | None = None, dim: bool = False, highlight: bool = False, + flash: bool = False, + connect_top: bool = False, + connect_bottom: bool = False, ) -> str: """Render a card as a 4-line Rich-markup string. Face-up: Face-down: Empty: ┌───┐ 1───┐ ┌───┐ - │ A │ │░░░│ │ │ - │ ♠ │ │░░░│ │ │ + │ A │ │▓▓▓│ │ │ + │ ♠ │ │▓▓▓│ │ │ └───┘ └───┘ └───┘ + + connect_top/connect_bottom merge borders for matched column pairs. """ d = "dim " if dim else "" - bc = HIGHLIGHT_COLOR if highlight else BORDER_COLOR + bc = FLASH_COLOR if flash else HIGHLIGHT_COLOR if highlight else BORDER_COLOR + bot = f"[{d}{bc}]├───┤[/{d}{bc}]" if connect_bottom else f"[{d}{bc}]└───┘[/{d}{bc}]" # Empty slot if card is None: c = EMPTY_COLOR + top_line = f"[{d}{c}]├───┤[/{d}{c}]" if connect_top else f"[{d}{c}]┌───┐[/{d}{c}]" + bot_line = f"[{d}{c}]├───┤[/{d}{c}]" if connect_bottom else f"[{d}{c}]└───┘[/{d}{c}]" return ( - f"[{d}{c}]┌───┐[/{d}{c}]\n" + f"{top_line}\n" f"[{d}{c}]│ │[/{d}{c}]\n" f"[{d}{c}]│ │[/{d}{c}]\n" - f"[{d}{c}]└───┘[/{d}{c}]" + f"{bot_line}" ) - top = _top_border(position, d, bc, highlight=highlight) + if connect_top: + top = f"[{d}{bc}]├───┤[/{d}{bc}]" + else: + top = _top_border(position, d, bc, highlight=highlight) # Face-down card with colored back if not card.face_up: back = _back_color_for_card(card, deck_colors) return ( f"{top}\n" - f"[{d}{bc}]│[/{d}{bc}][{d}{back}]░░░[/{d}{back}][{d}{bc}]│[/{d}{bc}]\n" - f"[{d}{bc}]│[/{d}{bc}][{d}{back}]░░░[/{d}{back}][{d}{bc}]│[/{d}{bc}]\n" - f"[{d}{bc}]└───┘[/{d}{bc}]" + f"[{d}{bc}]│[/{d}{bc}][{d}{back}]▓▒▓[/{d}{back}][{d}{bc}]│[/{d}{bc}]\n" + f"[{d}{bc}]│[/{d}{bc}][{d}{back}]▒▓▒[/{d}{back}][{d}{bc}]│[/{d}{bc}]\n" + f"{bot}" ) # Joker @@ -101,11 +113,12 @@ def render_card( f"{top}\n" f"[{d}{bc}]│[/{d}{bc}][{d}{jc}] {icon}[/{d}{jc}][{d}{bc}]│[/{d}{bc}]\n" f"[{d}{bc}]│[/{d}{bc}][{d}{jc}]JKR[/{d}{jc}][{d}{bc}]│[/{d}{bc}]\n" - f"[{d}{bc}]└───┘[/{d}{bc}]" + f"{bot}" ) # Face-up normal card fc = SUIT_RED if card.is_red else SUIT_BLACK + b = "bold " if dim else "" rank = card.display_rank suit = card.display_suit rank_line = f"{rank:^3}" @@ -113,9 +126,9 @@ def render_card( return ( f"{top}\n" - f"[{d}{bc}]│[/{d}{bc}][{d}{fc}]{rank_line}[/{d}{fc}][{d}{bc}]│[/{d}{bc}]\n" - f"[{d}{bc}]│[/{d}{bc}][{d}{fc}]{suit_line}[/{d}{fc}][{d}{bc}]│[/{d}{bc}]\n" - f"[{d}{bc}]└───┘[/{d}{bc}]" + f"[{d}{bc}]│[/{d}{bc}][{b}{d}{fc}]{rank_line}[/{b}{d}{fc}][{d}{bc}]│[/{d}{bc}]\n" + f"[{d}{bc}]│[/{d}{bc}][{b}{d}{fc}]{suit_line}[/{b}{d}{fc}][{d}{bc}]│[/{d}{bc}]\n" + f"{bot}" ) diff --git a/tui_client/src/tui_client/widgets/hand.py b/tui_client/src/tui_client/widgets/hand.py index de39ab5..5991b42 100644 --- a/tui_client/src/tui_client/widgets/hand.py +++ b/tui_client/src/tui_client/widgets/hand.py @@ -9,8 +9,6 @@ from textual.widgets import Static from tui_client.models import CardData, PlayerData from tui_client.widgets.card import render_card -# Color for the match connector lines (muted green — pairs cancel to 0) -_MATCH_COLOR = "#6a9955" def _check_column_match(cards: list[CardData]) -> list[bool]: @@ -38,30 +36,6 @@ def _check_column_match(cards: list[CardData]) -> list[bool]: return matched -def _build_match_connector(matched: list[bool]) -> str | None: - """Build a connector line with ║ ║ under each matched column. - - Card layout: 5-char card, 1-char gap, repeated. - Positions: 01234 5 6789A B CDEFG - card0 card1 card2 - - For each matched column, place ║ at offsets 1 and 3 within the card span. - """ - has_any = matched[0] or matched[1] or matched[2] - if not has_any: - return None - - # Build a 17-char line (3 cards * 5 + 2 gaps) - chars = list(" " * 17) - for col in range(3): - if matched[col]: # top card matched implies column matched - base = col * 6 # card start position (0, 6, 12) - chars[base + 1] = "║" - chars[base + 3] = "║" - - line = "".join(chars) - return f"[{_MATCH_COLOR}]{line}[/]" - def _render_card_lines( cards: list[CardData], @@ -70,32 +44,35 @@ def _render_card_lines( deck_colors: list[str] | None = None, matched: list[bool] | None = None, highlight: bool = False, + flash_position: int | None = None, ) -> list[str]: """Render the 2x3 card grid as a list of text lines (no box). - Inserts a connector line with ║ ║ between rows for matched columns. + Matched columns use connected borders (├───┤) instead of separate + └───┘/┌───┐ to avoid an extra connector row. """ if matched is None: matched = _check_column_match(cards) lines: list[str] = [] for row_idx, row_start in enumerate((0, 3)): - # Insert connector between row 0 and row 1 - if row_idx == 1: - connector = _build_match_connector(matched) - if connector: - lines.append(connector) - row_line_parts: list[list[str]] = [] for i in range(3): idx = row_start + i card = cards[idx] if idx < len(cards) else None pos = idx + 1 if is_local else None + # Top row cards: connect_bottom if matched + # Bottom row cards: connect_top if matched + cb = matched[idx] if row_idx == 0 else False + ct = matched[idx] if row_idx == 1 else False text = render_card( card, position=pos, deck_colors=deck_colors, dim=matched[idx], highlight=highlight, + flash=(flash_position == idx), + connect_bottom=cb, + connect_top=ct, ) card_lines = text.split("\n") while len(row_line_parts) < len(card_lines): @@ -132,8 +109,8 @@ class HandWidget(Static): self._is_current_turn: bool = False self._is_knocker: bool = False self._is_dealer: bool = False - self._has_connector: bool = False self._highlight: bool = False + self._flash_position: int | None = None self._box_width: int = 0 def update_player( @@ -145,6 +122,7 @@ class HandWidget(Static): is_knocker: bool = False, is_dealer: bool = False, highlight: bool = False, + flash_position: int | None = None, ) -> None: self._player = player if deck_colors is not None: @@ -153,6 +131,7 @@ class HandWidget(Static): self._is_knocker = is_knocker self._is_dealer = is_dealer self._highlight = highlight + self._flash_position = flash_position self._refresh() def on_mount(self) -> None: @@ -171,9 +150,8 @@ class HandWidget(Static): # Box layout: # Line 0: top border # Lines 1-4: row 0 cards (4 lines each) - # Line 5 (optional): match connector - # Lines 5-8 or 6-9: row 1 cards - # Last line: bottom border + # Lines 5-8: row 1 cards + # Line 9: bottom border # # Content x: │ then cards at x offsets 2, 8, 14 (each 5 wide, 1 gap) @@ -191,14 +169,12 @@ class HandWidget(Static): return # Determine row from y - # y=0: top border, y=1..4: row 0 cards, then optional connector, then row 1 + # y=0: top border, y=1..4: row 0, y=5..8: row 1, y=9: bottom border row = -1 if 1 <= y <= 4: row = 0 - else: - row1_start = 6 if self._has_connector else 5 - if row1_start <= y <= row1_start + 3: - row = 1 + elif 5 <= y <= 8: + row = 1 if row < 0: return @@ -215,7 +191,6 @@ class HandWidget(Static): cards = self._player.cards matched = _check_column_match(cards) - self._has_connector = any(matched[:3]) card_lines = _render_card_lines( cards, @@ -223,18 +198,21 @@ class HandWidget(Static): deck_colors=self._deck_colors, matched=matched, highlight=self._highlight, + flash_position=self._flash_position, ) + # Use visible_score (computed from face-up cards) during play, + # server-provided score at round/game over + display_score = self._player.score if self._player.score is not None else self._player.visible_score box_lines = render_player_box( self._player.name, - score=self._player.score, + score=display_score, total_score=self._player.total_score, content_lines=card_lines, is_current_turn=self._is_current_turn, is_knocker=self._is_knocker, is_dealer=self._is_dealer, is_local=self._is_local, - all_face_up=self._player.all_face_up, ) # Store box width for click coordinate mapping diff --git a/tui_client/src/tui_client/widgets/play_area.py b/tui_client/src/tui_client/widgets/play_area.py index 8febd2e..501e809 100644 --- a/tui_client/src/tui_client/widgets/play_area.py +++ b/tui_client/src/tui_client/widgets/play_area.py @@ -46,9 +46,11 @@ class PlayAreaWidget(Static): self._state: GameState | None = None self._local_player_id: str = "" self._has_holding: bool = False + self._discard_flash: bool = False - def update_state(self, state: GameState, local_player_id: str = "") -> None: + def update_state(self, state: GameState, local_player_id: str = "", discard_flash: bool = False) -> None: self._state = state + self._discard_flash = discard_flash if local_player_id: self._local_player_id = local_player_id self._refresh() @@ -82,21 +84,17 @@ class PlayAreaWidget(Static): deck_lines = deck_text.split("\n") # Discard card - discard_text = render_card(state.discard_top, deck_colors=state.deck_colors) + discard_text = render_card(state.discard_top, deck_colors=state.deck_colors, flash=self._discard_flash) discard_lines = discard_text.split("\n") - # Held card — force face_up so render_card shows the face - # Only show when it's the local player holding + # Held card — show for any player holding held_lines = None - local_holding = ( - state.has_drawn_card - and state.drawn_card - and state.drawn_player_id == self._local_player_id - ) - if local_holding: + is_local_holding = False + if state.has_drawn_card and state.drawn_card: revealed = replace(state.drawn_card, face_up=True) held_text = render_card(revealed, deck_colors=state.deck_colors) held_lines = held_text.split("\n") + is_local_holding = state.drawn_player_id == self._local_player_id self._has_holding = held_lines is not None @@ -120,7 +118,10 @@ class PlayAreaWidget(Static): discard_label = "DISCARD" label = _pad_center(deck_label, _COL_WIDTH) if held_lines: - holding_label = f"[bold {_HOLDING_COLOR}]HOLDING[/]" + if is_local_holding: + holding_label = f"[bold {_HOLDING_COLOR}]HOLDING[/]" + else: + holding_label = "[dim]HOLDING[/dim]" label += _pad_center(holding_label, _COL_WIDTH) else: label += " " * _COL_WIDTH diff --git a/tui_client/src/tui_client/widgets/player_box.py b/tui_client/src/tui_client/widgets/player_box.py index d1d8f13..ed109ab 100644 --- a/tui_client/src/tui_client/widgets/player_box.py +++ b/tui_client/src/tui_client/widgets/player_box.py @@ -6,7 +6,7 @@ import re # Border colors matching web UI palette _BORDER_NORMAL = "#555555" -_BORDER_TURN_LOCAL = "#9ab973" # green — your turn +_BORDER_TURN_LOCAL = "#f4a460" # sandy orange — your turn (matches opponent turn) _BORDER_TURN_OPPONENT = "#f4a460" # sandy orange — opponent's turn _BORDER_KNOCKER = "#ff6b35" # red-orange — went out _NAME_COLOR = "#e0e0e0" @@ -27,7 +27,6 @@ def render_player_box( is_knocker: bool = False, is_dealer: bool = False, is_local: bool = False, - all_face_up: bool = False, ) -> list[str]: """Render a bordered player container with name/score header. @@ -60,13 +59,9 @@ def render_player_box( display_name = name if is_dealer: display_name = f"Ⓓ {display_name}" - if all_face_up: - display_name += " ✓" - if is_knocker: - display_name += " OUT" - # Score text - score_text = 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}" # Compute box width. Every line is exactly box_width visible chars. # Content row: │ │ => box_width = vis(content) + 4 @@ -109,6 +104,16 @@ def render_player_box( ) # Bottom border - result.append(f"[{bc}]╰{'─' * inner}╯[/]") + 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}╯[/]") return result