Source code for yasuki_gui.ui.info_box

import tkinter as tk
from collections.abc import Callable
from functools import partial

from yasuki_core.engine.players import PlayerId
from yasuki_core.engine.intents import SetHonor
from yasuki_core.engine.table import DeckKey, ZoneKey, ZoneRole
from yasuki_core.game_pieces.constants import Side
from yasuki_gui import theme
from yasuki_gui.field_view import FieldView
from yasuki_gui.ui.dialogs import Dialogs
from yasuki_gui.ui.images import ImageProvider

try:
    from PIL import Image, ImageTk  # type: ignore
except Exception:  # pragma: no cover
    Image = None  # type: ignore
    ImageTk = None  # type: ignore

_CELL_W = 58
_CELL_H = 78

# The 3×3 grid of off-board piles, top-to-bottom and left-to-right. Decks and the hand count are
# display-only; discard and banish piles open an inspect dialog. The third column holds only the
# hand count (the playable hand itself stays on the board); its lower cells are intentionally blank.
_DECK_CELLS: tuple[tuple[int, int, Side, str], ...] = (
    (0, 0, Side.FATE, "Fate"),
    (0, 1, Side.DYNASTY, "Dynasty"),
)
_PILE_CELLS: tuple[tuple[int, int, ZoneRole, str], ...] = (
    (1, 0, ZoneRole.FATE_DISCARD, "Fate disc"),
    (1, 1, ZoneRole.DYNASTY_DISCARD, "Dyn disc"),
    (2, 0, ZoneRole.FATE_BANISH, "Fate ban"),
    (2, 1, ZoneRole.DYNASTY_BANISH, "Dyn ban"),
)


class _Cell(tk.Canvas):
    """A compact pile slot: a card silhouette with a count, captioned. Decks draw as a back, piles
    as a face when occupied; an empty pile draws as an outline."""

    def __init__(self, master: tk.Misc, caption: str, on_click: Callable[[], None] | None = None):
        super().__init__(
            master, width=_CELL_W, height=_CELL_H, bg=theme.PANEL, highlightthickness=0
        )
        self._caption = caption
        if on_click is not None:
            self.bind("<Button-1>", lambda e: on_click())
            self.configure(cursor="hand2")

    def render(self, count: int, *, is_back: bool) -> None:
        self.delete("all")
        self.create_text(
            _CELL_W // 2, 7, text=self._caption, fill=theme.INK_DIM, font=theme.serif(8)
        )
        x0, y0, x1, y1 = 4, 16, _CELL_W - 4, _CELL_H - 4
        if count > 0:
            fill = theme.GOLD if is_back else theme.CARD_FACE
            self.create_rectangle(x0, y0, x1, y1, fill=fill, outline=theme.LINE_SOFT)
            ink = "#ffffff" if is_back else theme.INK
            self.create_text(
                _CELL_W // 2,
                (y0 + y1) // 2,
                text=str(count),
                fill=ink,
                font=theme.serif(15, "bold"),
            )
        else:
            self.create_rectangle(x0, y0, x1, y1, outline=theme.LINE_SOFT, dash=(2, 2))


