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

+297 -167
View File
@@ -1,130 +1,264 @@
"""Main window: my deck | opponent | card preview."""
"""Main window: a top bar switching between the Deck, Match and Turn views."""
from __future__ import annotations
from collections import Counter
import time
from pathlib import Path
from PySide6.QtCore import Qt, QTimer
from PySide6.QtCore import QSettings, Qt, QThread, QTimer, Signal
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import (
QButtonGroup,
QFileDialog,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QMenu,
QMessageBox,
QSplitter,
QPushButton,
QStackedWidget,
QToolButton,
QVBoxLayout,
QWidget,
)
from ..carddb import CardDB
from ..gamestate import GameState, MatchTracker
from ..gamestate import MatchTracker
from ..history import HistoryRecorder, HistoryStore, import_log, replay_game
from ..images import ImageCache
from ..logtail import LogFollower
from .card_list import CardList, Row, Section
from .preview import CardPreview
from . import theme
from .arrange import ArrangeDialog
from .deck_view import MY_SECTIONS, OPP_SECTIONS, DeckView
from .history_view import HistoryView, fmt_when
from .match_view import MATCH_TOGGLES, MatchView
from .toggles import StayOpenMenu, Toggles
from .turn_view import TURN_DEFAULTS_OFF, TURN_TOGGLES, TurnView
REFRESH_MS = 150
DECK_TOGGLES = [("preview", "Card preview pane")]
VIEWS = ["Deck", "Match", "Turn", "History"]
HISTORY = 3
def _panel(title: str, body: QWidget) -> QWidget:
w = QWidget()
lay = QVBoxLayout(w)
lay.setContentsMargins(4, 4, 4, 4)
label = QLabel(f"<b>{title}</b>")
lay.addWidget(label)
lay.addWidget(body, 1)
return w
class HistoryImport(QThread):
"""Records completed matches from an older log (Player-prev.log) in the background."""
done = Signal(int)
def __init__(self, path: Path, store: HistoryStore, cards: CardDB):
super().__init__()
self.path, self.store, self.cards = path, store, cards
def run(self) -> None:
try:
st = self.path.stat()
except OSError:
return
stamp = f"{st.st_size}:{st.st_mtime_ns}"
key = f"imported:{self.path}"
if self.store.meta(key) == stamp:
return
# Own card DB connection: this runs off the GUI thread.
saved = import_log(self.path, self.store, CardDB(self.cards.path))
self.store.set_meta(key, stamp)
self.done.emit(saved)
class MainWindow(QMainWindow):
def __init__(self, cards: CardDB, images: ImageCache, log_path: Path | None,
def __init__(self, cards: CardDB, images: ImageCache, history: HistoryStore, log_path: Path | None,
replay_rate: float | None = None):
super().__init__()
self.setWindowTitle("MTGA Assistant")
self.cards = cards
self.images = images
self.history = history
self.recorder = HistoryRecorder(history)
self.tracker = MatchTracker(card_lookup=cards)
# A past game opened from the History view: (tracker, summary text). None = live.
self.past: tuple[MatchTracker, str] | None = None
self._importers: list[HistoryImport] = []
self.follower: LogFollower | None = None
self._rendered_version = -1
self.replaying = False
self._rendered: dict[int, int] = {} # view index -> tracker version last rendered
self._settings = QSettings("mtga-assistant", "mtga-assistant")
# Header
self.toggles = [
Toggles("deck", DECK_TOGGLES, parent=self),
Toggles("match", MATCH_TOGGLES, parent=self),
Toggles("turn", TURN_TOGGLES, TURN_DEFAULTS_OFF, parent=self),
]
self.my_sections = Toggles("deck_mine", MY_SECTIONS, title="My deck", parent=self)
self.opp_sections = Toggles("deck_opp", OPP_SECTIONS, title="Opponent", parent=self)
self.toggles[2].title = "Turn stats"
for tg in (*self.toggles, self.my_sections, self.opp_sections):
tg.changed.connect(self._force_refresh)
# Views. One DeckView is shared by the Deck page and the bottom half of the Match page.
self.deck_view = DeckView(cards, images, self.my_sections, self.opp_sections)
self.deck_page = QWidget()
self.deck_page_layout = QVBoxLayout(self.deck_page)
self.deck_page_layout.setContentsMargins(0, 0, 0, 0)
self.match_view = MatchView(cards, self.toggles[1])
self.match_view.hovered.connect(self.deck_view.preview.show_card)
self.turn_view = TurnView(self.toggles[2])
self.history_view = HistoryView()
self.history_view.open_game.connect(self.open_past_game)
self.stack = QStackedWidget()
for page in (self.deck_page, self.match_view, self.turn_view, self.history_view):
self.stack.addWidget(page)
# Top bar
self.nav = QButtonGroup(self)
self.nav.setExclusive(True)
bar = QHBoxLayout()
bar.setSpacing(8)
for i, name in enumerate(VIEWS):
btn = QPushButton(name)
btn.setObjectName("navButton")
btn.setCheckable(True)
btn.setShortcut(QKeySequence(f"Ctrl+{i + 1}"))
btn.setToolTip(f"{name} view (Ctrl+{i + 1})")
self.nav.addButton(btn, i)
bar.addWidget(btn)
self.nav.idClicked.connect(self.show_view)
bar.addSpacing(16)
self.header = QLabel("Waiting for a match…")
self.header.setTextFormat(Qt.TextFormat.RichText)
bar.addWidget(self.header, 1)
self.customize = QToolButton()
self.customize.setText("⚙ Customize")
self.customize.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
self.customize_menu = StayOpenMenu(self)
self.customize_menu.aboutToShow.connect(self._fill_customize)
self.customize.setMenu(self.customize_menu)
bar.addWidget(self.customize)
bar.addWidget(self._app_menu_button())
self.warning = QLabel()
self.warning.setStyleSheet("color: #d08770;")
self.warning.setObjectName("banner")
self.warning.setWordWrap(True)
self.warning.hide()
# Panels
self.mine = CardList()
self.opp = CardList()
self.preview = CardPreview(cards, images)
self.mine.hovered.connect(self.preview.show_card)
self.opp.hovered.connect(self.preview.show_card)
split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(_panel("My deck", self.mine))
split.addWidget(_panel("Opponent", self.opp))
split.addWidget(self.preview)
split.setStretchFactor(0, 3)
split.setStretchFactor(1, 3)
split.setStretchFactor(2, 2)
# Shown while looking at a past game.
self.past_bar = QFrame()
self.past_bar.setObjectName("pastBar")
past_lay = QHBoxLayout(self.past_bar)
past_lay.setContentsMargins(12, 6, 8, 6)
self.past_label = QLabel()
self.past_label.setTextFormat(Qt.TextFormat.RichText)
back = QPushButton("Back to live")
back.clicked.connect(self.close_past_game)
past_lay.addWidget(self.past_label, 1)
past_lay.addWidget(back)
self.past_bar.hide()
central = QWidget()
central.setObjectName("page")
lay = QVBoxLayout(central)
top = QHBoxLayout()
top.addWidget(self.header, 1)
lay.addLayout(top)
lay.setContentsMargins(12, 10, 12, 6)
lay.setSpacing(10)
lay.addLayout(bar)
lay.addWidget(self.warning)
lay.addWidget(split, 1)
lay.addWidget(self.past_bar)
lay.addWidget(self.stack, 1)
self.setCentralWidget(central)
# Status bar
self.status_log = QLabel()
self.status_cache = QLabel()
self.status_gap = QLabel()
self.status_gap.setStyleSheet(f"color: {theme.DANGER};")
self.status_gap.hide()
self.statusBar().addWidget(self.status_log, 1)
self.statusBar().addPermanentWidget(self.status_gap)
self.statusBar().addPermanentWidget(self.status_cache)
images.size_changed.connect(self._on_cache_size)
self._on_cache_size(images.disk_usage())
if not cards.available:
self.warning.setText("MTGA card database not found: card names are unavailable. "
"Set mtga_dir in ~/.config/mtga-assistant/config.toml.")
self.warning.show()
self._build_menu()
self.refresh_timer = QTimer(self)
self.refresh_timer.setInterval(REFRESH_MS)
self.refresh_timer.timeout.connect(self.refresh)
self.refresh_timer.start()
self.clock_timer = QTimer(self)
self.clock_timer.setInterval(1000)
self.clock_timer.timeout.connect(self._tick)
self.clock_timer.start()
self.resize(1280, 800)
self.resize(1400, 880)
self.history_view.set_games(history.games())
self.show_view(int(self._settings.value("view", 0, type=int)))
if log_path is not None:
self.start_log(log_path, replay_rate)
else:
self.status_log.setText("Player.log not found: use File → Open log…")
self.status_log.setText("Player.log not found: use ☰ → Open log…")
# -- menu ----------------------------------------------------------------
# -- top bar -------------------------------------------------------------
def _build_menu(self) -> None:
file_menu = self.menuBar().addMenu("&File")
open_act = QAction("&Open log…", self)
def _app_menu_button(self) -> QToolButton:
btn = QToolButton()
btn.setText("☰")
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
menu = QMenu(self)
open_act = menu.addAction("Open log…")
open_act.triggered.connect(lambda: self._choose_log(None))
replay_act = QAction("&Replay log…", self)
replay_act = menu.addAction("Replay log…")
replay_act.triggered.connect(lambda: self._choose_log(40.0))
quit_act = QAction("&Quit", self)
menu.addSeparator()
clear_act = menu.addAction("Clear image cache")
clear_act.triggered.connect(self._clear_cache)
menu.addSeparator()
quit_act = QAction("Quit", self)
quit_act.setShortcut(QKeySequence.StandardKey.Quit)
quit_act.triggered.connect(self.close)
file_menu.addActions([open_act, replay_act])
file_menu.addSeparator()
file_menu.addAction(quit_act)
self.addAction(quit_act) # keep the shortcut active while the menu is closed
menu.addAction(quit_act)
btn.setMenu(menu)
return btn
cache_menu = self.menuBar().addMenu("&Cache")
clear_act = QAction("&Clear image cache", self)
clear_act.triggered.connect(self._clear_cache)
cache_menu.addAction(clear_act)
def _arrangeable(self) -> list[Toggles]:
"""Toggle groups whose order can be arranged in the current view (deck view in match view too)."""
index = self.stack.currentIndex()
if index in (0, 1):
return [self.my_sections, self.opp_sections]
return [self.toggles[2]]
def _fill_customize(self) -> None:
menu = self.customize_menu
menu.clear()
index = self.stack.currentIndex()
if index == 2:
self.toggles[2].fill_menu(menu)
else:
self.toggles[index].fill_menu(menu)
for tg in (self.my_sections, self.opp_sections):
sub = StayOpenMenu(f"{tg.title} sections", menu)
tg.fill_menu(sub)
menu.addMenu(sub)
menu.addSeparator()
arrange = menu.addAction("Arrange " + ("stats" if index == 2 else "deck sections") + "…")
arrange.triggered.connect(self._open_arrange)
def _open_arrange(self) -> None:
index = self.stack.currentIndex()
title = "Arrange turn stats" if index == 2 else "Arrange deck sections"
ArrangeDialog(self._arrangeable(), title, self).exec()
def show_view(self, index: int) -> None:
index = max(0, min(len(VIEWS) - 1, index))
self.customize.setEnabled(index != HISTORY)
# Move the shared deck view to wherever it is needed.
if index == 1:
self.match_view.deck_slot_layout.addWidget(self.deck_view)
else:
self.deck_page_layout.addWidget(self.deck_view)
self.stack.setCurrentIndex(index)
self.nav.button(index).setChecked(True)
self._settings.setValue("view", index)
self._force_refresh()
# -- menu actions ----------------------------------------------------------
def _choose_log(self, replay_rate: float | None) -> None:
start = str(self.follower.path.parent) if self.follower else str(Path.home())
@@ -138,7 +272,7 @@ class MainWindow(QMainWindow):
self.images.clear()
def _on_cache_size(self, size: int) -> None:
self.status_cache.setText(f"Image cache: {size / 1_048_576:.1f} / {self.images.max_bytes / 1_048_576:.0f} MB")
self.status_cache.setText(f"Image cache {size / 1_048_576:.1f} / {self.images.max_bytes / 1_048_576:.0f} MB")
# -- log -----------------------------------------------------------------
@@ -147,6 +281,7 @@ class MainWindow(QMainWindow):
self.follower.stop()
self.follower.wait(2000)
self._reset_tracker()
self.replaying = replay_rate is not None
self.follower = LogFollower(path, replay_rate)
self.follower.events.connect(self._on_events)
self.follower.status.connect(self.status_log.setText)
@@ -155,13 +290,56 @@ class MainWindow(QMainWindow):
def _reset_tracker(self) -> None:
self.tracker = MatchTracker(card_lookup=self.cards)
self._rendered_version = -1
self.recorder.reset()
self._force_refresh()
def _on_events(self, events: list) -> None:
saved = False
for ev in events:
self.tracker.handle(ev)
saved |= self.recorder.observe(ev, self.tracker)
if saved:
self.history_view.set_games(self.history.games())
# -- history ---------------------------------------------------------------
def import_history(self, path: Path) -> None:
worker = HistoryImport(path, self.history, self.cards)
worker.done.connect(lambda n: n and self.history_view.set_games(self.history.games()))
worker.finished.connect(lambda: self._importers.remove(worker))
self._importers.append(worker)
worker.start()
def open_past_game(self, match_id: str, game_number: int) -> None:
summary = next((g for g in self.history.games()
if g.match_id == match_id and g.game_number == game_number), None)
events = self.history.events(match_id)
if summary is None or not events:
return
tracker = replay_game(events, game_number, self.cards)
when = fmt_when(summary.started_ms)
result = {"Won": "won", "Lost": "lost"}.get(summary.result, "")
text = (f"Viewing a past game: <b>vs {summary.opponent or '?'}</b> · {when}"
+ (f" · game {game_number} of 3" if summary.best_of == 3 else "")
+ (f" · you {result}" if result else ""))
self.past = (tracker, text)
self.past_label.setText(text)
self.past_bar.show()
self.turn_view.selected = None
self.show_view(0)
def close_past_game(self) -> None:
self.past = None
self.past_bar.hide()
self._force_refresh()
@property
def shown_tracker(self) -> MatchTracker:
return self.past[0] if self.past else self.tracker
def shutdown(self) -> None:
for worker in list(self._importers):
worker.wait(5000)
if self.follower:
self.follower.stop()
self.follower.wait(2000)
@@ -174,117 +352,69 @@ class MainWindow(QMainWindow):
# -- rendering -------------------------------------------------------------
def _rows(self, counts: Counter[int], note=None, dim: bool = False) -> list[Row]:
rows = []
for grp, n in counts.items():
if n <= 0:
continue
info = self.cards.get(grp)
rows.append(Row(info=info, count=n, note=note(grp, n) if note else "", dim=dim))
return rows
def now_ms(self) -> int:
"""Wall clock when following live; the newest log time when replaying or viewing the past."""
t = self.shown_tracker
if (self.replaying or self.past) and t.last_ms:
return t.last_ms
return int(time.time() * 1000)
def _force_refresh(self) -> None:
self._rendered.clear()
self.refresh()
def _tick(self) -> None:
if self.stack.currentIndex() == 1:
self.match_view.tick(self.shown_tracker, self.now_ms())
def refresh(self) -> None:
t = self.tracker
if t.version == self._rendered_version:
t = self.shown_tracker
index = self.stack.currentIndex()
key = (id(t), t.version)
if self._rendered.get(index) == key:
return
self._rendered_version = t.version
self._rendered[index] = key
self._render_header(t)
self.deck_view.preview.parentWidget().setVisible(self.toggles[0].on("preview"))
if index in (0, 1):
self.deck_view.render(t)
if index == 1:
self.match_view.render(t, self.now_ms())
elif index == 2:
self.turn_view.render(t)
def _render_header(self, t: MatchTracker) -> None:
g = t.game
self._render_header(t, g)
if g is None:
self._render_idle(t)
return
self._render_mine(t, g)
self._render_opp(t, g)
def _render_header(self, t: MatchTracker, g: GameState | None) -> None:
if g is None:
self.header.setText("Waiting for a match…")
self.warning.setVisible(not self.cards.available)
return
opp = t.opponent_name or "opponent"
parts = [f"<b>vs {opp}</b>"]
if t.event_id:
parts.append(t.event_id.replace("_", " "))
parts.append(f"Game {g.game_number}")
if g.turn:
whose = "your" if g.active_player == g.my_seat else "their"
parts.append(f"Turn {g.turn} ({whose})")
if g.life:
parts.append(f"Life: you {g.life.get(g.my_seat, '?')} · them {g.life.get(g.opp_seat, '?')}")
if t.game_results:
w = t.game_results.count("Won")
parts.append(f"Games {w}-{len(t.game_results) - w}")
if not t.in_match and t.last_result:
parts.append(f"<b>Match {t.last_result.lower()}</b>")
self.header.setText(" · ".join(parts))
lib_size = g.zone_size("ZoneType_Library", g.my_seat)
unaccounted = sum(t.my_library_remaining().values()) - lib_size if g.zones else 0
if g.desynced or unaccounted:
self.warning.setText(
"Part of the game log was skipped by MTGA (oversized message), so some zones may be "
"out of date until they next change."
+ (f" {unaccounted} card(s) of yours are unaccounted for." if unaccounted > 0 else "")
)
self.warning.show()
self.header.setText("<span style='color:#8b949c'>Waiting for a match…</span>")
else:
self.warning.setVisible(not self.cards.available)
parts = [f"<b>vs {t.opponent_name or 'opponent'}</b>"]
if t.event_id:
parts.append(t.event_id.replace("_", " "))
parts.append(f"Game {g.game_number}")
if g.turn:
parts.append(f"Turn {g.turn}")
if not t.in_match and t.last_result:
parts.append(f"<b>Match {t.last_result.lower()}</b>")
self.header.setText(" · ".join(parts))
def _render_idle(self, t: MatchTracker) -> None:
sections = []
if d := t.submitted_deck:
sections.append(Section("deck", f"{d.name or 'Last submitted deck'} ({sum(d.main.values())})",
self._rows(d.main)))
if d.sideboard:
sections.append(Section("side", f"Sideboard ({sum(d.sideboard.values())})",
self._rows(d.sideboard), collapsed_by_default=True))
self.mine.set_sections(sections)
self.opp.set_sections([])
def _render_mine(self, t: MatchTracker, g: GameState) -> None:
me = g.my_seat
lib_size = g.zone_size("ZoneType_Library", me)
remaining = t.my_library_remaining()
denom = lib_size or sum(remaining.values()) or 1
def pct(_grp: int, n: int) -> str:
return f"{100 * n / denom:.0f}%"
# Every card in the deck; ones with no copies left stay listed, greyed out.
lib_rows = [
Row(self.cards.get(grp), n, pct(grp, n) if n else "", dim=not n)
for grp in g.deck
for n in [remaining.get(grp, 0)]
]
sections = [
Section("library", f"Library ({lib_size})", lib_rows),
Section("hand", f"Hand ({g.zone_size('ZoneType_Hand', me)})", self._rows(g.cards_in("ZoneType_Hand", owner=me))),
Section("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=me))),
Section("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', me)})",
self._rows(g.cards_in("ZoneType_Graveyard", owner=me))),
Section("exile", "Exile", self._rows(g.cards_in("ZoneType_Exile", owner=me))),
Section("sideboard", f"Sideboard ({sum(g.sideboard.values())})", self._rows(g.sideboard),
collapsed_by_default=True),
]
self.mine.set_sections([s for s in sections if s.rows or s.key in ("library", "hand")])
def _render_opp(self, t: MatchTracker, g: GameState) -> None:
opp = g.opp_seat
hand_size = g.zone_size("ZoneType_Hand", opp)
lib_size = g.zone_size("ZoneType_Library", opp)
known_hand = g.opp_known_in("ZoneType_Hand")
known_lib = g.opp_known_in("ZoneType_Library")
seen = t.opp_seen_match
sections = [
Section("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=opp))),
Section("stack", "Stack", self._rows(g.cards_in("ZoneType_Stack", owner=opp))),
Section("hand", f"Hand ({hand_size}, {sum(known_hand.values())} known)", self._rows(known_hand)),
Section("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', opp)})",
self._rows(g.cards_in("ZoneType_Graveyard", owner=opp))),
Section("exile", "Exile", self._rows(g.cards_in("ZoneType_Exile", owner=opp))),
Section("library", f"Library ({lib_size}, {sum(known_lib.values())} known)", self._rows(known_lib)),
Section("seen", f"Seen this match ({sum(seen.values())} cards)",
[r for r in self._rows(seen) if not r.info.is_token]),
]
self.opp.set_sections([s for s in sections if s.rows or s.key in ("battlefield", "hand", "seen")])
messages = []
if not self.cards.available:
messages.append("MTGA card database not found: card names are unavailable. "
"Set mtga_dir in ~/.config/mtga-assistant/config.toml.")
# MTGA sometimes leaves oversized messages out of the log. Usually later messages fill the
# gap in; only flag it (quietly) while some of my cards are still not accounted for.
unaccounted = 0
if g is not None and g.zones:
lib_size = g.zone_size("ZoneType_Library", g.my_seat)
unaccounted = sum(t.my_library_remaining().values()) - lib_size
if unaccounted > 0:
self.status_gap.setText(f"⚠ {unaccounted} of your cards not tracked")
self.status_gap.setToolTip(
"MTGA left part of this game out of its log (an oversized message), so "
f"{unaccounted} of your cards that left the library weren't seen. Library odds may be "
"slightly off until those cards show up again."
)
self.status_gap.setVisible(unaccounted > 0)
self.warning.setText("\n".join(messages))
self.warning.setVisible(bool(messages))