diff --git a/README.md b/README.md
index fda36f8..4d64da2 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/src/mtga_assistant/gamestate.py b/src/mtga_assistant/gamestate.py
index 89e3a76..8abf1cd 100644
--- a/src/mtga_assistant/gamestate.py
+++ b/src/mtga_assistant/gamestate.py
@@ -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]] = {}
diff --git a/src/mtga_assistant/history.py b/src/mtga_assistant/history.py
index b669e68..08aa4ab 100644
--- a/src/mtga_assistant/history.py
+++ b/src/mtga_assistant/history.py
@@ -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)
- ORDER BY g.started_ms DESC, g.game_number DESC"""
+ 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),
)
diff --git a/src/mtga_assistant/history_stats.py b/src/mtga_assistant/history_stats.py
new file mode 100644
index 0000000..1fa0c97
--- /dev/null
+++ b/src/mtga_assistant/history_stats.py
@@ -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,
+ )
diff --git a/src/mtga_assistant/ui/card_list.py b/src/mtga_assistant/ui/card_list.py
index 3925a0d..0fdcf93 100644
--- a/src/mtga_assistant/ui/card_list.py
+++ b/src/mtga_assistant/ui/card_list.py
@@ -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}")
diff --git a/src/mtga_assistant/ui/deck_view.py b/src/mtga_assistant/ui/deck_view.py
index 3b479e8..7e441cc 100644
--- a/src/mtga_assistant/ui/deck_view.py
+++ b/src/mtga_assistant/ui/deck_view.py
@@ -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),
]
diff --git a/src/mtga_assistant/ui/history_view.py b/src/mtga_assistant/ui/history_view.py
index 6bd63cd..dc13dee 100644
--- a/src/mtga_assistant/ui/history_view.py
+++ b/src/mtga_assistant/ui/history_view.py
@@ -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"{rec.rate:.0%}")
+ 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)
diff --git a/src/mtga_assistant/ui/main_window.py b/src/mtga_assistant/ui/main_window.py
index 5d7adab..032d6f4 100644
--- a/src/mtga_assistant/ui/main_window.py
+++ b/src/mtga_assistant/ui/main_window.py
@@ -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
- 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)
+ 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:
+ 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)
- tg.fill_menu(sub)
- menu.addMenu(sub)
+ 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"Match {t.last_result.lower()}")
self.header.setText(" · ".join(parts))
+ self.header.setToolTip(" · ".join(parts))
messages = []
if not self.cards.available:
diff --git a/src/mtga_assistant/ui/match_view.py b/src/mtga_assistant/ui/match_view.py
index b55f3d2..268d0e8 100644
--- a/src/mtga_assistant/ui/match_view.py
+++ b/src/mtga_assistant/ui/match_view.py
@@ -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))
diff --git a/src/mtga_assistant/ui/preview.py b/src/mtga_assistant/ui/preview.py
index 4d20706..dddd76a 100644
--- a/src/mtga_assistant/ui/preview.py
+++ b/src/mtga_assistant/ui/preview.py
@@ -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:
diff --git a/src/mtga_assistant/ui/theme.py b/src/mtga_assistant/ui/theme.py
index 63e69c1..a37808f 100644
--- a/src/mtga_assistant/ui/theme.py
+++ b/src/mtga_assistant/ui/theme.py
@@ -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 {{
diff --git a/src/mtga_assistant/ui/turn_view.py b/src/mtga_assistant/ui/turn_view.py
index a81fa44..d016957 100644
--- a/src/mtga_assistant/ui/turn_view.py
+++ b/src/mtga_assistant/ui/turn_view.py
@@ -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)
diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py
index 391ef10..64567b2 100644
--- a/tests/test_gamestate.py
+++ b/tests/test_gamestate.py
@@ -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
diff --git a/tests/test_history.py b/tests/test_history.py
index fff475f..0df7371 100644
--- a/tests/test_history.py
+++ b/tests/test_history.py
@@ -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
diff --git a/tests/test_history_stats.py b/tests/test_history_stats.py
new file mode 100644
index 0000000..2dface6
--- /dev/null
+++ b/tests/test_history_stats.py
@@ -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