Add match, turn and history views with a dark theme

- Top bar switching between Deck, Match, Turn and History views
- Match view: life, player counters, commanders, emblems, zone counts,
  turn timer and timeouts, land drops used/allowed, play/draw and Bo3
  draw counts, with the deck view below
- Turn view: per-player and total stats per turn with history, plus
  whole-game totals and land drops
- History: every finished game (colours, winner, who was on the play);
  click to reopen it in the other views; imports Player-prev.log
- Customize menu to show/hide items and an Arrange dialog to reorder
  deck sections and turn stats, remembered across sessions
- Static dark theme with mint accent, rounded corners, larger text
- Commanders tracked separately from emblems, with location and tax
- Quieter, non-sticky warning for cards lost to MTGA log gaps
- Correct Scryfall images for tokens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aeth3randClaude Opus 5.5 committed 2026-10-16 11:18:44 -04:00
1 parent 7dc2ce0da8
commit 5b3c8c8e63
22 files changed
+2599 -193

No files matched your search

+287
View File
@@ -0,0 +1,287 @@
"""Match view: both players' status and game info on top, the deck view below."""
from __future__ import annotations
import re
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QSplitter,
QVBoxLayout,
QWidget,
)
from ..carddb import CardDB
from ..gamestate import GameState, MatchTracker
from . import theme
from .card_list import CardList, Row, Section
from .toggles import Toggles
MATCH_TOGGLES = [
("game_info", "Game info (turn, phase, score)"),
("play_draw", "On the play / on the draw"),
("duration", "Game duration"),
("life", "Life totals"),
("counters", "Player counters (poison, energy…)"),
("zones", "Hand / library / graveyard counts"),
("land_drop", "Land drop (played/allowed)"),
("timer", "Turn timer"),
("timeouts", "Timeouts used"),
("commander", "Commander"),
("emblems", "Emblems"),
]
def pretty_enum(value: str) -> str:
"""'Phase_Main1' -> 'Main 1', 'Step_BeginCombat' -> 'Begin combat'."""
name = value.split("_", 1)[-1]
words = re.findall(r"[A-Z][a-z]*|\d+", name)
return " ".join([words[0]] + [w.lower() for w in words[1:]]) if words else ""
def fmt_seconds(sec: float) -> str:
sec = int(sec)
h, rem = divmod(sec, 3600)
m, s = divmod(rem, 60)
return f"{h}:{m:02}:{s:02}" if h else f"{m}:{s:02}"
def _label(text: str = "", name: str | None = None) -> QLabel:
lbl = QLabel(text)
if name:
lbl.setObjectName(name)
return lbl
class PlayerCard(QFrame):
hovered = Signal(int)
def __init__(self, cards: CardDB, toggles: Toggles, parent=None):
super().__init__(parent)
self.setObjectName("card")
self.cards = cards
self.toggles = toggles
self.title = _label(name="cardTitle")
self.life = _label(name="bigNumber")
self.life_caption = _label("life", "muted")
self.counters = _label()
self.counters.setWordWrap(True)
self.zones = _label()
self.timer = _label()
self.timeouts = _label(name="muted")
self.land = _label()
self.commander = CardList()
self.commander.setMaximumHeight(90)
self.commander.hovered.connect(self.hovered)
self.emblems = CardList()
self.emblems.setMaximumHeight(120)
self.emblems.hovered.connect(self.hovered)
life_row = QHBoxLayout()
life_row.addWidget(self.life)
life_row.addWidget(self.life_caption, 0, Qt.AlignmentFlag.AlignBottom)
life_row.addStretch(1)
timer_col = QVBoxLayout()
timer_col.addWidget(self.timer, 0, Qt.AlignmentFlag.AlignRight)
timer_col.addWidget(self.timeouts, 0, Qt.AlignmentFlag.AlignRight)
timer_col.addWidget(self.land, 0, Qt.AlignmentFlag.AlignRight)
life_row.addLayout(timer_col)
lay = QVBoxLayout(self)
lay.setContentsMargins(14, 10, 14, 12)
lay.setSpacing(6)
lay.addWidget(self.title)
lay.addLayout(life_row)
lay.addWidget(self.counters)
lay.addWidget(self.zones)
lay.addWidget(self.commander)
lay.addWidget(self.emblems)
lay.addStretch(1)
def render(self, g: GameState, seat: int, name: str, now_ms: int) -> None:
t = self.toggles
active = " · <span style='color:%s'>active</span>" % theme.MINT_TEXT if g.active_player == seat else ""
play = ""
if t.on("play_draw") and g.on_play is not None:
on_play = g.on_play == seat
play = (f" · <span style='color:{theme.MINT_TEXT if on_play else theme.MUTED}'>"
f"{'▶ on the play' if on_play else 'on the draw'}</span>")
self.title.setText(f"{name}{active}{play}")
life = g.life.get(seat)
self.life.setText(str(life) if life is not None else "–")
self.life.setVisible(t.on("life"))
self.life_caption.setVisible(t.on("life"))
counters = {k: v for k, v in (g.player_counters.get(seat) or {}).items() if v}
self.counters.setText(" ".join(f"<b>{v}</b> {self.cards.counter_name(k).lower()}"
for k, v in sorted(counters.items()))
or f"<span style='color:{theme.MUTED}'>No counters</span>")
self.counters.setVisible(t.on("counters"))
self.zones.setText(
f"Hand <b>{g.zone_size('ZoneType_Hand', seat)}</b> · "
f"Library <b>{g.zone_size('ZoneType_Library', seat)}</b> · "
f"Graveyard <b>{g.zone_size('ZoneType_Graveyard', seat)}</b> · "
# Exile is a shared zone, so count the seat's visible cards in it.
f"Exile <b>{sum(g.cards_in('ZoneType_Exile', owner=seat).values())}</b>"
)
self.zones.setVisible(t.on("zones"))
self.render_timer(g, seat, now_ms)
drop = g.land_drop(seat)
if drop is None:
self.land.setText(f"<span style='color:{theme.MUTED}'>Land drop –</span>")
self.land.setToolTip("Lands can only be played on your own turn")
else:
used, allowed = drop
color = theme.MINT_TEXT if used < allowed else theme.MUTED
self.land.setText(f"Land drop <span style='color:{color}; font-weight:bold'>{used}/{allowed}</span>")
self.land.setToolTip(f"{used} of {allowed} land plays used this turn"
+ (" (still available)" if used < allowed else ""))
self.land.setVisible(t.on("land_drop") and g.turn > 0)
if g.max_timeouts and seat in g.timeouts_used:
self.timeouts.setText(f"Timeouts {g.timeouts_used[seat]}/{g.max_timeouts}")
elif g.max_timeouts:
self.timeouts.setText(f"Timeouts –/{g.max_timeouts}")
else:
self.timeouts.setText("")
self.timeouts.setVisible(t.on("timeouts") and bool(g.max_timeouts))
status = g.commander_status(seat)
if status:
tax = g.commander_tax(seat)
title = "Commander" + (f" (tax +{tax})" if tax else "")
self.commander.set_sections([Section("commander", title, [
Row(self.cards.get(grp), 0, where or "hidden") for grp, where in status
])])
self.commander.setVisible(t.on("commander") and bool(status))
emblems = g.emblems(seat)
self.emblems.set_sections([Section("emblems", "Emblems", [
Row(self.cards.get(grp), n) for grp, n in emblems.items()
])] if emblems else [])
self.emblems.setVisible(t.on("emblems") and bool(emblems))
def render_timer(self, g: GameState, seat: int, now_ms: int) -> None:
timer = g.turn_timer(seat, now_ms) if not g.complete else None
if timer is None:
self.timer.setText("")
else:
remaining, warn, running = timer
if not running:
color = theme.MUTED
else:
color = theme.DANGER if remaining <= warn else theme.MINT_TEXT
self.timer.setText(f"<span style='font-size:{theme.FONT_PT * 2}pt; color:{color}; font-weight:bold'>"
f"⏱ {fmt_seconds(remaining)}</span>")
self.timer.setToolTip("Turn timer running" if running else
"Time available before the turn timer runs out (starts when the rope appears)")
self.timer.setVisible(self.toggles.on("timer") and timer is not None)
class MatchView(QWidget):
hovered = Signal(int)
def __init__(self, cards: CardDB, toggles: Toggles, parent=None):
super().__init__(parent)
self.cards = cards
self.toggles = toggles
self.me = PlayerCard(cards, toggles)
self.opp = PlayerCard(cards, toggles)
self.me.hovered.connect(self.hovered)
self.opp.hovered.connect(self.hovered)
self.info = QFrame()
self.info.setObjectName("card")
self.turn_label = _label(name="bigNumber")
self.turn_caption = _label(name="muted")
self.phase = _label()
self.score = _label()
self.draws = _label(name="muted")
self.draws.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.duration = _label(name="muted")
grid = QGridLayout(self.info)
grid.setContentsMargins(14, 10, 14, 12)
for row, w in enumerate([_label("Turn", "cardTitle"), self.turn_label, self.turn_caption,
self.phase, self.score, self.draws, self.duration]):
grid.addWidget(w, row, 0, Qt.AlignmentFlag.AlignHCenter)
grid.setRowStretch(7, 1)
top = QWidget()
top_lay = QHBoxLayout(top)
top_lay.setContentsMargins(0, 0, 0, 0)
top_lay.addWidget(self.me, 3)
top_lay.addWidget(self.info, 2)
top_lay.addWidget(self.opp, 3)
# The shared DeckView is re-parented into this slot when the match view is shown.
self.deck_slot = QWidget()
self.deck_slot_layout = QVBoxLayout(self.deck_slot)
self.deck_slot_layout.setContentsMargins(0, 0, 0, 0)
split = QSplitter(Qt.Orientation.Vertical)
split.addWidget(top)
split.addWidget(self.deck_slot)
split.setChildrenCollapsible(False)
split.setSizes([1, 1])
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(split)
def render(self, t: MatchTracker, now_ms: int) -> None:
g = t.game
for w in (self.me, self.opp, self.info):
w.setVisible(g is not None)
if g is None:
return
self.me.render(g, g.my_seat, "You", now_ms)
self.opp.render(g, g.opp_seat, t.opponent_name or "Opponent", now_ms)
on = self.toggles.on
whose = "your turn" if g.active_player == g.my_seat else "their turn"
self.turn_label.setText(str(g.turn or "–"))
self.turn_caption.setText(whose if g.turn else "pre-game")
phase = pretty_enum(g.phase) if g.phase else ""
step = pretty_enum(g.step) if g.step else ""
self.phase.setText(" · ".join(x for x in (phase, step) if x))
wins = t.game_results.count("Won")
score = f"Game {g.game_number}"
if t.game_results:
score += f" · {wins}–{len(t.game_results) - wins}"
if not t.in_match and t.last_result:
score += f" · match {t.last_result.lower()}"
self.score.setText(score)
for w in (self.turn_label, self.turn_caption, self.phase, self.score):
w.setVisible(on("game_info"))
# Best-of-three: who has been on the draw more often (the loser of a game chooses next).
draws = t.games_on_draw()
if g.best_of == 3 and t.on_play:
mine, theirs = draws.get(g.my_seat, 0), draws.get(g.opp_seat, 0)
more = "you" if mine > theirs else "they" if theirs > mine else ""
self.draws.setText(f"On the draw: you {mine} · them {theirs}"
+ (f"<br><b>{more} had more games on the draw</b>" if more else "<br>even so far"))
self.draws.setToolTip(" · ".join(
f"Game {n}: {'you' if seat == g.my_seat else 'they'} on the play"
for n, seat in sorted(t.on_play.items())))
self.draws.setVisible(on("play_draw") and g.best_of == 3 and bool(t.on_play))
self.tick(t, now_ms)
def tick(self, t: MatchTracker, now_ms: int) -> None:
"""Cheap per-second update of the clocks."""
g = t.game
if g is None:
return
self.me.render_timer(g, g.my_seat, now_ms)
self.opp.render_timer(g, g.opp_seat, now_ms)
if g.started_ms is not None:
end = g.last_ms if g.complete or not t.in_match else max(now_ms, g.last_ms or now_ms)
self.duration.setText(f"Game time {fmt_seconds((end - g.started_ms) / 1000)}")
self.duration.setVisible(self.toggles.on("duration") and g.started_ms is not None)