From b3325e8ecc1f54375265bae54274bb85573da964 Mon Sep 17 00:00:00 2001 From: Aeth3r Date: Fri, 16 Oct 2026 12:34:02 -0400 Subject: [PATCH] Merge views into Match, expand history, and polish the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 45 ++-- src/mtga_assistant/gamestate.py | 37 +-- src/mtga_assistant/history.py | 56 ++++- src/mtga_assistant/history_stats.py | 103 +++++++++ src/mtga_assistant/ui/card_list.py | 11 +- src/mtga_assistant/ui/deck_view.py | 31 ++- src/mtga_assistant/ui/history_view.py | 315 ++++++++++++++++++++++---- src/mtga_assistant/ui/main_window.py | 102 +++++---- src/mtga_assistant/ui/match_view.py | 31 ++- src/mtga_assistant/ui/preview.py | 34 ++- src/mtga_assistant/ui/theme.py | 9 +- src/mtga_assistant/ui/turn_view.py | 56 +++-- tests/test_gamestate.py | 22 ++ tests/test_history.py | 33 +++ tests/test_history_stats.py | 58 +++++ 15 files changed, 760 insertions(+), 183 deletions(-) create mode 100644 src/mtga_assistant/history_stats.py create mode 100644 tests/test_history_stats.py 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