Add session persistence, splash screen, and TUI polish

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 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken 2026-02-25 19:35:03 -05:00
parent 67d06d9799
commit b1d3aa7b77
12 changed files with 396 additions and 98 deletions

View File

@ -55,8 +55,8 @@ class GolfApp(App):
yield KeymapBar(id="keymap-bar") yield KeymapBar(id="keymap-bar")
def on_mount(self) -> None: def on_mount(self) -> None:
from tui_client.screens.connect import ConnectScreen from tui_client.screens.splash import SplashScreen
self.push_screen(ConnectScreen()) self.push_screen(SplashScreen())
self._update_keymap() self._update_keymap()
def on_screen_resume(self) -> None: def on_screen_resume(self) -> None:

View File

@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
from pathlib import Path
from typing import Optional from typing import Optional
import httpx import httpx
@ -13,6 +14,9 @@ from websockets.asyncio.client import ClientConnection
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_SESSION_DIR = Path.home() / ".config" / "golfcards"
_SESSION_FILE = _SESSION_DIR / "session.json"
class GameClient: class GameClient:
"""Handles HTTP auth and WebSocket game communication.""" """Handles HTTP auth and WebSocket game communication."""
@ -47,6 +51,62 @@ class GameClient:
def username(self) -> Optional[str]: def username(self) -> Optional[str]:
return self._username 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: async def login(self, username: str, password: str) -> dict:
"""Login via HTTP and store JWT token. """Login via HTTP and store JWT token.

View File