[docs] class PlayerInfoBox(tk.Frame): """The off-board panel for one seat: avatar, name, editable honor, and a 3×3 grid of the seat's decks, discard and banish piles, and hand count. Reads every count and top card from the field's active render source (the redacted projection in rules mode), so the opponent's box shows only what the viewer is entitled to — counts, never hidden identities. Honor edits dispatch a ``SetHonor`` intent and so apply only in the manual sandbox; the rules engine owns honor during play. """
[docs] def __init__( self, master: tk.Misc, field: FieldView, owner: PlayerId, on_deck_activated: Callable[[DeckKey], None] | None = None, ): super().__init__(master, bg=theme.PANEL) self.field = field self.owner = owner self._on_deck_activated = on_deck_activated self._honor_text = tk.StringVar() self._avatar_canvas = tk.Canvas( self, width=50, height=50, bg=theme.PANEL, highlightthickness=0 ) self._avatar_canvas.grid(row=0, column=0, rowspan=2, padx=8, pady=(8, 4)) self._avatar_photo = None name = field.state.seats[owner].name self._avatar_initials = self._initials(name) self._draw_avatar_circle() self._name_label = tk.Label( self, text=name, fg=theme.INK, bg=theme.PANEL, font=theme.serif(13) ) self._name_label.grid(row=0, column=1, sticky="w", padx=(0, 8), pady=(8, 0)) self._honor_label = tk.Label( self, textvariable=self._honor_text, fg=theme.GOLD, bg=theme.PANEL, font=theme.serif(14, "bold"), ) self._honor_label.grid(row=1, column=1, sticky="w", padx=(0, 8), pady=(0, 4)) self._honor_label.bind("<Button-1>", lambda e: self._adjust(1)) self._honor_label.bind("<Button-2>", lambda e: self._adjust(-1)) self._honor_label.bind("<Button-3>", lambda e: self._adjust(-1)) self._honor_label.bind("<MouseWheel>", self._on_wheel) self._honor_label.bind("<Button-4>", lambda e: self._adjust(1)) self._honor_label.bind("<Button-5>", lambda e: self._adjust(-1)) self._honor_label.bind("<Enter>", self._on_hover) self._honor_label.bind("<Leave>", lambda e: self._restore_honor_bg()) self._grid = tk.Frame(self, bg=theme.PANEL) self._grid.grid(row=2, column=0, columnspan=2, sticky="ew", padx=6, pady=(0, 8)) self._deck_cells: dict[Side, _Cell] = {} self._pile_cells: dict[ZoneRole, _Cell] = {} self._build_cells() self.grid_columnconfigure(1, weight=1) self.refresh()
# ----- header (avatar / name / honor) ----------------------------------- @staticmethod def _initials(name: str) -> str: return "".join(part[0].upper() for part in name.split()[:2]) or "?" def _editable(self) -> bool: return self.owner is self.field.seat def _adjust(self, delta: int) -> None: if not self._editable(): return self.field.dispatch(SetHonor(delta=delta)) self.refresh() def _on_wheel(self, event: tk.Event) -> None: if event.delta: self._adjust(1 if event.delta > 0 else -1) def _on_hover(self, event: tk.Event) -> None: if self._editable(): self._honor_label.configure(bg=theme.GOLD, fg="#ffffff") def _restore_honor_bg(self) -> None: self._honor_label.configure( bg=theme.PANEL, fg=theme.GOLD if self._editable() else theme.INK_DIM ) def _draw_avatar_circle(self) -> None: c = self._avatar_canvas c.delete("all") c.create_oval(3, 3, 47, 47, fill=theme.AVATAR_BG, outline="") c.create_text( 25, 25, text=self._avatar_initials, fill=theme.AVATAR_FG, font=("TkDefaultFont", 14, "bold"), )
[docs] def set_profile(self, name: str | None, avatar_path: str | None) -> None: if name: self._name_label.configure(text=name) self._avatar_initials = self._initials(name) if avatar_path and Image is not None and ImageTk is not None: try: img = Image.open(avatar_path) img = img.resize((50, 50), getattr(Image, "LANCZOS", None) or Image.BILINEAR) photo = ImageTk.PhotoImage(img, master=self._avatar_canvas) self._avatar_canvas.delete("all") self._avatar_canvas.create_image(25, 25, image=photo) self._avatar_photo = photo # keep a reference so Tk does not GC it return except OSError: pass self._draw_avatar_circle()
# ----- the 3×3 pile grid ------------------------------------------------- def _build_cells(self) -> None: for row, col, side, caption in _DECK_CELLS: # The dynasty deck is clickable — it hosts the Legacy rulebook ability; other decks stay # display-only. deck_menu gates the action to the human's own deck. on_click = None if side is Side.DYNASTY and self._on_deck_activated is not None: on_click = partial(self._on_deck_activated, DeckKey(self.owner, side)) cell = _Cell(self._grid, caption, on_click=on_click) cell.grid(row=row, column=col, padx=2, pady=2) self._deck_cells[side] = cell for row, col, role, caption in _PILE_CELLS: cell = _Cell(self._grid, caption, on_click=partial(self._inspect, role, caption)) cell.grid(row=row, column=col, padx=2, pady=2) self._pile_cells[role] = cell self._hand_cell = _Cell(self._grid, "Hand") self._hand_cell.grid(row=0, column=2, padx=2, pady=2) def _inspect(self, role: ZoneRole, label: str) -> None: cards = self.field.zone_render_cards(ZoneKey(self.owner, role)) if not cards: return master = self.winfo_toplevel() Dialogs(master, ImageProvider(master)).deck_inspect(cards, label)
[docs] def cell_counts(self) -> dict[str, int]: """The count shown in each grid cell, keyed by a stable cell name, read from the field's active source. Pure data with no Tk, so the box's derivation is testable.""" counts = { f"{side.name.lower()}_deck": self.field.deck_summary(DeckKey(self.owner, side))[0] for side in (Side.FATE, Side.DYNASTY) } for _, _, role, _ in _PILE_CELLS: counts[role.value] = len(self.field.zone_render_cards(ZoneKey(self.owner, role))) counts["hand"] = self.field.hand_count(self.owner) return counts
[docs] def refresh(self) -> None: """Resync the honor header and every pile count with the field's current source.""" honor = self.field.state.seats[self.owner].honor self._honor_text.set(f"Honor {honor}") editable = self._editable() self._honor_label.configure( fg=theme.GOLD if editable else theme.INK_DIM, cursor="hand2" if editable else "" ) counts = self.cell_counts() for side, cell in self._deck_cells.items(): cell.render(counts[f"{side.name.lower()}_deck"], is_back=True) for role, cell in self._pile_cells.items(): cell.render(counts[role.value], is_back=False) self._hand_cell.render(counts["hand"], is_back=True)