Merge views into Match, expand history, and polish the UI

- Match page now holds players/match info, turn stats with a turn
  picker, and the deck lists (My deck · Preview · Opponent); the Deck
  and Turn tabs are gone. Section heights are draggable and remembered.
- History: sortable, resizable columns; deck and commander columns;
  right-click to show only or hide games with a value, with filter
  chips; stats tiles (win rate overall, last 10/100, last 7 days,
  play/draw, average length and turns) that follow the filters.
  Commanders are backfilled for games recorded before they were stored.
- Deck panel shows copies per zone against the deck total (e.g. 2/5).
- Preview can be cleared with ✕, a click on the image, or Esc.
- Customize menu stays open while toggling; Arrange covers deck
  sections and turn stats together.
- Fix squeezed nav buttons and the header pushing controls off-screen.
- Fix known opponent cards being double-counted when also revealed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aeth3randClaude Opus 5.5 committed 2026-10-16 12:34:02 -04:00
1 parent 5b3c8c8e63
commit b3325e8ecc
15 files changed
+754 -177

No files matched your search

+22 -23
View File
@@ -3,31 +3,30 @@
A native Linux deck and game tracker for **Magic: The Gathering Arena** running under Steam/Proton.
It follows MTGA's `Player.log`, much like Untapped.gg and other trackers do, and shows:
Switch views with the buttons in the top bar (or Ctrl+1/2/3):
Two views, switched from the top bar (Ctrl+1 / Ctrl+2):
- **Deck**
- *My deck*: remaining library with draw odds, hand, battlefield, graveyard, exile, sideboard.
Between matches, it shows the last deck you submitted.
- *Opponent*: battlefield, stack, graveyard, exile, cards *known* to be in their hand or library
(cards you saw that went back into a hidden zone, or were revealed), and every card seen this match.
- *Card preview*: hover a card to see its image from [Scryfall](https://scryfall.com).
- **Match**: life totals, poison/energy and other player counters, emblems, hand/library/graveyard
counts, turn timer and timeouts used, turn/phase, Bo3 score and game time. The deck view fills
the bottom half.
- **Turn**: what happened this turn, for you, your opponent and in total: cards drawn, spells cast,
lands played, creatures died, life gained/lost, damage, discards, mills, exiles, counters and
tokens, plus land drops used/allowed. Use ◀ ▶ to browse earlier turns; the "Whole game" table
shows game totals.
- **History**: every finished game with date, event, opponent, both decks' colours, who was on
the play, the result, turns and length. Click a game to open it in the Deck/Match/Turn views
("Back to live" returns). Games from the previous MTGA session (`Player-prev.log`) are imported
on startup.
- **Match**, from top to bottom (drag the dividers to resize; sizes are remembered):
- *You · Match · Opponent*: life totals, poison/energy and other player counters, commanders,
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,
game time).
- *Selected turn · Turn · Whole game*: what happened in a turn for you, your opponent and in
total (cards drawn, spells cast, lands played, creatures died, life gained/lost, damage,
discards, mills, exiles, counters, tokens, land drops). The middle panel picks the turn
(◀ ▶, or *Live* to follow the current one).
- *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
match; hover a card for its [Scryfall](https://scryfall.com) image.
- **History**: stats (win rate overall, last 10/100 games, last 7 days, on the play/draw, average
length and turns) and a sortable list of every finished game: date, event, deck, commanders,
opponent, both decks' colours, who was on the play, result, turns and length. Click a header to
sort, right-click a cell to show only (or hide) games with that value; the stats follow the
filters. Click a game to open it in the Match view ("Back to live" returns). Games from the
previous MTGA session (`Player-prev.log`) are imported on startup.
The Match view also shows who was on the play and, in best-of-three, who has been on the draw more.
*⚙ Customize* shows or hides the items in the current view. *Customize → Arrange…* lets you
reorder (drag, or ↑/↓) and show/hide the sections of the My deck and Opponent panels, or the
rows of the turn view. Your choices are remembered.
*⚙ Customize* shows or hides the match panels, turn stats, deck sections and the preview.
*Customize → Arrange…* reorders (drag, or ↑/↓) and shows/hides the My deck and Opponent sections
and the turn stats. Your choices are remembered.
Card names, mana costs and types come from MTGA's own local card database, so they work offline.
+23 -14
View File
@@ -415,22 +415,31 @@ class GameState:
return len(z.ids) if z else 0
def opp_known_in(self, ztype: str) -> Counter[int]:
"""Opponent cards we know are in a hidden zone (by tracking identity)."""
out: Counter[int] = Counter()
"""Opponent cards we know are in a hidden zone (by tracking identity, or revealed)."""
z = self.zone_of_type(ztype, self.opp_seat)
if z:
for iid in z.ids:
if iid in self.known:
out[self.known[iid]] += 1
# Cards the opponent revealed from that zone (e.g. "reveal your hand").
if z:
for obj in self.objects.values():
if (obj.get("type") == "GameObjectType_RevealedCard" and obj.get("zoneId") == z.zone_id
and obj.get("grpId")):
out[obj["grpId"]] += 1
if z is None:
return Counter()
tracked: Counter[int] = Counter(self.known[iid] for iid in z.ids if iid in self.known)
if ztype == "ZoneType_Library":
out += self.opp_library_unplaced
return out
tracked += self.opp_library_unplaced
# Cards the opponent revealed from that zone (e.g. "reveal your hand"). A revealed card is
# often one we already track, so per card take the larger count rather than the sum.
revealed: Counter[int] = Counter(
obj["grpId"] for obj in self.objects.values()
if obj.get("type") == "GameObjectType_RevealedCard" and obj.get("zoneId") == z.zone_id
and obj.get("grpId")
)
out = tracked | revealed
# Never claim to know more cards than the zone holds.
excess = sum(out.values()) - len(z.ids)
for grp in list(revealed):
if excess <= 0:
break
take = min(excess, out[grp] - tracked[grp])
if take > 0:
out[grp] -= take
excess -= take
return +out
def opp_seen_counts(self, normalize=lambda g: g) -> Counter[int]:
merged: dict[int, set[int]] = {}
+50 -4
View File
@@ -44,6 +44,8 @@ CREATE TABLE IF NOT EXISTS games (
opp_colors TEXT,
turns INTEGER,
best_of INTEGER,
my_commander TEXT, -- commander name(s), ' / '-separated; NULL = not yet worked out
opp_commander TEXT,
PRIMARY KEY (match_id, game_number)
);
CREATE TABLE IF NOT EXISTS matches (match_id TEXT PRIMARY KEY, events BLOB NOT NULL);
@@ -66,9 +68,15 @@ class GameSummary:
opp_colors: str
turns: int
best_of: int
my_commander: str | None = ""
opp_commander: str | None = ""
replayable: bool = True
GAME_COLUMNS = ("match_id, game_number, started_ms, ended_ms, event_id, opponent, deck_name, on_play, result, "
"my_colors, opp_colors, turns, best_of, my_commander, opp_commander")
# -- event (de)serialisation ------------------------------------------------------
@@ -118,6 +126,11 @@ class HistoryStore:
self._lock = threading.Lock()
self._conn = sqlite3.connect(path, check_same_thread=False)
self._conn.executescript(SCHEMA)
# Columns added after the first release.
existing = {row[1] for row in self._conn.execute("PRAGMA table_info(games)")}
for column in ("my_commander", "opp_commander"):
if column not in existing:
self._conn.execute(f"ALTER TABLE games ADD COLUMN {column} TEXT")
self._conn.commit()
def has_match(self, match_id: str) -> bool:
@@ -128,9 +141,10 @@ class HistoryStore:
blob = gzip.compress(json.dumps(events, separators=(",", ":")).encode())
with self._lock:
self._conn.executemany(
"INSERT OR IGNORE INTO games VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
f"INSERT OR IGNORE INTO games ({GAME_COLUMNS}) VALUES ({','.join('?' * 15)})",
[(g.match_id, g.game_number, g.started_ms, g.ended_ms, g.event_id, g.opponent, g.deck_name,
g.on_play, g.result, g.my_colors, g.opp_colors, g.turns, g.best_of) for g in games],
g.on_play, g.result, g.my_colors, g.opp_colors, g.turns, g.best_of,
g.my_commander, g.opp_commander) for g in games],
)
self._conn.execute("INSERT OR IGNORE INTO matches VALUES (?, ?)", (match_id, blob))
self._prune()
@@ -155,10 +169,34 @@ class HistoryStore:
def games(self) -> list[GameSummary]:
with self._lock:
rows = self._conn.execute(
"""SELECT g.*, m.match_id IS NOT NULL FROM games g LEFT JOIN matches m USING (match_id)
f"""SELECT {', '.join('g.' + c for c in GAME_COLUMNS.split(', '))}, m.match_id IS NOT NULL
FROM games g LEFT JOIN matches m USING (match_id)
ORDER BY g.started_ms DESC, g.game_number DESC"""
).fetchall()
return [GameSummary(*r[:13], replayable=bool(r[13])) for r in rows]
return [GameSummary(*r[:15], replayable=bool(r[15])) for r in rows]
def backfill_commanders(self, cards) -> int:
"""Work out commanders for games recorded before they were stored. Returns rows updated."""
with self._lock:
todo = self._conn.execute(
"""SELECT g.match_id, g.game_number FROM games g JOIN matches m USING (match_id)
WHERE g.my_commander IS NULL OR g.opp_commander IS NULL"""
).fetchall()
updated = 0
events_by_match: dict[str, list[Event]] = {}
for match_id, game_number in todo:
if match_id not in events_by_match:
events_by_match[match_id] = self.events(match_id)
t = replay_game(events_by_match[match_id], game_number, cards)
if t.game is None:
continue
mine, theirs = commander_names(cards, t.game, t.game.my_seat), commander_names(cards, t.game, t.game.opp_seat)
with self._lock:
self._conn.execute("UPDATE games SET my_commander = ?, opp_commander = ? "
"WHERE match_id = ? AND game_number = ?", (mine, theirs, match_id, game_number))
self._conn.commit()
updated += 1
return updated
def events(self, match_id: str) -> list[Event]:
with self._lock:
@@ -191,6 +229,13 @@ def colors_of(cards, grp_ids) -> str:
return "".join(c for c in "WUBRG" if c in found)
def commander_names(cards, g: GameState, seat: int) -> str:
grps = sorted(g.commanders.get(seat, ()))
if cards is None:
return " / ".join(str(grp) for grp in grps)
return " / ".join(cards.get(grp).name for grp in grps)
def summarize(t: MatchTracker, g: GameState, result: str) -> GameSummary:
cards = t.card_lookup
my_cards = list(g.deck) + list(g.commanders.get(g.my_seat, ()))
@@ -201,6 +246,7 @@ def summarize(t: MatchTracker, g: GameState, result: str) -> GameSummary:
event_id=t.event_id, opponent=t.opponent_name, deck_name=t.deck_name, on_play=on_play,
result=result, my_colors=colors_of(cards, my_cards), opp_colors=colors_of(cards, opp_cards),
turns=g.turn, best_of=g.best_of,
my_commander=commander_names(cards, g, g.my_seat), opp_commander=commander_names(cards, g, g.opp_seat),
)
+103
View File
@@ -0,0 +1,103 @@
"""Filtering, sorting keys and summary statistics for game history. Pure Python."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .history import GameSummary
WEEK_MS = 7 * 24 * 3600 * 1000
# Fields a game can be filtered on: key -> (column label, value getter, value display).
FILTER_FIELDS: dict[str, tuple[str, Any, Any]] = {
"event": ("Event", lambda g: g.event_id or "", lambda v: (v or "?").replace("_", " ")),
"deck": ("Deck", lambda g: g.deck_name or "", lambda v: v or "(no name)"),
"my_commander": ("Your commander", lambda g: g.my_commander or "", lambda v: v or "(none)"),
"opponent": ("Opponent", lambda g: g.opponent or "", lambda v: v or "?"),
"opp_commander": ("Their commander", lambda g: g.opp_commander or "", lambda v: v or "(none)"),
"game": ("Game", lambda g: "Bo1" if g.best_of != 3 else f"Game {g.game_number} of 3", lambda v: v),
"on_play": ("On the play", lambda g: g.on_play or "",
lambda v: {"me": "you on the play", "opp": "you on the draw"}.get(v, "unknown")),
"my_colors": ("Your colours", lambda g: g.my_colors or "", lambda v: v or "colourless"),
"opp_colors": ("Their colours", lambda g: g.opp_colors or "", lambda v: v or "colourless"),
"result": ("Result", lambda g: g.result or "", lambda v: v or "no result"),
"turns": ("Turns", lambda g: g.turns or 0, lambda v: str(v)),
}
@dataclass(frozen=True)
class Filter:
field: str
value: Any
exclude: bool = False
def matches(self, g: GameSummary) -> bool:
return (FILTER_FIELDS[self.field][1](g) == self.value) != self.exclude
@property
def label(self) -> str:
name, _get, show = FILTER_FIELDS[self.field]
return f"{name} {'≠' if self.exclude else '='} {show(self.value)}"
def apply_filters(games: list[GameSummary], filters: list[Filter]) -> list[GameSummary]:
return [g for g in games if all(f.matches(g) for f in filters)]
def length_seconds(g: GameSummary) -> int | None:
if not g.started_ms or not g.ended_ms:
return None
return max(0, (g.ended_ms - g.started_ms) // 1000)
@dataclass
class Record:
wins: int = 0
losses: int = 0
@property
def games(self) -> int:
return self.wins + self.losses
@property
def rate(self) -> float | None:
return self.wins / self.games if self.games else None
def record(games: list[GameSummary]) -> Record:
return Record(sum(g.result == "Won" for g in games), sum(g.result == "Lost" for g in games))
@dataclass
class HistoryStats:
games: int
overall: Record
last_10: Record
last_100: Record
week_games: int
week: Record
on_play: Record
on_draw: Record
avg_length_s: float | None
avg_turns: float | None
def compute_stats(games: list[GameSummary], now_ms: int) -> HistoryStats:
newest_first = sorted(games, key=lambda g: g.started_ms or 0, reverse=True)
decided = [g for g in newest_first if g.result in ("Won", "Lost")]
week = [g for g in newest_first if g.started_ms and now_ms - g.started_ms <= WEEK_MS]
lengths = [s for g in games if (s := length_seconds(g)) is not None]
turns = [g.turns for g in games if g.turns]
return HistoryStats(
games=len(games),
overall=record(decided),
last_10=record(decided[:10]),
last_100=record(decided[:100]),
week_games=len(week),
week=record(week),
on_play=record([g for g in games if g.on_play == "me"]),
on_draw=record([g for g in games if g.on_play == "opp"]),
avg_length_s=sum(lengths) / len(lengths) if lengths else None,
avg_turns=sum(turns) / len(turns) if turns else None,
)
+9 -2
View File
@@ -40,6 +40,13 @@ class Row:
count: int = 1
note: str = ""
dim: bool = False
total: int | None = None # copies in the deck; shown as "count/total" when set
@property
def count_text(self) -> str:
if self.total:
return f"{self.count}/{self.total}"
return f"{self.count}×" if self.count else ""
@dataclass
@@ -144,7 +151,7 @@ class CardList(QTreeWidget):
def set_sections(self, sections: list[Section]) -> None:
# Skip rebuilding (and losing scroll position) when nothing changed.
signature = [(s.key, s.title, [(r.info.grp_id, r.count, r.note, r.dim) for r in s.rows]) for s in sections]
signature = [(s.key, s.title, [(r.info.grp_id, r.count, r.total, r.note, r.dim) for r in s.rows]) for s in sections]
if signature == self._last_render:
return
self._last_render = signature
@@ -169,7 +176,7 @@ class CardList(QTreeWidget):
self.addTopLevelItem(top)
top.setFirstColumnSpanned(True)
for row in sorted(sec.rows, key=sort_key):
it = QTreeWidgetItem([f"{row.count}×" if row.count else "", "", row.info.name, row.note])
it = QTreeWidgetItem([row.count_text, "", row.info.name, row.note])
it.setData(0, GRP_ROLE, row.info.grp_id)
it.setData(COL_MANA, MANA_ROLE, list(row.info.mana))
it.setToolTip(COL_NAME, f"{row.info.name}\n{row.info.type_line}")
+22 -9
View File
@@ -71,20 +71,26 @@ class DeckView(QWidget):
self.preview = CardPreview(cards, images)
self.mine.hovered.connect(self.preview.show_card)
self.opp.hovered.connect(self.preview.show_card)
self.preview.cleared.connect(self._clear_selection)
split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(card_frame("My deck", self.mine))
split.addWidget(card_frame("Opponent", self.opp))
split.addWidget(card_frame("Preview", self.preview))
split.addWidget(card_frame("Opponent", self.opp))
split.setStretchFactor(0, 3)
split.setStretchFactor(1, 3)
split.setStretchFactor(2, 2)
split.setStretchFactor(1, 2)
split.setStretchFactor(2, 3)
split.setChildrenCollapsible(False)
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(split)
def _clear_selection(self) -> None:
for lst in (self.mine, self.opp):
lst.setCurrentItem(None)
lst.clearSelection()
def _rows(self, counts: Counter[int], note=None, dim: bool = False) -> list[Row]:
return [
Row(info=self.cards.get(grp), count=n, note=note(grp, n) if note else "", dim=dim)
@@ -131,19 +137,26 @@ class DeckView(QWidget):
# 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)
Row(self.cards.get(grp), n, pct(grp, n) if n else "", dim=not n, total=g.deck[grp])
for grp in g.deck
for n in [remaining.get(grp, 0)]
]
def zone_rows(counts: Counter[int]) -> list[Row]:
"""Rows showing "in this zone / copies in the deck" for cards from my deck list."""
rows = self._rows(counts)
for row in rows:
row.total = g.deck.get(t.normalize(row.info.grp_id)) or None
return rows
sections = self._commander_section(g, me) + [
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("stack", "Stack", self._rows(g.cards_in("ZoneType_Stack", owner=me))),
Section("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=me))),
Section("hand", f"Hand ({g.zone_size('ZoneType_Hand', me)})", zone_rows(g.cards_in("ZoneType_Hand", owner=me))),
Section("stack", "Stack", zone_rows(g.cards_in("ZoneType_Stack", owner=me))),
Section("battlefield", "Battlefield", zone_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))),
zone_rows(g.cards_in("ZoneType_Graveyard", owner=me))),
Section("exile", "Exile", zone_rows(g.cards_in("ZoneType_Exile", owner=me))),
Section("sideboard", f"Sideboard ({sum(g.sideboard.values())})", self._rows(g.sideboard),
collapsed_by_default=True),
]
+276 -39
View File
@@ -1,16 +1,21 @@
"""History view: games played; click one to open it in the deck/match/turn views."""
"""History view: stats, sortable/filterable list of games; click one to open it."""
from __future__ import annotations
import time
from datetime import datetime
from PySide6.QtCore import Qt, Signal
from PySide6.QtCore import QPoint, QSettings, Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QAbstractItemView,
QFrame,
QGridLayout,
QHBoxLayout,
QHeaderView,
QLabel,
QMenu,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
@@ -18,13 +23,26 @@ from PySide6.QtWidgets import (
)
from ..history import GameSummary
from ..history_stats import FILTER_FIELDS, Filter, Record, apply_filters, compute_stats, length_seconds
from . import theme
from .card_list import MANA_ROLE, ManaDelegate
from .deck_view import card_frame
COLUMNS = ["When", "Event", "Opponent", "Game", "On the play", "You", "Them", "Result", "Turns", "Length"]
COL_PLAY, COL_MINE, COL_THEIRS, COL_RESULT = 4, 5, 6, 7
COLUMNS = ["When", "Event", "Deck", "Your commander", "Opponent", "Their commander", "Game", "On the play",
"You", "Them", "Result", "Turns", "Length"]
(COL_WHEN, COL_EVENT, COL_DECK, COL_MY_CMDR, COL_OPPONENT, COL_OPP_CMDR, COL_GAME, COL_PLAY, COL_MINE,
COL_THEIRS, COL_RESULT, COL_TURNS, COL_LENGTH) = range(len(COLUMNS))
COLUMN_STATE_KEY = "history/columns-v2" # bump when the column set changes
SORT_KEY = "history/sort"
KEY_ROLE = Qt.ItemDataRole.UserRole + 20
SORT_ROLE = Qt.ItemDataRole.UserRole + 21
# Right-click filtering: column -> field in history_stats.FILTER_FIELDS (not offered for When/Length).
COLUMN_FIELDS = {
COL_EVENT: "event", COL_DECK: "deck", COL_MY_CMDR: "my_commander", COL_OPPONENT: "opponent",
COL_OPP_CMDR: "opp_commander", COL_GAME: "game", COL_PLAY: "on_play", COL_MINE: "my_colors",
COL_THEIRS: "opp_colors", COL_RESULT: "result", COL_TURNS: "turns",
}
def fmt_when(ms: int | None) -> str:
@@ -37,18 +55,98 @@ def fmt_when(ms: int | None) -> str:
return dt.strftime("%b %d %H:%M")
def _fmt_length(g: GameSummary) -> str:
if not g.started_ms or not g.ended_ms:
def fmt_seconds(sec: float | None) -> str:
if sec is None:
return ""
sec = max(0, (g.ended_ms - g.started_ms) // 1000)
sec = int(sec)
return f"{sec // 60}:{sec % 60:02}"
def sort_key(g: GameSummary, col: int):
"""Comparable key for a column (all keys in a column share a type)."""
if col == COL_WHEN:
return g.started_ms or 0
if col == COL_GAME:
return (g.best_of, g.game_number)
if col in (COL_MINE, COL_THEIRS):
colors = g.my_colors if col == COL_MINE else g.opp_colors
return (len(colors or ""), tuple("WUBRG".index(c) for c in colors or ""))
if col == COL_TURNS:
return g.turns or 0
if col == COL_LENGTH:
return length_seconds(g) or 0
field = COLUMN_FIELDS.get(col)
return str(FILTER_FIELDS[field][1](g)).lower() if field else ""
class SortItem(QTableWidgetItem):
def __lt__(self, other: QTableWidgetItem) -> bool:
return self.data(SORT_ROLE) < other.data(SORT_ROLE)
class StatTile(QFrame):
def __init__(self, title: str, parent=None):
super().__init__(parent)
self.setObjectName("card")
self.title = QLabel(title)
self.title.setObjectName("muted")
self.value = QLabel()
self.value.setObjectName("statValue")
self.sub = QLabel()
self.sub.setObjectName("muted")
lay = QVBoxLayout(self)
lay.setContentsMargins(12, 8, 12, 10)
lay.setSpacing(2)
lay.addWidget(self.title)
lay.addWidget(self.value)
lay.addWidget(self.sub)
def show_record(self, rec: Record, extra: str = "") -> None:
if rec.rate is None:
self.value.setText("–")
else:
color = theme.MINT_TEXT if rec.rate >= 0.5 else theme.DANGER
self.value.setText(f"<span style='color:{color}'>{rec.rate:.0%}</span>")
self.sub.setText(" · ".join(x for x in (f"{rec.wins}–{rec.losses}", extra) if x))
def show_value(self, value: str, sub: str = "") -> None:
self.value.setText(value or "–")
self.sub.setText(sub)
class HistoryView(QWidget):
open_game = Signal(str, int) # match_id, game_number
def __init__(self, parent=None):
super().__init__(parent)
self._settings = QSettings("mtga-assistant", "mtga-assistant")
self._columns_ready = False
self.all_games: list[GameSummary] = []
self.shown: list[GameSummary] = []
self.filters: list[Filter] = []
# Stats tiles
self.tiles = {
"overall": StatTile("Win rate"),
"last10": StatTile("Last 10 games"),
"last100": StatTile("Last 100 games"),
"week": StatTile("Last 7 days"),
"play": StatTile("On the play"),
"draw": StatTile("On the draw"),
"length": StatTile("Avg game length"),
"turns": StatTile("Avg turns"),
}
tiles = QGridLayout()
tiles.setSpacing(8)
for i, tile in enumerate(self.tiles.values()):
tiles.addWidget(tile, 0, i)
# Filter chips
self.chips_bar = QWidget()
self.chips = QHBoxLayout(self.chips_bar)
self.chips.setContentsMargins(0, 0, 0, 0)
# Table
self.table = QTableWidget(0, len(COLUMNS))
self.table.setHorizontalHeaderLabels(COLUMNS)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
@@ -59,69 +157,200 @@ class HistoryView(QWidget):
self.table.setMouseTracking(True)
self.table.verticalHeader().hide()
self.table.verticalHeader().setDefaultSectionSize(36)
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.table.customContextMenuRequested.connect(self._context_menu)
hdr = self.table.horizontalHeader()
hdr.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
# User-resizable columns; widths are remembered (see _restore_or_size_columns).
hdr.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
hdr.setStretchLastSection(False)
hdr.setMinimumSectionSize(40)
hdr.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
for col in (COL_MINE, COL_THEIRS):
self.table.setItemDelegateForColumn(col, ManaDelegate(self.table))
hdr.setSortIndicatorShown(True)
hdr.setSectionsClickable(True)
col, order = self._saved_sort()
hdr.setSortIndicator(col, order)
hdr.sectionResized.connect(self._save_columns)
hdr.sortIndicatorChanged.connect(self._save_sort)
for c in (COL_MINE, COL_THEIRS):
self.table.setItemDelegateForColumn(c, ManaDelegate(self.table))
self.table.cellClicked.connect(self._clicked)
self.table.cellActivated.connect(self._clicked)
self.summary = QLabel()
self.summary.setObjectName("muted")
self.empty = QLabel("No games recorded yet. Finished matches show up here.")
self.empty = QLabel()
self.empty.setObjectName("muted")
self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
top = QHBoxLayout()
top.addWidget(self.summary, 1)
hint = QLabel("Click a game to open it")
hint = QLabel("Click a game to open it · right-click to filter · click a header to sort")
hint.setObjectName("muted")
top = QHBoxLayout()
top.addWidget(self.chips_bar, 1)
top.addWidget(hint)
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addLayout(tiles)
lay.addLayout(top)
lay.addWidget(card_frame("Games played", self.table), 1)
lay.addWidget(self.empty)
def _clicked(self, row: int, _col: int) -> None:
# -- persistence -----------------------------------------------------------------
def _saved_sort(self) -> tuple[int, Qt.SortOrder]:
saved = self._settings.value(SORT_KEY)
try:
col, order = (int(x) for x in str(saved).split(":"))
if 0 <= col < len(COLUMNS):
return col, Qt.SortOrder(order)
except (TypeError, ValueError):
pass
return COL_WHEN, Qt.SortOrder.DescendingOrder
def _save_sort(self, col: int, order: Qt.SortOrder) -> None:
self._settings.setValue(SORT_KEY, f"{col}:{order.value}")
def _restore_or_size_columns(self) -> None:
"""First time there are rows: restore saved widths, or size columns to fit their contents."""
self._columns_ready = True
saved = self._settings.value(COLUMN_STATE_KEY)
hdr = self.table.horizontalHeader()
sort = (hdr.sortIndicatorSection(), hdr.sortIndicatorOrder())
if saved is not None and hdr.restoreState(saved):
hdr.setSortIndicator(*sort) # the separately saved sort wins
return
self.table.resizeColumnsToContents()
pips = 5 * (ManaDelegate.PIP + 2) + 16 # room for all five colours
for col in (COL_MINE, COL_THEIRS):
self.table.setColumnWidth(col, max(self.table.columnWidth(col), pips))
self.table.setColumnWidth(COL_OPPONENT, max(self.table.columnWidth(COL_OPPONENT) + 24, 180))
for col in range(len(COLUMNS)):
# Breathing room (and the sort arrow), but don't let long names take over the table.
self.table.setColumnWidth(col, min(self.table.columnWidth(col) + 24, 260))
self._save_columns()
def _save_columns(self, *_args) -> None:
if self._columns_ready:
self._settings.setValue(COLUMN_STATE_KEY, self.table.horizontalHeader().saveState())
# -- interaction --------------------------------------------------------------------
def _game_at_row(self, row: int) -> GameSummary | None:
item = self.table.item(row, 0)
if item is None:
return
match_id, game_number, replayable = item.data(KEY_ROLE)
if replayable:
self.open_game.emit(match_id, game_number)
return None
match_id, game_number = item.data(KEY_ROLE)
return next((g for g in self.shown if g.match_id == match_id and g.game_number == game_number), None)
def _clicked(self, row: int, _col: int) -> None:
g = self._game_at_row(row)
if g is not None and g.replayable:
self.open_game.emit(g.match_id, g.game_number)
def _context_menu(self, pos: QPoint) -> None:
index = self.table.indexAt(pos)
menu = QMenu(self)
g = self._game_at_row(index.row()) if index.isValid() else None
field = COLUMN_FIELDS.get(index.column()) if index.isValid() else None
if g is not None and field is not None:
value = FILTER_FIELDS[field][1](g)
only, hide = Filter(field, value), Filter(field, value, exclude=True)
menu.addAction(f"Only show: {only.label}").triggered.connect(lambda: self.add_filter(only))
menu.addAction(f"Hide: {only.label}").triggered.connect(lambda: self.add_filter(hide))
if self.filters:
if not menu.isEmpty():
menu.addSeparator()
menu.addAction("Clear all filters").triggered.connect(self.clear_filters)
if not menu.isEmpty():
menu.exec(self.table.viewport().mapToGlobal(pos))
def add_filter(self, f: Filter) -> None:
# A new filter on a field replaces an existing "only show" on the same field.
self.filters = [x for x in self.filters if not (x.field == f.field and not x.exclude and not f.exclude)]
if f not in self.filters:
self.filters.append(f)
self._refresh()
def remove_filter(self, f: Filter) -> None:
self.filters = [x for x in self.filters if x != f]
self._refresh()
def clear_filters(self) -> None:
self.filters = []
self._refresh()
# -- rendering -----------------------------------------------------------------------
def set_games(self, games: list[GameSummary]) -> None:
self.empty.setVisible(not games)
wins = sum(g.result == "Won" for g in games)
losses = sum(g.result == "Lost" for g in games)
on_play = [g for g in games if g.on_play]
play_wins = sum(g.result == "Won" for g in on_play if g.on_play == "me")
draw_wins = sum(g.result == "Won" for g in on_play if g.on_play == "opp")
n_play = sum(g.on_play == "me" for g in on_play)
n_draw = len(on_play) - n_play
self.summary.setText(
f"{len(games)} games · {wins}–{losses}"
+ (f" · on the play {play_wins}/{n_play} won" if n_play else "")
+ (f" · on the draw {draw_wins}/{n_draw} won" if n_draw else "")
)
self.all_games = games
self._refresh()
def _refresh(self) -> None:
self.shown = apply_filters(self.all_games, self.filters)
self._render_chips()
self._render_stats()
self._render_table()
if not self.all_games:
self.empty.setText("No games recorded yet. Finished matches show up here.")
else:
self.empty.setText("No games match the filters.")
self.empty.setVisible(not self.shown)
def _render_chips(self) -> None:
while self.chips.count():
w = self.chips.takeAt(0).widget()
if w is not None:
w.hide() # deleteLater is deferred; don't leave it drawn under the new chips
w.deleteLater()
if not self.filters:
label = QLabel(f"All games ({len(self.all_games)})")
label.setObjectName("muted")
self.chips.addWidget(label)
else:
label = QLabel(f"Filtered: {len(self.shown)} of {len(self.all_games)} games")
label.setObjectName("cardTitle")
self.chips.addWidget(label)
for f in self.filters:
chip = QPushButton(f"{f.label} ✕")
chip.setObjectName("chip")
chip.setToolTip("Remove this filter")
chip.clicked.connect(lambda _=False, f=f: self.remove_filter(f))
self.chips.addWidget(chip)
clear = QPushButton("Clear all")
clear.clicked.connect(self.clear_filters)
self.chips.addWidget(clear)
self.chips.addStretch(1)
def _render_stats(self) -> None:
s = compute_stats(self.shown, int(time.time() * 1000))
t = self.tiles
t["overall"].show_record(s.overall, f"{s.games} games")
t["last10"].show_record(s.last_10)
t["last100"].show_record(s.last_100)
t["week"].show_record(s.week, f"{s.week_games} played")
t["play"].show_record(s.on_play)
t["draw"].show_record(s.on_draw)
t["length"].show_value(fmt_seconds(s.avg_length_s), "per game")
t["turns"].show_value(f"{s.avg_turns:.1f}" if s.avg_turns is not None else "", "per game")
def _render_table(self) -> None:
games = self.shown
self.table.setSortingEnabled(False) # don't re-sort while filling
self.table.setRowCount(len(games))
for r, g in enumerate(games):
game_label = f"{g.game_number} of 3" if g.best_of == 3 else "Bo1"
values = [fmt_when(g.started_ms), (g.event_id or "").replace("_", " "), g.opponent or "?",
game_label, "", "", "", g.result or "–", str(g.turns or ""), _fmt_length(g)]
values = [fmt_when(g.started_ms), (g.event_id or "").replace("_", " "), g.deck_name or "",
g.my_commander or "", g.opponent or "?", g.opp_commander or "",
game_label, "", "", "", g.result or "–", str(g.turns or ""), fmt_seconds(length_seconds(g))]
for c, v in enumerate(values):
item = QTableWidgetItem(v)
item = SortItem(v)
item.setData(SORT_ROLE, sort_key(g, c))
item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
if v:
item.setToolTip(v) # full text when the column is narrower than it
if not g.replayable:
item.setForeground(QColor(theme.MUTED))
item.setToolTip("Details for this game were pruned from the history cache")
self.table.setItem(r, c, item)
self.table.item(r, 0).setData(KEY_ROLE, (g.match_id, g.game_number, g.replayable))
self.table.item(r, 0).setData(KEY_ROLE, (g.match_id, g.game_number))
play = self.table.item(r, COL_PLAY)
if g.on_play == "me":
@@ -145,3 +374,11 @@ class HistoryView(QWidget):
res.setForeground(QColor(theme.MINT_TEXT))
elif g.result == "Lost":
res.setForeground(QColor(theme.DANGER))
self.table.setSortingEnabled(True) # sorts by the current header indicator
if games and not self._columns_ready:
self._restore_or_size_columns()
# Commander columns only matter if some shown game had commanders (Brawl).
has_commanders = any(g.my_commander or g.opp_commander for g in self.shown)
for col in (COL_MY_CMDR, COL_OPP_CMDR):
self.table.setColumnHidden(col, not has_commanders)
+48 -44
View File
@@ -6,7 +6,7 @@ import time
from pathlib import Path
from PySide6.QtCore import QSettings, Qt, QThread, QTimer, Signal
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtGui import QAction, QFont, QFontMetrics, QKeySequence
from PySide6.QtWidgets import (
QButtonGroup,
QFileDialog,
@@ -17,6 +17,7 @@ from PySide6.QtWidgets import (
QMenu,
QMessageBox,
QPushButton,
QSizePolicy,
QStackedWidget,
QToolButton,
QVBoxLayout,
@@ -38,8 +39,8 @@ 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
VIEWS = ["Match", "History"]
MATCH, HISTORY = 0, 1
class HistoryImport(QThread):
@@ -52,17 +53,21 @@ class HistoryImport(QThread):
self.path, self.store, self.cards = path, store, cards
def run(self) -> None:
# Own card DB connection: this runs off the GUI thread.
cards = CardDB(self.cards.path)
saved = 0
try:
st = self.path.stat()
except OSError:
return
st = None
if st is not None:
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))
if self.store.meta(key) != stamp:
saved = import_log(self.path, self.store, cards)
self.store.set_meta(key, stamp)
# Games recorded before commanders were stored.
saved += self.store.backfill_commanders(cards)
self.done.emit(saved)
@@ -97,16 +102,20 @@ class MainWindow(QMainWindow):
# 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.match_view = MatchView(cards, self.toggles[1], self.turn_view, self.deck_view)
self.match_view.hovered.connect(self.deck_view.preview.show_card)
if (state := self._settings.value("match/split")) is not None:
self.match_view.split.restoreState(state)
else:
# Players / turn stats / deck lists: enough room for a useful number of stat rows.
self.match_view.split.setSizes([250, 360, 560])
self.match_view.split.splitterMoved.connect(
lambda *_: self._settings.setValue("match/split", self.match_view.split.saveState()))
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):
for page in (self.match_view, self.history_view):
self.stack.addWidget(page)
# Top bar
@@ -120,12 +129,19 @@ class MainWindow(QMainWindow):
btn.setCheckable(True)
btn.setShortcut(QKeySequence(f"Ctrl+{i + 1}"))
btn.setToolTip(f"{name} view (Ctrl+{i + 1})")
# The selected button's label is bold; reserve room for that so it isn't squeezed.
bold = QFont(btn.font())
bold.setBold(True)
btn.setMinimumWidth(QFontMetrics(bold).horizontalAdvance(name) + 2 * theme.NAV_PADDING_X + 4)
btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
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)
# Let long headers be cut off rather than squeezing the buttons around them.
self.header.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
bar.addWidget(self.header, 1)
self.customize = QToolButton()
self.customize.setText("⚙ Customize")
@@ -188,7 +204,7 @@ class MainWindow(QMainWindow):
self.resize(1400, 880)
self.history_view.set_games(history.games())
self.show_view(int(self._settings.value("view", 0, type=int)))
self.show_view(int(self._settings.value("view2", MATCH, type=int)))
if log_path is not None:
self.start_log(log_path, replay_rate)
else:
@@ -213,49 +229,38 @@ class MainWindow(QMainWindow):
quit_act.setShortcut(QKeySequence.StandardKey.Quit)
quit_act.triggered.connect(self.close)
self.addAction(quit_act) # keep the shortcut active while the menu is closed
unpreview = QAction("Stop previewing", self)
unpreview.setShortcut(QKeySequence(Qt.Key.Key_Escape))
unpreview.triggered.connect(self.deck_view.preview.clear)
self.addAction(unpreview)
menu.addAction(quit_act)
btn.setMenu(menu)
return btn
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]]
return [self.my_sections, self.opp_sections, 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)
self.toggles[1].fill_menu(menu) # match panels
for title, tg in (("Turn stats", self.toggles[2]), ("My deck sections", self.my_sections),
("Opponent sections", self.opp_sections), ("Preview", self.toggles[0])):
sub = StayOpenMenu(title, 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)
menu.addAction("Arrange sections and stats…").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()
ArrangeDialog(self._arrangeable(), "Arrange sections and stats", 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.customize.setEnabled(index == MATCH)
self.stack.setCurrentIndex(index)
self.nav.button(index).setChecked(True)
self._settings.setValue("view", index)
self._settings.setValue("view2", index)
self._force_refresh()
# -- menu actions ----------------------------------------------------------
@@ -326,7 +331,7 @@ class MainWindow(QMainWindow):
self.past_label.setText(text)
self.past_bar.show()
self.turn_view.selected = None
self.show_view(0)
self.show_view(MATCH)
def close_past_game(self) -> None:
self.past = None
@@ -364,7 +369,7 @@ class MainWindow(QMainWindow):
self.refresh()
def _tick(self) -> None:
if self.stack.currentIndex() == 1:
if self.stack.currentIndex() == MATCH:
self.match_view.tick(self.shown_tracker, self.now_ms())
def refresh(self) -> None:
@@ -376,11 +381,9 @@ class MainWindow(QMainWindow):
self._rendered[index] = key
self._render_header(t)
self.deck_view.preview.parentWidget().setVisible(self.toggles[0].on("preview"))
if index in (0, 1):
if index == MATCH:
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:
@@ -397,6 +400,7 @@ class MainWindow(QMainWindow):
if not t.in_match and t.last_result:
parts.append(f"<b>Match {t.last_result.lower()}</b>")
self.header.setText(" · ".join(parts))
self.header.setToolTip(" · ".join(parts))
messages = []
if not self.cards.available:
+15 -16
View File
@@ -189,7 +189,7 @@ class PlayerCard(QFrame):
class MatchView(QWidget):
hovered = Signal(int)
def __init__(self, cards: CardDB, toggles: Toggles, parent=None):
def __init__(self, cards: CardDB, toggles: Toggles, turn_view: QWidget, deck_view: QWidget, parent=None):
super().__init__(parent)
self.cards = cards
self.toggles = toggles
@@ -200,7 +200,7 @@ class MatchView(QWidget):
self.info = QFrame()
self.info.setObjectName("card")
self.turn_label = _label(name="bigNumber")
self.turn_label = _label(name="statValue")
self.turn_caption = _label(name="muted")
self.phase = _label()
self.score = _label()
@@ -209,7 +209,7 @@ class MatchView(QWidget):
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,
for row, w in enumerate([_label("Match", "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)
@@ -221,19 +221,18 @@ class MatchView(QWidget):
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])
# Players + match info, then turn stats, then the deck lists; heights are draggable.
self.split = QSplitter(Qt.Orientation.Vertical)
self.split.addWidget(top)
self.split.addWidget(turn_view)
self.split.addWidget(deck_view)
self.split.setChildrenCollapsible(False)
self.split.setStretchFactor(0, 2)
self.split.setStretchFactor(1, 3)
self.split.setStretchFactor(2, 5)
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(split)
lay.addWidget(self.split)
def render(self, t: MatchTracker, now_ms: int) -> None:
g = t.game
@@ -246,8 +245,8 @@ class MatchView(QWidget):
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")
self.turn_label.setText(f"Turn {g.turn}" if g.turn else "Pre-game")
self.turn_caption.setText(whose if g.turn else "")
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))
+31 -3
View File
@@ -2,15 +2,17 @@
from __future__ import annotations
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QPixmap, QResizeEvent
from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtGui import QMouseEvent, QPixmap, QResizeEvent
from PySide6.QtWidgets import QHBoxLayout, QLabel, QSizePolicy, QToolButton, QVBoxLayout, QWidget
from ..carddb import CardDB
from ..images import ImageCache
class CardPreview(QWidget):
cleared = Signal()
def __init__(self, cards: CardDB, images: ImageCache, parent=None):
super().__init__(parent)
self.cards = cards
@@ -26,18 +28,44 @@ class CardPreview(QWidget):
self.caption.setWordWrap(True)
self.caption.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.close_btn = QToolButton()
self.close_btn.setText("✕")
self.close_btn.setToolTip("Stop previewing this card (Esc, or click the image)")
self.close_btn.clicked.connect(self.clear)
top = QHBoxLayout()
top.addStretch(1)
top.addWidget(self.close_btn)
lay = QVBoxLayout(self)
lay.setContentsMargins(4, 4, 4, 4)
lay.addLayout(top)
lay.addWidget(self.image, 1)
lay.addWidget(self.caption)
images.image_ready.connect(self._on_image_ready)
self.clear()
def clear(self) -> None:
"""Back to the empty state."""
was_showing = self.grp_id is not None
self.grp_id = None
self.caption.setText("")
self._show_placeholder("Hover a card to preview it")
self.close_btn.setVisible(False)
if was_showing:
self.cleared.emit()
def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton and self.grp_id is not None:
self.clear()
return
super().mousePressEvent(event)
def show_card(self, grp_id: int) -> None:
if grp_id == self.grp_id:
return
self.grp_id = grp_id
self.close_btn.setVisible(True)
info = self.cards.get(grp_id)
details = [info.type_line]
if info.power or info.toughness:
+8 -1
View File
@@ -18,6 +18,7 @@ DANGER = "#e07a6b"
FONT_PT = 13
RADIUS = 10
NAV_PADDING_X = 22
QSS = f"""
* {{ outline: none; }}
@@ -44,6 +45,12 @@ QFrame#card {{
}}
QLabel#cardTitle {{ color: {MINT_TEXT}; font-weight: bold; }}
QLabel#muted {{ color: {MUTED}; }}
QLabel#statValue {{ font-size: {int(FONT_PT * 1.8)}pt; font-weight: bold; color: {TEXT}; }}
QPushButton#chip {{
background: #1b2f29; border: 1px solid {MINT}; color: {MINT_TEXT};
border-radius: 14px; padding: 4px 12px;
}}
QPushButton#chip:hover {{ background: #24443a; }}
QLabel#bigNumber {{ font-size: {FONT_PT * 3}pt; font-weight: 700; color: {TEXT}; }}
QFrame#pastBar {{
background: #1b2f29; border: 1px solid {MINT}; border-radius: {RADIUS}px;
@@ -62,7 +69,7 @@ QPushButton:disabled, QToolButton:disabled {{ color: {MUTED}; }}
QPushButton:checked, QToolButton:checked {{
background: {MINT}; color: {BG}; border-color: {MINT}; font-weight: bold;
}}
QPushButton#navButton {{ padding: 8px 22px; border-radius: 16px; }}
QPushButton#navButton {{ padding: 8px {NAV_PADDING_X}px; border-radius: 16px; }}
QToolButton::menu-indicator {{ image: none; width: 0; }}
QTreeWidget, QTableWidget, QListWidget {{
+34 -22
View File
@@ -38,7 +38,7 @@ class StatsTable(QTableWidget):
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.setShowGrid(False)
self.setAlternatingRowColors(True)
self.verticalHeader().setDefaultSectionSize(34)
self.verticalHeader().setDefaultSectionSize(28)
self.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.verticalHeader().setStyleSheet(f"QHeaderView::section {{ color: {theme.TEXT}; font-weight: 400;"
@@ -77,41 +77,53 @@ class TurnView(QWidget):
self.prev_btn = QPushButton("◀")
self.next_btn = QPushButton("▶")
self.prev_btn.setToolTip("Previous turn")
self.next_btn.setToolTip("Next turn")
self.live_btn = QPushButton("Live")
self.live_btn.setCheckable(True)
self.live_btn.setChecked(True)
self.live_btn.setToolTip("Follow the current turn")
self.title = QLabel()
self.title.setObjectName("cardTitle")
self.title.setObjectName("statValue")
self.title.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.title.setMinimumWidth(260)
self.whose = QLabel()
self.whose.setObjectName("muted")
self.whose.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.prev_btn.clicked.connect(lambda: self._step(-1))
self.next_btn.clicked.connect(lambda: self._step(1))
self.live_btn.toggled.connect(self._set_live)
nav = QHBoxLayout()
nav.addStretch(1)
nav.addWidget(self.prev_btn)
nav.addWidget(self.title)
nav.addWidget(self.next_btn)
nav.addSpacing(12)
nav.addWidget(self.live_btn)
nav.addStretch(1)
# Turn picker: sits between the two tables.
arrows = QHBoxLayout()
arrows.addWidget(self.prev_btn)
arrows.addWidget(self.next_btn)
picker = QWidget()
picker_lay = QVBoxLayout(picker)
picker_lay.setContentsMargins(0, 0, 0, 0)
picker_lay.addStretch(1)
picker_lay.addWidget(self.title)
picker_lay.addWidget(self.whose)
picker_lay.addSpacing(6)
picker_lay.addLayout(arrows)
picker_lay.addWidget(self.live_btn)
picker_lay.addStretch(1)
self.picker_frame = card_frame("Turn", picker)
self.picker_frame.setFixedWidth(170)
self.turn_table = StatsTable()
self.game_table = StatsTable()
self.turn_frame = card_frame("Selected turn", self.turn_table)
tables = QHBoxLayout()
tables.addWidget(self.turn_frame, 1)
tables.addWidget(card_frame("Whole game", self.game_table), 1)
self.game_frame = card_frame("Whole game", self.game_table)
self.empty = QLabel("Waiting for a game…")
self.empty = QLabel("Turn stats appear once a game starts.")
self.empty.setObjectName("muted")
self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
lay = QVBoxLayout(self)
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addLayout(nav)
lay.addLayout(tables, 1)
lay.addWidget(self.turn_frame, 1)
lay.addWidget(self.picker_frame)
lay.addWidget(self.game_frame, 1)
lay.addWidget(self.empty, 1)
def _turns(self, g: GameState) -> list[int]:
@@ -145,8 +157,7 @@ class TurnView(QWidget):
g = t.game
has_game = g is not None
self.empty.setVisible(not has_game)
for w in (self.turn_table, self.game_table, self.turn_frame, self.prev_btn, self.next_btn,
self.live_btn, self.title):
for w in (self.turn_frame, self.picker_frame, self.game_frame):
w.setVisible(has_game)
if g is None:
return
@@ -156,8 +167,9 @@ class TurnView(QWidget):
self.selected = None
shown = self.selected if self.selected is not None else g.turn
active = g.turns.active.get(shown)
whose = "yours" if active == g.my_seat else "theirs" if active else ""
self.title.setText(f"Turn {shown}" + (f" ({whose})" if whose else "") if shown else "Pre-game")
whose = "your" if active == g.my_seat else "their" if active else ""
self.title.setText(f"Turn {shown}" if shown else "Pre-game")
self.whose.setText(f"{whose} turn".capitalize() if whose else "")
self.prev_btn.setEnabled(bool(turns) and shown != turns[0])
self.next_btn.setEnabled(self.selected is not None)
+22
View File
@@ -225,3 +225,25 @@ def test_commanders_are_not_emblems():
assert g.commander_status(OPP) == [(9000, "stack")]
assert g.commander_tax(OPP) == 2
assert g.commander_tax(ME) == 0
def test_revealed_card_already_tracked_is_not_double_counted():
t = MatchTracker()
start_game(t)
# Their card is bounced back to hand (tracked), and a reveal shows the same card.
t.handle(GreMessages([gsm(2, annotations=[id_changed(200, 210)], zones=[
zone(OPP_HAND, "Hand", [201], OPP), zone(BATTLEFIELD, "Battlefield", [210]),
], objects=[card(210, 5005, BATTLEFIELD)])]))
t.handle(GreMessages([gsm(3, annotations=[id_changed(210, 220)], zones=[
zone(OPP_HAND, "Hand", [220], OPP), zone(BATTLEFIELD, "Battlefield", []),
], objects=[card(400, 5005, OPP_HAND, otype="RevealedCard")], deleted=[210])]))
assert t.game.opp_known_in("ZoneType_Hand") == {5005: 1}
def test_known_cards_never_exceed_zone_size():
t = MatchTracker()
start_game(t)
# Two reveals of different cards while their hand only has one card left.
t.handle(GreMessages([gsm(2, zones=[zone(OPP_HAND, "Hand", [201], OPP)], objects=[
card(400, 7007, OPP_HAND, otype="RevealedCard"), card(401, 7008, OPP_HAND, otype="RevealedCard")])]))
assert sum(t.game.opp_known_in("ZoneType_Hand").values()) == 1
+33
View File
@@ -65,3 +65,36 @@ def test_recorder_ignores_unfinished_match(tmp_path):
t.handle(ev)
rec.observe(ev, t)
assert store.games() == []
def test_old_database_is_upgraded_and_backfilled(tmp_path):
import sqlite3
path = tmp_path / "old.db"
store = HistoryStore(path)
import_log(FIXTURE, store, None)
# Simulate a database from before commanders were stored.
conn = sqlite3.connect(path)
conn.execute("UPDATE games SET my_commander = NULL, opp_commander = NULL")
conn.commit()
conn.close()
store = HistoryStore(path)
assert all(g.my_commander is None for g in store.games())
assert store.backfill_commanders(None) == 2
assert all(g.my_commander == "" and g.opp_commander == "" for g in store.games()) # not a Brawl match
assert store.backfill_commanders(None) == 0
def test_schema_without_commander_columns_gets_them(tmp_path):
import sqlite3
path = tmp_path / "v1.db"
conn = sqlite3.connect(path)
conn.executescript("""CREATE TABLE games (match_id TEXT NOT NULL, game_number INTEGER NOT NULL,
started_ms INTEGER, ended_ms INTEGER, event_id TEXT, opponent TEXT, deck_name TEXT, on_play TEXT,
result TEXT, my_colors TEXT, opp_colors TEXT, turns INTEGER, best_of INTEGER,
PRIMARY KEY (match_id, game_number));
INSERT INTO games VALUES ('m1', 1, 1, 2, 'Ladder', 'Opp', 'Deck', 'me', 'Won', 'U', 'R', 5, 1);""")
conn.commit()
conn.close()
games = HistoryStore(path).games()
assert len(games) == 1 and games[0].deck_name == "Deck" and games[0].my_commander is None
+58
View File
@@ -0,0 +1,58 @@
from mtga_assistant.history import GameSummary
from mtga_assistant.history_stats import WEEK_MS, Filter, apply_filters, compute_stats
NOW = 1_800_000_000_000
DAY = 24 * 3600 * 1000
def game(n: int, result: str, *, deck="Deck A", on_play="me", days_ago=0, turns=8, length_s=300,
event="Ladder", best_of=1, game_number=1) -> GameSummary:
start = NOW - days_ago * DAY - n * 1000
return GameSummary(match_id=f"m{n}", game_number=game_number, started_ms=start, ended_ms=start + length_s * 1000,
event_id=event, opponent=f"opp{n}", deck_name=deck, on_play=on_play, result=result,
my_colors="UB", opp_colors="R", turns=turns, best_of=best_of)
def test_filters_only_and_hide_combine():
games = [game(1, "Won", deck="A"), game(2, "Lost", deck="B"), game(3, "Won", deck="A", on_play="opp")]
assert [g.match_id for g in apply_filters(games, [Filter("deck", "A")])] == ["m1", "m3"]
assert [g.match_id for g in apply_filters(games, [Filter("deck", "A"), Filter("on_play", "opp")])] == ["m3"]
assert [g.match_id for g in apply_filters(games, [Filter("deck", "A", exclude=True)])] == ["m2"]
def test_filter_labels_are_readable():
assert Filter("on_play", "opp").label == "On the play = you on the draw"
assert Filter("event", "Play_Brawl_Historic", exclude=True).label == "Event ≠ Play Brawl Historic"
assert Filter("game", "Bo1").label == "Game = Bo1"
def test_stats_windows_and_averages():
# 12 recent games (newest first: n=0 is newest): W W L W W L W W L W | L L
results = ["Won", "Won", "Lost", "Won", "Won", "Lost", "Won", "Won", "Lost", "Won", "Lost", "Lost"]
games = [game(n, r, days_ago=0 if n < 6 else 10, turns=6 + n % 3, length_s=240 + 60 * (n % 2),
on_play="me" if n % 2 == 0 else "opp")
for n, r in enumerate(results)]
games.append(game(99, "", days_ago=1)) # no result: counted as a game, not in win rates
s = compute_stats(games, NOW)
assert s.games == 13
assert (s.overall.wins, s.overall.losses) == (7, 5)
assert (s.last_10.wins, s.last_10.losses) == (7, 3)
assert s.last_10.rate == 0.7
assert s.last_100.games == 12
assert s.week_games == 7 and (s.week.wins, s.week.losses) == (4, 2)
assert (s.on_play.wins, s.on_play.losses) == (3, 3)
assert (s.on_draw.wins, s.on_draw.losses) == (4, 2)
assert s.avg_turns is not None and 6 <= s.avg_turns <= 8
assert s.avg_length_s is not None and 240 <= s.avg_length_s <= 300
def test_stats_follow_filters():
games = [game(1, "Won", deck="A"), game(2, "Lost", deck="A"), game(3, "Lost", deck="B"), game(4, "Lost", deck="B")]
assert compute_stats(apply_filters(games, [Filter("deck", "A")]), NOW).overall.rate == 0.5
assert compute_stats(apply_filters(games, [Filter("deck", "B")]), NOW).overall.rate == 0.0
def test_empty_history():
s = compute_stats([], NOW)
assert s.games == 0 and s.overall.rate is None and s.avg_length_s is None and s.week_games == 0
assert WEEK_MS == 7 * DAY