@ -61,6 +61,29 @@ class PlayerData:
rounds_won: int = 0 rounds_won: int = 0
all_face_up: bool = False 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 @classmethod
def from_dict(cls, d: dict) -> PlayerData: def from_dict(cls, d: dict) -> PlayerData:
return cls( return cls(

View File

@ -7,6 +7,7 @@ from textual.containers import Container, Horizontal, Vertical
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Button, Input, Static 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]"
@ -61,8 +62,13 @@ class ConnectScreen(Screen):
yield Static("", id="connect-status") 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: def on_mount(self) -> None:
self._update_form_visibility() self._update_form_visibility()
self._update_footer()
def _update_form_visibility(self) -> None: def _update_form_visibility(self) -> None:
try: try:
@ -70,6 +76,17 @@ class ConnectScreen(Screen):
self.query_one("#signup-form").display = self._mode == "signup" self.query_one("#signup-form").display = self._mode == "signup"
except Exception: except Exception:
pass 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: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-login": if event.button.id == "btn-login":
@ -150,6 +167,7 @@ class ConnectScreen(Screen):
client = self.app.client client = self.app.client
self._set_status("Connecting...") self._set_status("Connecting...")
await client.connect() await client.connect()
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.push_screen(LobbyScreen())

View File

@ -172,6 +172,8 @@ class GameScreen(Screen):
self._initial_flip_positions: list[int] = [] self._initial_flip_positions: list[int] = []
self._can_flip_optional = False self._can_flip_optional = False
self._term_width: int = 80 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 self._term_height: int = 24
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
@ -210,7 +212,10 @@ class GameScreen(Screen):
def _handle_game_state(self, data: dict) -> None: def _handle_game_state(self, data: dict) -> None:
state_data = data.get("game_state", data) 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() self._full_refresh()
def _handle_your_turn(self, data: dict) -> None: def _handle_your_turn(self, data: dict) -> None:
@ -320,7 +325,13 @@ class GameScreen(Screen):
self.action_draw_deck() self.action_draw_deck()
def on_play_area_widget_discard_clicked(self, event: PlayAreaWidget.DiscardClicked) -> None: def on_play_area_widget_discard_clicked(self, event: PlayAreaWidget.DiscardClicked) -> None:
"""Handle click on the discard pile.""" """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() self.action_pick_discard()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -481,6 +492,46 @@ class GameScreen(Screen):
if hasattr(lobby, "reset_to_pre_room"): if hasattr(lobby, "reset_to_pre_room"):
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 # Helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -532,7 +583,7 @@ class GameScreen(Screen):
# Play area # Play area
play_area = self.query_one("#play-area", PlayAreaWidget) 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 is_active = self._is_my_turn() and not state.waiting_for_initial_flip
play_area.set_class(is_active, "my-turn") 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_knocker=(me.id == state.finisher_id and state.phase == "final_turn"),
is_dealer=(me.id == state.dealer_id), is_dealer=(me.id == state.dealer_id),
highlight=state.waiting_for_initial_flip, highlight=state.waiting_for_initial_flip,
flash_position=self._swap_flash.get(me.id),
) )
else: else:
self.query_one("#local-hand-label", Static).update("") self.query_one("#local-hand-label", Static).update("")
@ -588,25 +640,32 @@ class GameScreen(Screen):
matched = _check_column_match(cards) matched = _check_column_match(cards)
card_lines = _render_card_lines( card_lines = _render_card_lines(
cards, deck_colors=deck_colors, matched=matched, 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 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( box = render_player_box(
opp.name, opp.name,
score=opp.score, score=display_score,
total_score=opp.total_score, total_score=opp.total_score,
content_lines=card_lines, content_lines=card_lines,
is_current_turn=opp_turn, is_current_turn=opp_turn,
is_knocker=(opp.id == state.finisher_id and state.phase == "final_turn"), is_knocker=(opp.id == state.finisher_id and state.phase == "final_turn"),
is_dealer=(opp.id == state.dealer_id), is_dealer=(opp.id == state.dealer_id),
all_face_up=opp.all_face_up,
) )
opp_blocks.append(box) opp_blocks.append(box)
# Determine how many opponents fit per row # 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] 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) gap_len = len(gap)
# Greedily fit as many as possible in one row # Greedily fit as many as possible in one row
@ -614,7 +673,7 @@ class GameScreen(Screen):
row_width = 0 row_width = 0
for bw in box_widths: for bw in box_widths:
needed_width = bw if per_row == 0 else gap_len + bw 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 row_width += needed_width
per_row += 1 per_row += 1
else: else:

View File

@ -196,9 +196,14 @@ class LobbyScreen(Screen):
yield Static("", id="lobby-status") 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: def on_mount(self) -> None:
self._update_visibility() self._update_visibility()
self._update_keymap() self._update_keymap()
self._update_footer()
def reset_to_pre_room(self) -> None: def reset_to_pre_room(self) -> None:
"""Reset lobby back to create/join state after leaving a game.""" """Reset lobby back to create/join state after leaving a game."""
@ -228,7 +233,18 @@ class LobbyScreen(Screen):
except Exception: except Exception:
pass 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: def _update_keymap(self) -> None:
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 [Esc][Esc] Quit")
@ -441,6 +457,7 @@ class LobbyScreen(Screen):
def _handle_player_joined(self, data: dict) -> None: def _handle_player_joined(self, data: dict) -> None:
self._players = data.get("players", []) self._players = data.get("players", [])
self._refresh_player_list() self._refresh_player_list()
self._auto_adjust_decks()
def _handle_game_started(self, data: dict) -> None: def _handle_game_started(self, data: dict) -> None:
from tui_client.screens.game import GameScreen from tui_client.screens.game import GameScreen
@ -463,6 +480,19 @@ class LobbyScreen(Screen):
lines.append(f" {i}. {name}{suffix}") lines.append(f" {i}. {name}{suffix}")
self.query_one("#player-list", Static).update("\n".join(lines) if lines else " (empty)") 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: def _set_room_info(self, text: str) -> None:
self.query_one("#room-info", Static).update(text) self.query_one("#room-info", Static).update(text)

View File

@ -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())

View File

@ -3,6 +3,17 @@ Screen {
background: $surface; background: $surface;
} }
/* Splash screen */
SplashScreen {
align: center middle;
}
#splash-status {
text-align: center;
width: 100%;
margin-top: 1;
}
/* Connect screen */ /* Connect screen */
ConnectScreen { ConnectScreen {
align: center middle; align: center middle;
@ -65,6 +76,23 @@ ConnectScreen {
height: 1; 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 */ /* Lobby screen */
LobbyScreen { LobbyScreen {
align: center middle; align: center middle;
@ -231,7 +259,7 @@ GameScreen {
#opponents-area { #opponents-area {
height: auto; height: auto;
max-height: 50%; max-height: 50%;
padding: 1 2 0 2; padding: 1 2 1 2;
text-align: center; text-align: center;
content-align: center middle; content-align: center middle;
} }
@ -270,7 +298,7 @@ GameScreen {
} }
/* Scoreboard overlay */ /* Scoreboard overlay */
#scoreboard-overlay { ScoreboardScreen {
align: center middle; align: center middle;
background: $surface 80%; background: $surface 80%;
} }
@ -284,6 +312,7 @@ GameScreen {
border: thick $primary; border: thick $primary;
padding: 1 2; padding: 1 2;
background: $surface; background: $surface;
align: center middle;
} }
#scoreboard-title { #scoreboard-title {
@ -293,7 +322,7 @@ GameScreen {
} }
#scoreboard-table { #scoreboard-table {
width: 100%; width: auto;
height: auto; height: auto;
} }
@ -364,9 +393,9 @@ HelpScreen {
} }
#help-dialog { #help-dialog {
width: auto; width: 48;
max-width: 48;
height: auto; height: auto;
max-height: 80%;
border: thick $primary; border: thick $primary;
padding: 1 2; padding: 1 2;
background: $surface; background: $surface;
@ -374,6 +403,7 @@ HelpScreen {
#help-text { #help-text {
width: 100%; width: 100%;
height: auto;
} }
/* Standings dialog */ /* Standings dialog */
@ -383,9 +413,9 @@ StandingsScreen {
} }
#standings-dialog { #standings-dialog {
width: auto; width: 48;
max-width: 48;
height: auto; height: auto;
max-height: 80%;
border: thick $primary; border: thick $primary;
padding: 1 2; padding: 1 2;
background: $surface; background: $surface;
@ -399,6 +429,13 @@ StandingsScreen {
#standings-body { #standings-body {
width: 100%; width: 100%;
height: auto;
}
#standings-hint {
width: 100%;
height: 1;
margin-top: 1;
} }
#standings-hint { #standings-hint {

View File

@ -24,13 +24,14 @@ BACK_COLORS: dict[str, str] = {
} }
# Face-up card text colors (matching web UI) # Face-up card text colors (matching web UI)
SUIT_RED = "#c0392b" # hearts, diamonds SUIT_RED = "#ff4444" # hearts, diamonds — bright red
SUIT_BLACK = "#e0e0e0" # clubs, spades (light for dark terminal bg) SUIT_BLACK = "#ffffff" # clubs, spades — white for dark terminal bg
JOKER_COLOR = "#9b59b6" # purple JOKER_COLOR = "#9b59b6" # purple
BORDER_COLOR = "#888888" # card border BORDER_COLOR = "#888888" # card border
EMPTY_COLOR = "#555555" # empty card slot EMPTY_COLOR = "#555555" # empty card slot
POSITION_COLOR = "#f0e68c" # pale yellow — distinct from suits and card backs POSITION_COLOR = "#f0e68c" # pale yellow — distinct from suits and card backs
HIGHLIGHT_COLOR = "#ffaa00" # bright amber — initial flip / attention 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: def _back_color_for_card(card: CardData, deck_colors: list[str] | None = None) -> str:
@ -59,28 +60,39 @@ def render_card(
deck_colors: list[str] | None = None, deck_colors: list[str] | None = None,
dim: bool = False, dim: bool = False,
highlight: bool = False, highlight: bool = False,
flash: bool = False,
connect_top: bool = False,
connect_bottom: bool = False,
) -> str: ) -> str:
"""Render a card as a 4-line Rich-markup string. """Render a card as a 4-line Rich-markup string.
Face-up: Face-down: Empty: Face-up: Face-down: Empty:
1 1
A A
connect_top/connect_bottom merge borders for matched column pairs.
""" """
d = "dim " if dim else "" 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 # Empty slot
if card is None: if card is None:
c = EMPTY_COLOR 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 ( 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}]\n" f"[{d}{c}]│ │[/{d}{c}]\n"
f"[{d}{c}]└───┘[/{d}{c}]" f"{bot_line}"
) )
if connect_top:
top = f"[{d}{bc}]├───┤[/{d}{bc}]"
else:
top = _top_border(position, d, bc, highlight=highlight) top = _top_border(position, d, bc, highlight=highlight)
# Face-down card with colored back # Face-down card with colored back
@ -88,9 +100,9 @@ def render_card(
back = _back_color_for_card(card, deck_colors) back = _back_color_for_card(card, deck_colors)
return ( return (
f"{top}\n" 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}][{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"{bot}"
) )
# Joker # Joker
@ -101,11 +113,12 @@ def render_card(
f"{top}\n" 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}] {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}][{d}{jc}]JKR[/{d}{jc}][{d}{bc}]│[/{d}{bc}]\n"
f"[{d}{bc}]└───┘[/{d}{bc}]" f"{bot}"
) )
# Face-up normal card # Face-up normal card
fc = SUIT_RED if card.is_red else SUIT_BLACK fc = SUIT_RED if card.is_red else SUIT_BLACK
b = "bold " if dim else ""
rank = card.display_rank rank = card.display_rank
suit = card.display_suit suit = card.display_suit
rank_line = f"{rank:^3}" rank_line = f"{rank:^3}"
@ -113,9 +126,9 @@ def render_card(
return ( return (
f"{top}\n" f"{top}\n"
f"[{d}{bc}]│[/{d}{bc}][{d}{fc}]{rank_line}[/{d}{fc}][{d}{bc}]│[/{d}{bc}]\n" f"[{d}{bc}]│[/{d}{bc}][{b}{d}{fc}]{rank_line}[/{b}{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}][{b}{d}{fc}]{suit_line}[/{b}{d}{fc}][{d}{bc}]│[/{d}{bc}]\n"
f"[{d}{bc}]└───┘[/{d}{bc}]" f"{bot}"
) )

View File

@ -9,8 +9,6 @@ from textual.widgets import Static
from tui_client.models import CardData, PlayerData from tui_client.models import CardData, PlayerData
from tui_client.widgets.card import render_card 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]: def _check_column_match(cards: list[CardData]) -> list[bool]:
@ -38,30 +36,6 @@ def _check_column_match(cards: list[CardData]) -> list[bool]:
return matched 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( def _render_card_lines(
cards: list[CardData], cards: list[CardData],
@ -70,32 +44,35 @@ def _render_card_lines(
deck_colors: list[str] | None = None, deck_colors: list[str] | None = None,
matched: list[bool] | None = None, matched: list[bool] | None = None,
highlight: bool = False, highlight: bool = False,
flash_position: int | None = None,
) -> list[str]: ) -> list[str]:
"""Render the 2x3 card grid as a list of text lines (no box). """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: if matched is None:
matched = _check_column_match(cards) matched = _check_column_match(cards)
lines: list[str] = [] lines: list[str] = []
for row_idx, row_start in enumerate((0, 3)): 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]] = [] row_line_parts: list[list[str]] = []
for i in range(3): for i in range(3):
idx = row_start + i idx = row_start + i
card = cards[idx] if idx < len(cards) else None card = cards[idx] if idx < len(cards) else None
pos = idx + 1 if is_local 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( text = render_card(
card, card,
position=pos, position=pos,
deck_colors=deck_colors, deck_colors=deck_colors,
dim=matched[idx], dim=matched[idx],
highlight=highlight, highlight=highlight,
flash=(flash_position == idx),
connect_bottom=cb,
connect_top=ct,
) )
card_lines = text.split("\n") card_lines = text.split("\n")
while len(row_line_parts) < len(card_lines): while len(row_line_parts) < len(card_lines):
@ -132,8 +109,8 @@ class HandWidget(Static):
self._is_current_turn: bool = False self._is_current_turn: bool = False
self._is_knocker: bool = False self._is_knocker: bool = False
self._is_dealer: bool = False self._is_dealer: bool = False
self._has_connector: bool = False
self._highlight: bool = False self._highlight: bool = False
self._flash_position: int | None = None
self._box_width: int = 0 self._box_width: int = 0
def update_player( def update_player(
@ -145,6 +122,7 @@ class HandWidget(Static):
is_knocker: bool = False, is_knocker: bool = False,
is_dealer: bool = False, is_dealer: bool = False,
highlight: bool = False, highlight: bool = False,
flash_position: int | None = None,
) -> None: ) -> None:
self._player = player self._player = player
if deck_colors is not None: if deck_colors is not None:
@ -153,6 +131,7 @@ class HandWidget(Static):
self._is_knocker = is_knocker self._is_knocker = is_knocker
self._is_dealer = is_dealer self._is_dealer = is_dealer
self._highlight = highlight self._highlight = highlight
self._flash_position = flash_position
self._refresh() self._refresh()
def on_mount(self) -> None: def on_mount(self) -> None:
@ -171,9 +150,8 @@ class HandWidget(Static):
# Box layout: # Box layout:
# Line 0: top border # Line 0: top border
# Lines 1-4: row 0 cards (4 lines each) # Lines 1-4: row 0 cards (4 lines each)
# Line 5 (optional): match connector # Lines 5-8: row 1 cards
# Lines 5-8 or 6-9: row 1 cards # Line 9: bottom border
# Last line: bottom border
# #
# Content x: │ <space> then cards at x offsets 2, 8, 14 (each 5 wide, 1 gap) # Content x: │ <space> then cards at x offsets 2, 8, 14 (each 5 wide, 1 gap)
@ -191,13 +169,11 @@ class HandWidget(Static):
return return
# Determine row from y # 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 row = -1
if 1 <= y <= 4: if 1 <= y <= 4:
row = 0 row = 0
else: elif 5 <= y <= 8:
row1_start = 6 if self._has_connector else 5
if row1_start <= y <= row1_start + 3:
row = 1 row = 1
if row < 0: if row < 0:
@ -215,7 +191,6 @@ class HandWidget(Static):
cards = self._player.cards cards = self._player.cards
matched = _check_column_match(cards) matched = _check_column_match(cards)
self._has_connector = any(matched[:3])
card_lines = _render_card_lines( card_lines = _render_card_lines(
cards, cards,
@ -223,18 +198,21 @@ class HandWidget(Static):
deck_colors=self._deck_colors, deck_colors=self._deck_colors,
matched=matched, matched=matched,
highlight=self._highlight, 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( box_lines = render_player_box(
self._player.name, self._player.name,
score=self._player.score, score=display_score,
total_score=self._player.total_score, total_score=self._player.total_score,
content_lines=card_lines, content_lines=card_lines,
is_current_turn=self._is_current_turn, is_current_turn=self._is_current_turn,
is_knocker=self._is_knocker, is_knocker=self._is_knocker,
is_dealer=self._is_dealer, is_dealer=self._is_dealer,
is_local=self._is_local, is_local=self._is_local,
all_face_up=self._player.all_face_up,
) )
# Store box width for click coordinate mapping # Store box width for click coordinate mapping

View File

@ -46,9 +46,11 @@ class PlayAreaWidget(Static):
self._state: GameState | None = None self._state: GameState | None = None
self._local_player_id: str = "" self._local_player_id: str = ""
self._has_holding: bool = False 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._state = state
self._discard_flash = discard_flash
if local_player_id: if local_player_id:
self._local_player_id = local_player_id self._local_player_id = local_player_id
self._refresh() self._refresh()
@ -82,21 +84,17 @@ class PlayAreaWidget(Static):
deck_lines = deck_text.split("\n") deck_lines = deck_text.split("\n")
# Discard card # 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") discard_lines = discard_text.split("\n")
# Held card — force face_up so render_card shows the face # Held card — show for any player holding
# Only show when it's the local player holding
held_lines = None held_lines = None
local_holding = ( is_local_holding = False
state.has_drawn_card if state.has_drawn_card and state.drawn_card:
and state.drawn_card
and state.drawn_player_id == self._local_player_id
)
if local_holding:
revealed = replace(state.drawn_card, face_up=True) revealed = replace(state.drawn_card, face_up=True)
held_text = render_card(revealed, deck_colors=state.deck_colors) held_text = render_card(revealed, deck_colors=state.deck_colors)
held_lines = held_text.split("\n") 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 self._has_holding = held_lines is not None
@ -120,7 +118,10 @@ class PlayAreaWidget(Static):
discard_label = "DISCARD" discard_label = "DISCARD"
label = _pad_center(deck_label, _COL_WIDTH) label = _pad_center(deck_label, _COL_WIDTH)
if held_lines: if held_lines:
if is_local_holding:
holding_label = f"[bold {_HOLDING_COLOR}]HOLDING[/]" holding_label = f"[bold {_HOLDING_COLOR}]HOLDING[/]"
else:
holding_label = "[dim]HOLDING[/dim]"
label += _pad_center(holding_label, _COL_WIDTH) label += _pad_center(holding_label, _COL_WIDTH)
else: else:
label += " " * _COL_WIDTH label += " " * _COL_WIDTH

View File

@ -6,7 +6,7 @@ import re
# Border colors matching web UI palette # Border colors matching web UI palette
_BORDER_NORMAL = "#555555" _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_TURN_OPPONENT = "#f4a460" # sandy orange — opponent's turn
_BORDER_KNOCKER = "#ff6b35" # red-orange — went out _BORDER_KNOCKER = "#ff6b35" # red-orange — went out
_NAME_COLOR = "#e0e0e0" _NAME_COLOR = "#e0e0e0"
@ -27,7 +27,6 @@ def render_player_box(
is_knocker: bool = False, is_knocker: bool = False,
is_dealer: bool = False, is_dealer: bool = False,
is_local: bool = False, is_local: bool = False,
all_face_up: bool = False,
) -> list[str]: ) -> list[str]:
"""Render a bordered player container with name/score header. """Render a bordered player container with name/score header.
@ -60,13 +59,9 @@ def render_player_box(
display_name = name display_name = name
if is_dealer: if is_dealer:
display_name = f"{display_name}" display_name = f"{display_name}"
if all_face_up:
display_name += ""
if is_knocker:
display_name += " OUT"
# Score text # 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. # Compute box width. Every line is exactly box_width visible chars.
# Content row: │ <space> <content> <pad> │ => box_width = vis(content) + 4 # Content row: │ <space> <content> <pad> │ => box_width = vis(content) + 4
@ -109,6 +104,16 @@ def render_player_box(
) )
# Bottom border # Bottom border
if is_knocker:
out_label = " OUT "
left_fill = 1
right_fill = inner - left_fill - len(out_label)
result.append(
f"[{bc}]╰{'' * left_fill}[/]"
f"[bold {bc}]{out_label}[/]"
f"[{bc}]{'' * max(1, right_fill)}╯[/]"
)
else:
result.append(f"[{bc}]╰{'' * inner}╯[/]") result.append(f"[{bc}]╰{'' * inner}╯[/]")
return result return result