Split turn stats into mirrored per-player tables

Replace the "Selected turn" and "Whole game" tables (each with You,
Opponent and Total columns) with one table per player: yours on the
left (stat, turn, game) and the opponent's on the right, mirrored
(game, turn, stat), so whole-game numbers sit next to the turn picker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aeth3randClaude Opus 5.5 committed 2026-10-16 12:40:00 -04:00
1 parent b3325e8ecc
commit 171fc64ca9
2 files changed
+50 -44

No files matched your search

+5 -4
View File
@@ -10,10 +10,11 @@ Two views, switched from the top bar (Ctrl+1 / Ctrl+2):
emblems, hand/library/graveyard counts, turn timer and timeouts, land drops used/allowed, who emblems, hand/library/graveyard counts, turn timer and timeouts, land drops used/allowed, who
was on the play, plus the match panel (turn, phase, game number, Bo3 score and draw count, was on the play, plus the match panel (turn, phase, game number, Bo3 score and draw count,
game time). game time).
- *Selected turn · Turn · Whole game*: what happened in a turn for you, your opponent and in - *You · Turn · Opponent*: each player's stats for the selected turn and the whole game
total (cards drawn, spells cast, lands played, creatures died, life gained/lost, damage, (cards drawn, spells cast, lands played, creatures died, life gained/lost, damage, discards,
discards, mills, exiles, counters, tokens, land drops). The middle panel picks the turn mills, exiles, counters, tokens, land drops). The two sides mirror each other, with the
(◀ ▶, or *Live* to follow the current one). whole-game column next to the middle panel, which picks the turn (◀ ▶, or *Live* to follow
the current one).
- *My deck · Opponent · Preview*: remaining library with draw odds, your zones; the opponent's - *My deck · Opponent · Preview*: remaining library with draw odds, your zones; the opponent's
board, graveyard, exile, cards known to be in their hand or library, and every card seen this board, graveyard, exile, cards known to be in their hand or library, and every card seen this
match; hover a card for its [Scryfall](https://scryfall.com) image. match; hover a card for its [Scryfall](https://scryfall.com) image.
+45 -40
View File
@@ -29,39 +29,50 @@ TURN_TOGGLES = [(LAND_DROP, "Land drop (played/allowed)")] + STATS
TURN_DEFAULTS_OFF = {"permanents_died", "energy_spent"} TURN_DEFAULTS_OFF = {"permanents_died", "energy_spent"}
class StatsTable(QTableWidget): class PlayerStatsTable(QTableWidget):
def __init__(self, parent=None): """One player's stats: label | turn | game, or mirrored (game | turn | label) for the right side,
so the whole-game column always sits next to the turn picker in the middle."""
def __init__(self, mirrored: bool, parent=None):
super().__init__(0, 3, parent) super().__init__(0, 3, parent)
self.setHorizontalHeaderLabels(["You", "Opponent", "Total"]) self.mirrored = mirrored
self.label_col, self.turn_col, self.game_col = (2, 1, 0) if mirrored else (0, 1, 2)
headers = ["", "", ""]
headers[self.turn_col], headers[self.game_col] = "Turn", "Game"
self.setHorizontalHeaderLabels(headers)
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) self.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.setShowGrid(False) self.setShowGrid(False)
self.setAlternatingRowColors(True) self.setAlternatingRowColors(True)
self.verticalHeader().hide()
self.verticalHeader().setDefaultSectionSize(28) self.verticalHeader().setDefaultSectionSize(28)
self.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed) self.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) hdr = self.horizontalHeader()
self.verticalHeader().setStyleSheet(f"QHeaderView::section {{ color: {theme.TEXT}; font-weight: 400;" hdr.setSectionResizeMode(self.label_col, QHeaderView.ResizeMode.Stretch)
f" padding: 0 12px; border: none; }}") for col in (self.turn_col, self.game_col):
hdr.setSectionResizeMode(col, QHeaderView.ResizeMode.Fixed)
self.setColumnWidth(col, 80)
hdr.setDefaultAlignment(Qt.AlignmentFlag.AlignCenter)
def fill(self, rows: list[tuple[str, list[int | str | None]]]) -> None: def fill(self, rows: list[tuple[str, int | str | None, int | str | None]]) -> None:
"""rows: (label, [you, opponent, total]). Ints: 0 is dimmed. Strings shown as-is; None = n/a.""" """rows: (label, this turn, whole game). Ints: 0 is dimmed. Strings shown as-is; None = n/a."""
self.setRowCount(len(rows)) self.setRowCount(len(rows))
self.setVerticalHeaderLabels([label for label, _ in rows]) label_align = (Qt.AlignmentFlag.AlignRight if self.mirrored else Qt.AlignmentFlag.AlignLeft) \
for r, (_label, values) in enumerate(rows): | Qt.AlignmentFlag.AlignVCenter
for c, v in enumerate(values): for r, (label, turn_value, game_value) in enumerate(rows):
label_item = QTableWidgetItem(label)
label_item.setTextAlignment(label_align)
self.setItem(r, self.label_col, label_item)
for col, v in ((self.turn_col, turn_value), (self.game_col, game_value)):
empty = v is None or v == 0 empty = v is None or v == 0
item = QTableWidgetItem("·" if v == 0 else "–" if v is None else str(v)) item = QTableWidgetItem("·" if v == 0 else "–" if v is None else str(v))
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
if empty: if empty:
item.setForeground(QColor(theme.BORDER)) item.setForeground(QColor(theme.BORDER))
elif c == 2: elif col == self.game_col:
item.setForeground(QColor(theme.MINT_TEXT)) item.setForeground(QColor(theme.MINT_TEXT))
self.setItem(r, c, item) self.setItem(r, col, item)
def counts_row(counts: Counter[int], me: int, opp: int) -> list[int]:
return [counts.get(me, 0), counts.get(opp, 0), counts.get(me, 0) + counts.get(opp, 0)]
def fraction(used: int, allowed: int) -> str: def fraction(used: int, allowed: int) -> str:
@@ -110,10 +121,12 @@ class TurnView(QWidget):
self.picker_frame = card_frame("Turn", picker) self.picker_frame = card_frame("Turn", picker)
self.picker_frame.setFixedWidth(170) self.picker_frame.setFixedWidth(170)
self.turn_table = StatsTable() self.my_table = PlayerStatsTable(mirrored=False)
self.game_table = StatsTable() self.opp_table = PlayerStatsTable(mirrored=True)
self.turn_frame = card_frame("Selected turn", self.turn_table) self.my_frame = card_frame("You", self.my_table)
self.game_frame = card_frame("Whole game", self.game_table) self.opp_frame = card_frame("Opponent", self.opp_table)
self.opp_title = self.opp_frame.findChild(QLabel)
self.opp_title.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.empty = QLabel("Turn stats appear once a game starts.") self.empty = QLabel("Turn stats appear once a game starts.")
self.empty.setObjectName("muted") self.empty.setObjectName("muted")
@@ -121,9 +134,9 @@ class TurnView(QWidget):
lay = QHBoxLayout(self) lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0) lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(self.turn_frame, 1) lay.addWidget(self.my_frame, 1)
lay.addWidget(self.picker_frame) lay.addWidget(self.picker_frame)
lay.addWidget(self.game_frame, 1) lay.addWidget(self.opp_frame, 1)
lay.addWidget(self.empty, 1) lay.addWidget(self.empty, 1)
def _turns(self, g: GameState) -> list[int]: def _turns(self, g: GameState) -> list[int]:
@@ -157,7 +170,7 @@ class TurnView(QWidget):
g = t.game g = t.game
has_game = g is not None has_game = g is not None
self.empty.setVisible(not has_game) self.empty.setVisible(not has_game)
for w in (self.turn_frame, self.picker_frame, self.game_frame): for w in (self.my_frame, self.picker_frame, self.opp_frame):
w.setVisible(has_game) w.setVisible(has_game)
if g is None: if g is None:
return return
@@ -177,23 +190,15 @@ class TurnView(QWidget):
turn_stats = g.turns.turn(shown) turn_stats = g.turns.turn(shown)
totals = g.turns.game_totals() totals = g.turns.game_totals()
me, opp = g.my_seat, g.opp_seat me, opp = g.my_seat, g.opp_seat
self.opp_title.setText(t.opponent_name or "Opponent")
def turn_row(key: str) -> list: def row(key: str, label: str, seat: int) -> tuple:
if key == LAND_DROP: if key == LAND_DROP:
# Only the active player can play lands; show used/allowed for them. # Only the active player plays lands: show used/allowed on their turns.
drop = g.turns.land_drop(shown) if shown else None drop = g.turns.land_drop(shown) if shown else None
active = g.turns.active.get(shown) turn_cell = fraction(*drop) if drop and g.turns.active.get(shown) == seat else None
if drop is None: return label, turn_cell, fraction(*g.turns.land_drops_total(seat))
return [None, None, None] return label, turn_stats.get(key, Counter()).get(seat, 0), totals.get(key, Counter()).get(seat, 0)
cell = fraction(*drop)
return [cell if active == me else None, cell if active == opp else None, cell]
return counts_row(turn_stats.get(key, Counter()), me, opp)
def game_row(key: str) -> list: self.my_table.fill([row(key, label, me) for key, label in enabled])
if key == LAND_DROP: self.opp_table.fill([row(key, label, opp) for key, label in enabled])
mine, theirs = g.turns.land_drops_total(me), g.turns.land_drops_total(opp)
return [fraction(*mine), fraction(*theirs), fraction(mine[0] + theirs[0], mine[1] + theirs[1])]
return counts_row(totals.get(key, Counter()), me, opp)
self.turn_table.fill([(label, turn_row(key)) for key, label in enabled])
self.game_table.fill([(label, game_row(key)) for key, label in enabled])