Add MTGA Assistant: native Linux deck and game tracker
PySide6 app that follows MTG Arena's Player.log under Steam/Proton and shows my remaining library with draw odds, my zones, and everything revealed about the opponent (board, graveyard, exile, known hand/library, seen this match). - Auto-detects the Steam library, Proton prefix log and MTGA install - Card names/costs/types from MTGA's local SQLite card database - Tracks card identity across zone changes, shuffles and Bo3 games - Scryfall card images with a lazy, size-capped LRU disk cache - Live log following with restart detection, plus --replay mode - Flags log gaps where MTGA omits oversized game-state messages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
734336168c
commit
7dc2ce0da8
21 files changed
+2736
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
|
||||
from .app import main
|
||||
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Entry point: mtga-assistant [--log PATH] [--replay PATH [--rate N]]."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from . import paths
|
||||
from .carddb import CardDB
|
||||
from .images import ImageCache
|
||||
from .ui.main_window import MainWindow
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="MTG Arena deck and game tracker")
|
||||
ap.add_argument("--log", type=Path, help="Player.log to follow (default: auto-detect)")
|
||||
ap.add_argument("--replay", type=Path, help="replay a saved log instead of following the live one")
|
||||
ap.add_argument("--rate", type=float, default=40.0, help="replay speed in log events per second")
|
||||
ap.add_argument("--mtga-dir", type=Path, help="MTGA install directory (default: auto-detect)")
|
||||
args, qt_args = ap.parse_known_args()
|
||||
|
||||
cfg = paths.load_config()
|
||||
if args.log:
|
||||
cfg.log_path = args.log
|
||||
if args.mtga_dir:
|
||||
cfg.mtga_dir = args.mtga_dir
|
||||
|
||||
app = QApplication([sys.argv[0], *qt_args])
|
||||
app.setApplicationName("MTGA Assistant")
|
||||
app.setDesktopFileName("mtga-assistant") # Wayland app_id, usable in Hyprland window rules
|
||||
|
||||
mtga_dir = paths.find_mtga_dir(cfg)
|
||||
db_path = paths.find_card_db(mtga_dir) if mtga_dir else None
|
||||
cards = CardDB(db_path)
|
||||
images = ImageCache(paths.CACHE_DIR, cards, cfg.cache_max_mb * 1024 * 1024)
|
||||
|
||||
if args.replay:
|
||||
win = MainWindow(cards, images, args.replay, replay_rate=args.rate)
|
||||
else:
|
||||
win = MainWindow(cards, images, paths.find_log_path(cfg))
|
||||
app.aboutToQuit.connect(win.shutdown)
|
||||
win.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Read-only access to MTGA's local SQLite card database (Raw_CardDatabase_*.mtga)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
CARD_TYPES = {
|
||||
1: "Artifact", 2: "Creature", 3: "Enchantment", 4: "Instant", 5: "Land",
|
||||
6: "Phenomenon", 7: "Plane", 8: "Planeswalker", 9: "Scheme", 10: "Sorcery",
|
||||
11: "Kindred", 12: "Vanguard", 13: "Dungeon", 14: "Battle", 15: "Conspiracy",
|
||||
}
|
||||
RARITIES = {0: "token", 1: "land", 2: "common", 3: "uncommon", 4: "rare", 5: "mythic"}
|
||||
|
||||
# Cards.LinkedFaceType values whose first LinkedFaceGrpId is the "base" card: DFC back
|
||||
# faces, split/room halves, adventure and omen sides, meld results, specializations...
|
||||
# (Observed: odd types are the derived face, except omens where 20 is the derived side.)
|
||||
DERIVED_FACE_TYPES = {1, 3, 5, 7, 9, 11, 13, 15, 17, 20}
|
||||
|
||||
_MANA_TOKEN = re.compile(r"o(\([^)]*\)|[^o(]+)")
|
||||
|
||||
|
||||
def parse_mana(text: str) -> list[str]:
|
||||
"""'o2o(W/U)oU' -> ['2', 'W/U', 'U']."""
|
||||
return [m.group(1).strip("()") for m in _MANA_TOKEN.finditer(text or "")]
|
||||
|
||||
|
||||
def mana_value(symbols: list[str]) -> int:
|
||||
total = 0
|
||||
for s in symbols:
|
||||
if s.isdigit():
|
||||
total += int(s)
|
||||
elif s.startswith("2/"):
|
||||
total += 2
|
||||
elif s != "X":
|
||||
total += 1
|
||||
return total
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CardInfo:
|
||||
grp_id: int
|
||||
name: str
|
||||
mana: tuple[str, ...] = ()
|
||||
types: tuple[str, ...] = ()
|
||||
set_code: str = ""
|
||||
collector_number: str = ""
|
||||
rarity: str = ""
|
||||
is_token: bool = False
|
||||
power: str = ""
|
||||
toughness: str = ""
|
||||
linked_faces: tuple[int, ...] = field(default=())
|
||||
linked_face_type: int = 0
|
||||
|
||||
@property
|
||||
def mana_value(self) -> int:
|
||||
return mana_value(list(self.mana))
|
||||
|
||||
@property
|
||||
def is_land(self) -> bool:
|
||||
return "Land" in self.types
|
||||
|
||||
@property
|
||||
def type_line(self) -> str:
|
||||
return " ".join(self.types)
|
||||
|
||||
|
||||
def unknown_card(grp_id: int) -> CardInfo:
|
||||
return CardInfo(grp_id=grp_id, name=f"Unknown card #{grp_id}")
|
||||
|
||||
|
||||
class CardDB:
|
||||
def __init__(self, path: Path | None):
|
||||
self.path = path
|
||||
self._conn: sqlite3.Connection | None = None
|
||||
if path is not None:
|
||||
# immutable=1: MTGA may rewrite the file on update; never take locks on it.
|
||||
uri = f"file:{path}?mode=ro&immutable=1"
|
||||
self._conn = sqlite3.connect(uri, uri=True, check_same_thread=False)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._conn is not None
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get(self, grp_id: int) -> CardInfo:
|
||||
if self._conn is None:
|
||||
return unknown_card(grp_id)
|
||||
row = self._conn.execute(
|
||||
"""
|
||||
SELECT c.GrpId,
|
||||
(SELECT Loc FROM Localizations_enUS l WHERE l.LocId = c.TitleId
|
||||
ORDER BY l.Formatted = 1 DESC, l.Formatted LIMIT 1),
|
||||
c.OldSchoolManaText, c.Types, c.ExpansionCode, c.CollectorNumber,
|
||||
c.Rarity, c.IsToken, c.Power, c.Toughness, c.LinkedFaceGrpIds, c.LinkedFaceType
|
||||
FROM Cards c WHERE c.GrpId = ?
|
||||
""",
|
||||
(grp_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return unknown_card(grp_id)
|
||||
(gid, name, mana, types, set_code, cn, rarity, is_token, power, tough, linked, ltype) = row
|
||||
return CardInfo(
|
||||
grp_id=gid,
|
||||
name=_strip_tags(name) if name else f"Card #{gid}",
|
||||
mana=tuple(parse_mana(mana)),
|
||||
types=tuple(CARD_TYPES.get(int(t), "?") for t in (types or "").split(",") if t),
|
||||
set_code=set_code or "",
|
||||
collector_number=cn or "",
|
||||
rarity=RARITIES.get(rarity, ""),
|
||||
is_token=bool(is_token),
|
||||
power=power or "",
|
||||
toughness=tough or "",
|
||||
linked_faces=tuple(int(x) for x in (linked or "").split(",") if x),
|
||||
linked_face_type=int(ltype or 0),
|
||||
)
|
||||
|
||||
def base_grp(self, grp_id: int) -> int:
|
||||
"""Canonical grpId for a physical card, e.g. a DFC's back face -> its front face."""
|
||||
info = self.get(grp_id)
|
||||
if info.linked_face_type in DERIVED_FACE_TYPES and info.linked_faces:
|
||||
return info.linked_faces[0]
|
||||
return grp_id
|
||||
|
||||
|
||||
def _strip_tags(s: str) -> str:
|
||||
# Some titles carry markup such as <nobr> or <sprite=...>.
|
||||
return re.sub(r"<[^>]+>", "", s)
|
||||
@@ -0,0 +1,366 @@
|
||||
"""Game/match state reconstructed from GRE messages. Pure Python, no Qt.
|
||||
|
||||
Model notes (derived from MTGA's GRE protocol as it appears in Player.log):
|
||||
|
||||
* Each zone message carries the zone's *complete* ``objectInstanceIds`` list, so
|
||||
zones are replaced wholesale on every update.
|
||||
* Game objects are only sent for cards the local player can see. Hidden cards
|
||||
(libraries, the opponent's hand) exist only as instance ids in a zone list.
|
||||
* When a card changes zone it usually gets a new instance id, announced by an
|
||||
``ObjectIdChanged`` annotation (orig_id -> new_id). We follow these chains so
|
||||
that an opponent's card we have seen keeps its identity when it goes back into
|
||||
a hidden zone (bounced to hand, put back into library, ...).
|
||||
* ``Shuffle`` annotations renumber a library (OldIds -> NewIds) in random order,
|
||||
so known cards in a shuffled library lose their position but are still there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .logparse import ClientDeck, DeckSubmitted, Event, GreMessages, MatchRoom
|
||||
|
||||
HIDDEN_ZONES = {"ZoneType_Library", "ZoneType_Hand"}
|
||||
CARD_OBJECT_TYPES = {"GameObjectType_Card"}
|
||||
# Zones whose cards are "out of the library" for my remaining-library maths.
|
||||
MY_TRACKED_ZONES = (
|
||||
"ZoneType_Hand",
|
||||
"ZoneType_Stack",
|
||||
"ZoneType_Battlefield",
|
||||
"ZoneType_Graveyard",
|
||||
"ZoneType_Exile",
|
||||
"ZoneType_Command",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Zone:
|
||||
zone_id: int
|
||||
type: str
|
||||
owner: int | None
|
||||
ids: list[int]
|
||||
|
||||
|
||||
def _details(annotation: dict[str, Any]) -> dict[str, list[Any]]:
|
||||
out: dict[str, list[Any]] = {}
|
||||
for d in annotation.get("details") or []:
|
||||
vals = d.get("valueInt32") or d.get("valueString") or d.get("valueUint32") or []
|
||||
out[d.get("key", "")] = vals
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameState:
|
||||
"""State of one game (one game of a best-of-three)."""
|
||||
|
||||
my_seat: int
|
||||
deck: Counter[int]
|
||||
sideboard: Counter[int]
|
||||
game_number: int = 1
|
||||
zones: dict[int, Zone] = field(default_factory=dict)
|
||||
objects: dict[int, dict[str, Any]] = field(default_factory=dict)
|
||||
life: dict[int, int] = field(default_factory=dict)
|
||||
turn: int = 0
|
||||
active_player: int = 0
|
||||
phase: str = ""
|
||||
complete: bool = False
|
||||
applied_state_ids: set[int] = field(default_factory=set)
|
||||
# MTGA omits oversized GRE messages from the log ("Message summarized..."); after such a
|
||||
# gap some zones may be stale until the server next sends them.
|
||||
desynced: bool = False
|
||||
|
||||
# Opponent knowledge.
|
||||
parent: dict[int, int] = field(default_factory=dict) # new instance id -> previous id
|
||||
known: dict[int, int] = field(default_factory=dict) # opp instance id -> grpId, survives hiding
|
||||
opp_library_unplaced: Counter[int] = field(default_factory=Counter) # known cards shuffled away
|
||||
# grpId -> instance ids seen. Resolved to physical cards (chain roots) only when counting,
|
||||
# because an object can be logged a message before the ObjectIdChanged that links it.
|
||||
opp_seen: dict[int, set[int]] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def opp_seat(self) -> int:
|
||||
return 2 if self.my_seat == 1 else 1
|
||||
|
||||
# -- applying messages -------------------------------------------------
|
||||
|
||||
def apply(self, gsm: dict[str, Any]) -> None:
|
||||
state_id = gsm.get("gameStateId")
|
||||
if state_id is not None:
|
||||
if state_id in self.applied_state_ids:
|
||||
return
|
||||
self.applied_state_ids.add(state_id)
|
||||
|
||||
if gsm.get("type") == "GameStateType_Full":
|
||||
self.zones.clear()
|
||||
self.objects.clear()
|
||||
self.desynced = False
|
||||
elif (prev := gsm.get("prevGameStateId")) and self.applied_state_ids - {state_id} \
|
||||
and prev not in self.applied_state_ids:
|
||||
self.desynced = True
|
||||
|
||||
if info := gsm.get("gameInfo"):
|
||||
self.game_number = info.get("gameNumber", self.game_number)
|
||||
if info.get("stage") == "GameStage_GameOver" or info.get("matchState") in (
|
||||
"MatchState_GameComplete",
|
||||
"MatchState_MatchComplete",
|
||||
):
|
||||
self.complete = True
|
||||
for p in gsm.get("players") or []:
|
||||
seat = p.get("systemSeatNumber")
|
||||
if seat is not None and "lifeTotal" in p:
|
||||
self.life[seat] = p["lifeTotal"]
|
||||
if ti := gsm.get("turnInfo"):
|
||||
self.turn = ti.get("turnNumber", self.turn)
|
||||
self.active_player = ti.get("activePlayer", self.active_player)
|
||||
self.phase = ti.get("phase", self.phase)
|
||||
|
||||
for ann in gsm.get("annotations") or []:
|
||||
self._apply_annotation(ann)
|
||||
|
||||
for z in gsm.get("zones") or []:
|
||||
zid = z["zoneId"]
|
||||
self.zones[zid] = Zone(zid, z.get("type", ""), z.get("ownerSeatId"), list(z.get("objectInstanceIds") or []))
|
||||
|
||||
for obj in gsm.get("gameObjects") or []:
|
||||
self._apply_object(obj)
|
||||
|
||||
for iid in gsm.get("diffDeletedInstanceIds") or []:
|
||||
self.objects.pop(iid, None)
|
||||
|
||||
def _apply_annotation(self, ann: dict[str, Any]) -> None:
|
||||
types = ann.get("type") or []
|
||||
if "AnnotationType_ObjectIdChanged" in types:
|
||||
d = _details(ann)
|
||||
if d.get("orig_id") and d.get("new_id"):
|
||||
orig, new = d["orig_id"][0], d["new_id"][0]
|
||||
self.parent[new] = orig
|
||||
if orig in self.known:
|
||||
self.known[new] = self.known[orig]
|
||||
if "AnnotationType_Shuffle" in types:
|
||||
d = _details(ann)
|
||||
for old in d.get("OldIds") or []:
|
||||
if old in self.known:
|
||||
self.opp_library_unplaced[self.known.pop(old)] += 1
|
||||
|
||||
def _apply_object(self, obj: dict[str, Any]) -> None:
|
||||
iid = obj["instanceId"]
|
||||
self.objects[iid] = obj
|
||||
grp = obj.get("grpId") or 0
|
||||
if not grp or obj.get("ownerSeatId") != self.opp_seat:
|
||||
return
|
||||
otype = obj.get("type")
|
||||
if otype in CARD_OBJECT_TYPES:
|
||||
newly_visible = iid not in self.known
|
||||
self.known[iid] = grp
|
||||
ids = self.opp_seen.setdefault(grp, set())
|
||||
if newly_visible and self.opp_library_unplaced[grp] > 0 \
|
||||
and self.root_of(iid) not in {self.root_of(i) for i in ids}:
|
||||
# Possibly the known card we lost track of in a shuffle resurfacing.
|
||||
self.opp_library_unplaced[grp] -= 1
|
||||
ids.add(iid)
|
||||
elif otype == "GameObjectType_RevealedCard":
|
||||
self.opp_seen.setdefault(grp, set()).add(-iid) # distinct key space for reveals
|
||||
|
||||
def root_of(self, iid: int) -> int:
|
||||
"""First instance id of the chain this id belongs to (the physical card)."""
|
||||
seen = set()
|
||||
while iid in self.parent and iid not in seen:
|
||||
seen.add(iid)
|
||||
iid = self.parent[iid]
|
||||
return iid
|
||||
|
||||
# -- queries ------------------------------------------------------------
|
||||
|
||||
def zone_of_type(self, ztype: str, owner: int | None) -> Zone | None:
|
||||
for z in self.zones.values():
|
||||
if z.type == ztype and (owner is None or z.owner == owner):
|
||||
return z
|
||||
return None
|
||||
|
||||
def cards_in(self, ztype: str, *, owner: int | None = None, controller: int | None = None,
|
||||
include_tokens: bool = True) -> Counter[int]:
|
||||
"""grpId counts of visible objects in zones of the given type."""
|
||||
out: Counter[int] = Counter()
|
||||
for z in self.zones.values():
|
||||
if z.type != ztype:
|
||||
continue
|
||||
for iid in z.ids:
|
||||
obj = self.objects.get(iid)
|
||||
if not obj or not obj.get("grpId"):
|
||||
continue
|
||||
otype = obj.get("type")
|
||||
if otype not in CARD_OBJECT_TYPES and not (include_tokens and otype == "GameObjectType_Token"):
|
||||
continue
|
||||
if owner is not None and obj.get("ownerSeatId") != owner:
|
||||
continue
|
||||
if controller is not None and obj.get("controllerSeatId", obj.get("ownerSeatId")) != controller:
|
||||
continue
|
||||
out[obj["grpId"]] += 1
|
||||
return out
|
||||
|
||||
def zone_size(self, ztype: str, owner: int) -> int:
|
||||
z = self.zone_of_type(ztype, owner)
|
||||
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()
|
||||
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 ztype == "ZoneType_Library":
|
||||
out += self.opp_library_unplaced
|
||||
return out
|
||||
|
||||
def opp_seen_counts(self, normalize=lambda g: g) -> Counter[int]:
|
||||
merged: dict[int, set[int]] = {}
|
||||
for g, ids in self.opp_seen.items():
|
||||
# Negative ids are reveal copies, which have no chain.
|
||||
merged.setdefault(normalize(g), set()).update(i if i < 0 else self.root_of(i) for i in ids)
|
||||
return Counter({g: len(r) for g, r in merged.items() if r})
|
||||
|
||||
def my_outside_library(self, normalize) -> Counter[int]:
|
||||
"""My non-token cards outside the library, keyed by deck grpId."""
|
||||
out: Counter[int] = Counter()
|
||||
for ztype in MY_TRACKED_ZONES:
|
||||
for g, n in self.cards_in(ztype, owner=self.my_seat, include_tokens=False).items():
|
||||
out[normalize(g)] += n
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchTracker:
|
||||
"""Everything the UI needs, across the games of a match."""
|
||||
|
||||
card_lookup: Any = None # CardDB-like with .get(grpId) -> CardInfo; optional
|
||||
match_id: str = ""
|
||||
event_id: str = ""
|
||||
players: dict[int, str] = field(default_factory=dict)
|
||||
my_seat: int | None = None
|
||||
game: GameState | None = None
|
||||
in_match: bool = False
|
||||
game_results: list[str] = field(default_factory=list) # "Won"/"Lost" per finished game
|
||||
last_result: str = ""
|
||||
next_deck: ClientDeck | None = None
|
||||
# Opponent cards seen in earlier games of this match (max count in any one game).
|
||||
opp_seen_prev_games: Counter[int] = field(default_factory=Counter)
|
||||
deck_name: str = ""
|
||||
submitted_deck: DeckSubmitted | None = None
|
||||
version: int = 0 # bumped on every change, for cheap UI refresh checks
|
||||
|
||||
def handle(self, ev: Event) -> None:
|
||||
if isinstance(ev, GreMessages):
|
||||
for m in ev.messages:
|
||||
self._handle_gre(m)
|
||||
elif isinstance(ev, MatchRoom):
|
||||
self._handle_room(ev)
|
||||
elif isinstance(ev, DeckSubmitted):
|
||||
self.submitted_deck = ev
|
||||
self.deck_name = ev.name
|
||||
elif isinstance(ev, ClientDeck):
|
||||
self.next_deck = ev
|
||||
self.version += 1
|
||||
|
||||
def _start_match(self) -> None:
|
||||
self.opp_seen_prev_games.clear()
|
||||
self.game_results.clear()
|
||||
self.last_result = ""
|
||||
self.players.clear()
|
||||
self.in_match = True
|
||||
|
||||
def _handle_room(self, ev: MatchRoom) -> None:
|
||||
if ev.state == "MatchGameRoomStateType_Playing":
|
||||
# ConnectResp (which starts the match on our side) may arrive before or after this.
|
||||
if not self.in_match:
|
||||
self._start_match()
|
||||
self.match_id = ev.match_id
|
||||
for p in ev.players:
|
||||
self.players[p.get("systemSeatId")] = p.get("playerName", "")
|
||||
self.event_id = p.get("eventId", self.event_id)
|
||||
elif ev.state == "MatchGameRoomStateType_MatchCompleted":
|
||||
self.in_match = False
|
||||
res = ev.final_result or {}
|
||||
for r in res.get("resultList") or []:
|
||||
if r.get("scope") == "MatchScope_Match" and self.my_seat is not None:
|
||||
won = r.get("winningTeamId") == self.my_seat
|
||||
self.last_result = "Won" if won else "Lost"
|
||||
if self.game:
|
||||
self.game.complete = True
|
||||
|
||||
def _handle_gre(self, m: dict[str, Any]) -> None:
|
||||
mtype = m.get("type")
|
||||
if mtype == "GREMessageType_ConnectResp":
|
||||
seats = m.get("systemSeatIds") or [1]
|
||||
self.my_seat = seats[0]
|
||||
deck_msg = (m.get("connectResp") or {}).get("deckMessage") or {}
|
||||
if not self.in_match:
|
||||
self._start_match()
|
||||
self.next_deck = None
|
||||
self.game = GameState(
|
||||
my_seat=self.my_seat,
|
||||
deck=Counter(deck_msg.get("deckCards") or []),
|
||||
sideboard=Counter(deck_msg.get("sideboardCards") or []),
|
||||
)
|
||||
return
|
||||
gsm = m.get("gameStateMessage")
|
||||
if gsm is None or self.game is None:
|
||||
return
|
||||
game_no = (gsm.get("gameInfo") or {}).get("gameNumber")
|
||||
if gsm.get("type") == "GameStateType_Full" and game_no and game_no != self.game.game_number:
|
||||
# Next game of the match: no new ConnectResp, just a fresh Full state.
|
||||
prev = self.game
|
||||
self.opp_seen_prev_games = self.opp_seen_match
|
||||
deck = self.next_deck or ClientDeck(prev.deck, prev.sideboard)
|
||||
self.next_deck = None
|
||||
self.game = GameState(my_seat=prev.my_seat, deck=Counter(deck.main),
|
||||
sideboard=Counter(deck.sideboard), game_number=game_no)
|
||||
self.game.apply(gsm)
|
||||
results = (gsm.get("gameInfo") or {}).get("results") or []
|
||||
game_results = [r for r in results if r.get("scope") == "MatchScope_Game"]
|
||||
if len(game_results) > len(self.game_results) and self.my_seat is not None:
|
||||
self.game_results = [
|
||||
"Won" if r.get("winningTeamId") == self.my_seat else "Lost" for r in game_results
|
||||
]
|
||||
self.game.complete = True
|
||||
|
||||
# -- derived views ------------------------------------------------------
|
||||
|
||||
@property
|
||||
def opp_seen_match(self) -> Counter[int]:
|
||||
"""Opponent cards seen this match: for each card, the most copies seen in any one game."""
|
||||
out = self.opp_seen_prev_games.copy()
|
||||
if self.game is not None:
|
||||
for g, n in self.game.opp_seen_counts(self.normalize).items():
|
||||
out[g] = max(out[g], n)
|
||||
return out
|
||||
|
||||
def normalize(self, grp: int) -> int:
|
||||
"""Map a game object's grpId to its card's canonical grpId (e.g. DFC back -> front)."""
|
||||
g = self.game
|
||||
if (g is not None and grp in g.deck) or self.card_lookup is None:
|
||||
return grp
|
||||
return self.card_lookup.base_grp(grp)
|
||||
|
||||
def my_library_remaining(self) -> Counter[int]:
|
||||
g = self.game
|
||||
if g is None:
|
||||
return Counter()
|
||||
remaining = g.deck.copy()
|
||||
remaining.subtract(g.my_outside_library(self.normalize))
|
||||
return +remaining # drop zero/negative entries
|
||||
|
||||
@property
|
||||
def opponent_name(self) -> str:
|
||||
if self.my_seat is None:
|
||||
return ""
|
||||
return self.players.get(2 if self.my_seat == 1 else 1, "")
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Card images from Scryfall with a size-capped on-disk LRU cache.
|
||||
|
||||
Images are fetched lazily, only for cards that actually show up, and stored as
|
||||
``<cache>/images/<grpId>.jpg``. A file's mtime is bumped whenever it is used, and
|
||||
the least recently used files are evicted when the cache exceeds its cap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
from PySide6.QtGui import QPixmap
|
||||
|
||||
from .carddb import CardDB
|
||||
|
||||
USER_AGENT = "mtga-assistant/0.1 (personal desktop tracker)"
|
||||
SCRYFALL = "https://api.scryfall.com"
|
||||
MIN_REQUEST_INTERVAL = 0.11 # Scryfall asks for <= 10 requests/second
|
||||
|
||||
|
||||
class ImageCache(QObject):
|
||||
image_ready = Signal(int) # grpId
|
||||
size_changed = Signal(int) # bytes on disk
|
||||
_downloaded = Signal(int)
|
||||
|
||||
def __init__(self, cache_dir: Path, cards: CardDB, max_bytes: int):
|
||||
super().__init__()
|
||||
self.dir = cache_dir / "images"
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cards = cards
|
||||
self.max_bytes = max_bytes
|
||||
self._pool = ThreadPoolExecutor(max_workers=3, thread_name_prefix="scryfall")
|
||||
self._lock = threading.Lock()
|
||||
self._inflight: set[int] = set()
|
||||
self._failed: set[int] = set()
|
||||
self._last_request = 0.0
|
||||
self._pixmaps: dict[int, QPixmap] = {}
|
||||
# Emitted from worker threads; Qt queues delivery onto this object's (GUI) thread.
|
||||
self._downloaded.connect(self._on_downloaded)
|
||||
self.evict()
|
||||
|
||||
def path_for(self, grp_id: int) -> Path:
|
||||
return self.dir / f"{grp_id}.jpg"
|
||||
|
||||
def pixmap(self, grp_id: int) -> QPixmap | None:
|
||||
"""Return the image if cached; otherwise start a download and return None."""
|
||||
if pm := self._pixmaps.get(grp_id):
|
||||
return pm
|
||||
path = self.path_for(grp_id)
|
||||
if path.exists():
|
||||
try:
|
||||
path.touch()
|
||||
except OSError:
|
||||
pass
|
||||
pm = QPixmap(str(path))
|
||||
if not pm.isNull():
|
||||
if len(self._pixmaps) > 200:
|
||||
self._pixmaps.clear()
|
||||
self._pixmaps[grp_id] = pm
|
||||
return pm
|
||||
self.request(grp_id)
|
||||
return None
|
||||
|
||||
def request(self, grp_id: int) -> None:
|
||||
with self._lock:
|
||||
if grp_id in self._inflight or grp_id in self._failed:
|
||||
return
|
||||
self._inflight.add(grp_id)
|
||||
self._pool.submit(self._download, grp_id)
|
||||
|
||||
def is_failed(self, grp_id: int) -> bool:
|
||||
return grp_id in self._failed
|
||||
|
||||
# -- worker side ---------------------------------------------------------
|
||||
|
||||
def _throttle(self) -> None:
|
||||
with self._lock:
|
||||
wait = self._last_request + MIN_REQUEST_INTERVAL - time.monotonic()
|
||||
self._last_request = max(time.monotonic(), self._last_request + MIN_REQUEST_INTERVAL)
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
|
||||
def _candidate_urls(self, grp_id: int) -> list[str]:
|
||||
q = "format=image&version=normal"
|
||||
urls = [f"{SCRYFALL}/cards/arena/{grp_id}?{q}"]
|
||||
info = self.cards.get(grp_id)
|
||||
if info.set_code and info.collector_number:
|
||||
set_code = urllib.parse.quote(info.set_code.lower())
|
||||
cn = urllib.parse.quote(info.collector_number)
|
||||
urls.append(f"{SCRYFALL}/cards/{set_code}/{cn}?{q}")
|
||||
if info.name and not info.name.startswith(("Unknown card", "Card #")):
|
||||
exact = urllib.parse.quote(info.name.split(" // ")[0])
|
||||
urls.append(f"{SCRYFALL}/cards/named?exact={exact}&{q}")
|
||||
return urls
|
||||
|
||||
def _download(self, grp_id: int) -> None:
|
||||
ok = False
|
||||
try:
|
||||
for url in self._candidate_urls(grp_id):
|
||||
self._throttle()
|
||||
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "*/*"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
data = resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
continue
|
||||
break
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
break
|
||||
tmp = self.path_for(grp_id).with_suffix(".part")
|
||||
tmp.write_bytes(data)
|
||||
tmp.replace(self.path_for(grp_id))
|
||||
ok = True
|
||||
break
|
||||
finally:
|
||||
with self._lock:
|
||||
self._inflight.discard(grp_id)
|
||||
if not ok:
|
||||
self._failed.add(grp_id)
|
||||
if ok:
|
||||
self._downloaded.emit(grp_id)
|
||||
|
||||
def _on_downloaded(self, grp_id: int) -> None:
|
||||
self.evict()
|
||||
self.image_ready.emit(grp_id)
|
||||
|
||||
# -- cache management ------------------------------------------------------
|
||||
|
||||
def disk_usage(self) -> int:
|
||||
return sum(p.stat().st_size for p in self.dir.glob("*.jpg"))
|
||||
|
||||
def evict(self) -> None:
|
||||
files = []
|
||||
for p in self.dir.glob("*.jpg"):
|
||||
try:
|
||||
st = p.stat()
|
||||
except OSError:
|
||||
continue
|
||||
files.append((st.st_mtime, st.st_size, p))
|
||||
total = sum(f[1] for f in files)
|
||||
if total > self.max_bytes:
|
||||
for _, size, p in sorted(files):
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
total -= size
|
||||
if p.stem.isdigit():
|
||||
self._pixmaps.pop(int(p.stem), None)
|
||||
if total <= self.max_bytes * 0.9:
|
||||
break
|
||||
self.size_changed.emit(total)
|
||||
|
||||
def clear(self) -> None:
|
||||
for p in self.dir.glob("*"):
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
self._pixmaps.clear()
|
||||
self._failed.clear()
|
||||
self.size_changed.emit(0)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._pool.shutdown(wait=False, cancel_futures=True)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Turn Player.log lines into typed events.
|
||||
|
||||
The log interleaves plain text with JSON payloads. The payloads we care about are:
|
||||
|
||||
* a line starting with ``{`` that holds ``greToClientEvent`` (in-game state) or
|
||||
``matchGameRoomStateChangedEvent`` (match start / end), preceded by a header line;
|
||||
* ``[UnityCrossThreadLogger]==> EventSetDeckV3 {...}`` requests, whose ``request``
|
||||
field is itself a JSON string carrying the deck submitted to an event.
|
||||
|
||||
This module has no Qt dependency so it can be unit-tested and replayed headlessly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
MAX_PENDING_LINES = 5000
|
||||
|
||||
|
||||
@dataclass
|
||||
class GreMessages:
|
||||
messages: list[dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchRoom:
|
||||
state: str
|
||||
match_id: str
|
||||
players: list[dict[str, Any]]
|
||||
final_result: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeckSubmitted:
|
||||
name: str
|
||||
event: str
|
||||
main: Counter[int] = field(default_factory=Counter)
|
||||
sideboard: Counter[int] = field(default_factory=Counter)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientDeck:
|
||||
"""Deck the client submitted for the next game of a match (after sideboarding)."""
|
||||
|
||||
main: Counter[int]
|
||||
sideboard: Counter[int]
|
||||
|
||||
|
||||
Event = GreMessages | MatchRoom | DeckSubmitted | ClientDeck
|
||||
|
||||
_REQUEST_RE = re.compile(r"==> (EventSetDeck\w*) (\{.*)$")
|
||||
|
||||
|
||||
class LogParser:
|
||||
"""Incremental line parser. Feed complete lines; get events back."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pending: list[str] = []
|
||||
|
||||
def reset(self) -> None:
|
||||
self._pending.clear()
|
||||
|
||||
def feed(self, line: str) -> list[Event]:
|
||||
line = line.rstrip("\r\n")
|
||||
if self._pending:
|
||||
# Continuing a multi-line JSON body.
|
||||
if line.startswith("[") and not line.startswith("[{"):
|
||||
self._pending.clear() # a new log entry began; drop the fragment
|
||||
else:
|
||||
self._pending.append(line)
|
||||
# Pretty-printed bodies close with a '}' at column 0; only reparse then.
|
||||
return self._try_pending() if line.startswith("}") else []
|
||||
|
||||
if line.startswith("{"):
|
||||
self._pending = [line]
|
||||
return self._try_pending()
|
||||
|
||||
if "==> EventSetDeck" in line:
|
||||
m = _REQUEST_RE.search(line)
|
||||
if m:
|
||||
try:
|
||||
return _deck_submitted(json.loads(m.group(2)))
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
||||
return []
|
||||
return []
|
||||
|
||||
def _try_pending(self) -> list[Event]:
|
||||
text = "\n".join(self._pending)
|
||||
try:
|
||||
obj = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
if len(self._pending) > MAX_PENDING_LINES:
|
||||
self._pending.clear()
|
||||
return []
|
||||
self._pending.clear()
|
||||
return _classify(obj)
|
||||
|
||||
|
||||
def _classify(obj: Any) -> list[Event]:
|
||||
if not isinstance(obj, dict):
|
||||
return []
|
||||
if gre := obj.get("greToClientEvent"):
|
||||
msgs = gre.get("greToClientMessages") or []
|
||||
return [GreMessages(msgs)] if msgs else []
|
||||
if room := obj.get("matchGameRoomStateChangedEvent"):
|
||||
info = room.get("gameRoomInfo") or {}
|
||||
cfg = info.get("gameRoomConfig") or {}
|
||||
return [
|
||||
MatchRoom(
|
||||
state=info.get("stateType", ""),
|
||||
match_id=cfg.get("matchId", "") or (info.get("finalMatchResult") or {}).get("matchId", ""),
|
||||
players=cfg.get("reservedPlayers") or [],
|
||||
final_result=info.get("finalMatchResult"),
|
||||
)
|
||||
]
|
||||
payload = obj.get("payload")
|
||||
if isinstance(payload, dict) and payload.get("type") == "ClientMessageType_SubmitDeckResp":
|
||||
deck = (payload.get("submitDeckResp") or {}).get("deck") or {}
|
||||
return [ClientDeck(Counter(deck.get("deckCards") or []), Counter(deck.get("sideboardCards") or []))]
|
||||
return []
|
||||
|
||||
|
||||
def _deck_submitted(payload: dict[str, Any]) -> list[Event]:
|
||||
req = json.loads(payload["request"])
|
||||
deck = req.get("Deck") or {}
|
||||
main: Counter[int] = Counter()
|
||||
side: Counter[int] = Counter()
|
||||
for c in deck.get("MainDeck") or []:
|
||||
main[int(c["cardId"])] += int(c["quantity"])
|
||||
for c in deck.get("Sideboard") or []:
|
||||
side[int(c["cardId"])] += int(c["quantity"])
|
||||
summary = req.get("Summary") or {}
|
||||
return [DeckSubmitted(name=summary.get("Name", ""), event=req.get("EventName", ""), main=main, sideboard=side)]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Background thread that follows Player.log (or replays a saved log) and emits events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from .logparse import LogParser
|
||||
|
||||
POLL_INTERVAL = 0.25
|
||||
BATCH_MAX = 200
|
||||
|
||||
|
||||
class LogFollower(QThread):
|
||||
events = Signal(list) # list[Event]
|
||||
status = Signal(str)
|
||||
reset = Signal() # log was replaced (MTGA restarted): state must be rebuilt
|
||||
|
||||
def __init__(self, path: Path, replay_rate: float | None = None):
|
||||
"""replay_rate: if set, read the file once at that many events/second, then stop."""
|
||||
super().__init__()
|
||||
self.path = path
|
||||
self.replay_rate = replay_rate
|
||||
self._stop = False
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop = True
|
||||
|
||||
def run(self) -> None:
|
||||
if self.replay_rate:
|
||||
self._replay()
|
||||
else:
|
||||
self._follow()
|
||||
|
||||
def _replay(self) -> None:
|
||||
parser = LogParser()
|
||||
delay = 1.0 / self.replay_rate
|
||||
self.status.emit(f"Replaying {self.path.name}")
|
||||
with open(self.path, encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
if self._stop:
|
||||
return
|
||||
evs = parser.feed(line)
|
||||
if evs:
|
||||
self.events.emit(evs)
|
||||
time.sleep(delay)
|
||||
self.status.emit(f"Replay of {self.path.name} finished")
|
||||
|
||||
def _follow(self) -> None:
|
||||
parser = LogParser()
|
||||
f = None
|
||||
inode = None
|
||||
pos = 0
|
||||
buf = b""
|
||||
waiting_reported = False
|
||||
following = False
|
||||
while not self._stop:
|
||||
try:
|
||||
st = os.stat(self.path)
|
||||
except FileNotFoundError:
|
||||
if not waiting_reported:
|
||||
self.status.emit(f"Waiting for {self.path}")
|
||||
waiting_reported = True
|
||||
time.sleep(1.0)
|
||||
continue
|
||||
waiting_reported = False
|
||||
|
||||
if f is None or st.st_ino != inode or st.st_size < pos:
|
||||
# First open, or MTGA restarted and replaced/truncated the log.
|
||||
if f is not None:
|
||||
f.close()
|
||||
self.reset.emit()
|
||||
f = open(self.path, "rb")
|
||||
inode, pos, buf = st.st_ino, 0, b""
|
||||
parser.reset()
|
||||
self.status.emit(f"Reading {self.path.name}")
|
||||
following = False
|
||||
|
||||
chunk = f.read()
|
||||
if not chunk:
|
||||
if not following:
|
||||
self.status.emit(f"Following {self.path.name}")
|
||||
following = True
|
||||
time.sleep(POLL_INTERVAL)
|
||||
continue
|
||||
pos = f.tell()
|
||||
buf += chunk
|
||||
lines = buf.split(b"\n")
|
||||
buf = lines.pop() # keep a trailing partial line for the next read
|
||||
|
||||
batch = []
|
||||
for line in lines:
|
||||
batch.extend(parser.feed(line.decode("utf-8", errors="replace")))
|
||||
if len(batch) >= BATCH_MAX:
|
||||
self.events.emit(batch)
|
||||
batch = []
|
||||
if batch:
|
||||
self.events.emit(batch)
|
||||
if f is not None:
|
||||
f.close()
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Locate MTGA's Player.log and install directory inside a Steam/Proton setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
MTGA_APP_ID = "2141910"
|
||||
LOG_SUBPATH = Path(
|
||||
"pfx/drive_c/users/steamuser/AppData/LocalLow/Wizards Of The Coast/MTGA"
|
||||
)
|
||||
|
||||
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "mtga-assistant"
|
||||
CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "mtga-assistant"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
||||
|
||||
STEAM_ROOTS = [
|
||||
Path.home() / ".local/share/Steam",
|
||||
Path.home() / ".steam/steam",
|
||||
Path.home() / ".steam/root",
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
log_path: Path | None = None
|
||||
mtga_dir: Path | None = None
|
||||
cache_max_mb: int = 500
|
||||
|
||||
|
||||
def load_config() -> Config:
|
||||
cfg = Config()
|
||||
try:
|
||||
data = tomllib.loads(CONFIG_FILE.read_text())
|
||||
except (FileNotFoundError, tomllib.TOMLDecodeError):
|
||||
return cfg
|
||||
if p := data.get("log_path"):
|
||||
cfg.log_path = Path(p).expanduser()
|
||||
if p := data.get("mtga_dir"):
|
||||
cfg.mtga_dir = Path(p).expanduser()
|
||||
if (mb := data.get("cache_max_mb")) is not None:
|
||||
cfg.cache_max_mb = int(mb)
|
||||
return cfg
|
||||
|
||||
|
||||
def steam_libraries() -> list[Path]:
|
||||
"""All Steam library folders listed in any libraryfolders.vdf we can find."""
|
||||
libs: list[Path] = []
|
||||
for root in STEAM_ROOTS:
|
||||
vdf = root / "steamapps/libraryfolders.vdf"
|
||||
try:
|
||||
text = vdf.read_text(errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
for m in re.finditer(r'"path"\s+"([^"]+)"', text):
|
||||
p = Path(m.group(1).replace("\\\\", "\\"))
|
||||
if p not in libs:
|
||||
libs.append(p)
|
||||
if root not in libs and (root / "steamapps").is_dir():
|
||||
libs.append(root)
|
||||
return libs
|
||||
|
||||
|
||||
def find_mtga_library() -> Path | None:
|
||||
for lib in steam_libraries():
|
||||
if (lib / f"steamapps/appmanifest_{MTGA_APP_ID}.acf").exists():
|
||||
return lib
|
||||
return None
|
||||
|
||||
|
||||
def find_log_path(cfg: Config) -> Path | None:
|
||||
if cfg.log_path:
|
||||
return cfg.log_path
|
||||
libs = steam_libraries()
|
||||
mtga_lib = find_mtga_library()
|
||||
# The prefix normally lives in the same library as the game, but check all of them.
|
||||
if mtga_lib:
|
||||
libs = [mtga_lib] + [l for l in libs if l != mtga_lib]
|
||||
for lib in libs:
|
||||
d = lib / "steamapps/compatdata" / MTGA_APP_ID / LOG_SUBPATH
|
||||
if d.is_dir():
|
||||
return d / "Player.log"
|
||||
return None
|
||||
|
||||
|
||||
def find_mtga_dir(cfg: Config) -> Path | None:
|
||||
if cfg.mtga_dir:
|
||||
return cfg.mtga_dir
|
||||
lib = find_mtga_library()
|
||||
if lib:
|
||||
d = lib / "steamapps/common/MTGA"
|
||||
if d.is_dir():
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def find_card_db(mtga_dir: Path) -> Path | None:
|
||||
raw = mtga_dir / "MTGA_Data/Downloads/Raw"
|
||||
dbs = sorted(raw.glob("Raw_CardDatabase_*.mtga"), key=lambda p: p.stat().st_mtime)
|
||||
return dbs[-1] if dbs else None
|
||||
Whitespace-only changes.
@@ -0,0 +1,184 @@
|
||||
"""Sectioned card list: count, mana cost, name, note. Emits the hovered card's grpId."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from PySide6.QtCore import QModelIndex, QRectF, QSize, Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor, QFont, QPainter, QPen
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QHeaderView,
|
||||
QStyledItemDelegate,
|
||||
QStyleOptionViewItem,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
)
|
||||
|
||||
from ..carddb import CardInfo
|
||||
|
||||
GRP_ROLE = Qt.ItemDataRole.UserRole + 1
|
||||
MANA_ROLE = Qt.ItemDataRole.UserRole + 2
|
||||
|
||||
COL_COUNT, COL_MANA, COL_NAME, COL_NOTE = range(4)
|
||||
|
||||
MANA_COLORS = {
|
||||
"W": "#f8f6d8",
|
||||
"U": "#c1d7e9",
|
||||
"B": "#bab1ab",
|
||||
"R": "#e49977",
|
||||
"G": "#a3c095",
|
||||
"C": "#cac5c0",
|
||||
}
|
||||
GENERIC_COLOR = "#cac5c0"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Row:
|
||||
info: CardInfo
|
||||
count: int = 1
|
||||
note: str = ""
|
||||
dim: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Section:
|
||||
key: str # stable id used to remember expanded/collapsed state
|
||||
title: str
|
||||
rows: list[Row] = field(default_factory=list)
|
||||
collapsed_by_default: bool = False
|
||||
|
||||
|
||||
def sort_key(row: Row) -> tuple:
|
||||
i = row.info
|
||||
return (i.is_land, i.mana_value, i.name)
|
||||
|
||||
|
||||
class ManaDelegate(QStyledItemDelegate):
|
||||
PIP = 16
|
||||
|
||||
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
|
||||
symbols = index.data(MANA_ROLE)
|
||||
if not symbols:
|
||||
super().paint(painter, option, index)
|
||||
return
|
||||
self.initStyleOption(option, index)
|
||||
style = option.widget.style() if option.widget else None
|
||||
if style:
|
||||
# Draw selection/hover background without text.
|
||||
option.text = ""
|
||||
style.drawControl(style.ControlElement.CE_ItemViewItem, option, painter, option.widget)
|
||||
painter.save()
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
size = min(self.PIP, option.rect.height() - 4)
|
||||
x = option.rect.left() + 2
|
||||
y = option.rect.top() + (option.rect.height() - size) // 2
|
||||
font = QFont(option.font)
|
||||
font.setPixelSize(int(size * 0.7))
|
||||
font.setBold(True)
|
||||
painter.setFont(font)
|
||||
for sym in symbols:
|
||||
if x + size > option.rect.right():
|
||||
break
|
||||
rect = QRectF(x, y, size, size)
|
||||
parts = [p for p in sym.split("/") if p]
|
||||
phyrexian = "P" in parts
|
||||
parts = [p for p in parts if p != "P"] or ["C"]
|
||||
fills = [MANA_COLORS.get(p, GENERIC_COLOR) for p in parts]
|
||||
painter.setPen(QPen(QColor("#555"), 0.8))
|
||||
if len(fills) >= 2: # hybrid: two half circles
|
||||
painter.setBrush(QBrush(QColor(fills[0])))
|
||||
painter.drawPie(rect, 90 * 16, 180 * 16)
|
||||
painter.setBrush(QBrush(QColor(fills[1])))
|
||||
painter.drawPie(rect, 270 * 16, 180 * 16)
|
||||
else:
|
||||
painter.setBrush(QBrush(QColor(fills[0])))
|
||||
painter.drawEllipse(rect)
|
||||
if phyrexian:
|
||||
label = "φ"
|
||||
elif sym == "C":
|
||||
label = "◇"
|
||||
elif parts[0] not in MANA_COLORS:
|
||||
label = parts[0] # generic number, X, or the "2" of a 2/C hybrid
|
||||
else:
|
||||
label = ""
|
||||
if label:
|
||||
painter.setPen(QColor("#111"))
|
||||
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, label)
|
||||
x += size + 2
|
||||
painter.restore()
|
||||
|
||||
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
|
||||
symbols = index.data(MANA_ROLE) or []
|
||||
return QSize(len(symbols) * (self.PIP + 2) + 4, self.PIP + 6)
|
||||
|
||||
|
||||
class CardList(QTreeWidget):
|
||||
hovered = Signal(int)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setColumnCount(4)
|
||||
self.setHeaderHidden(True)
|
||||
self.setRootIsDecorated(True)
|
||||
self.setIndentation(12)
|
||||
self.setUniformRowHeights(True)
|
||||
self.setMouseTracking(True)
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.setItemDelegateForColumn(COL_MANA, ManaDelegate(self))
|
||||
hdr = self.header()
|
||||
hdr.setStretchLastSection(False)
|
||||
hdr.setSectionResizeMode(COL_COUNT, QHeaderView.ResizeMode.ResizeToContents)
|
||||
hdr.setSectionResizeMode(COL_MANA, QHeaderView.ResizeMode.ResizeToContents)
|
||||
hdr.setSectionResizeMode(COL_NAME, QHeaderView.ResizeMode.Stretch)
|
||||
hdr.setSectionResizeMode(COL_NOTE, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.itemEntered.connect(self._on_entered)
|
||||
self.currentItemChanged.connect(lambda cur, _prev: self._on_entered(cur, 0))
|
||||
self._expanded: dict[str, bool] = {}
|
||||
self._last_render: list | None = None
|
||||
|
||||
def _on_entered(self, item: QTreeWidgetItem | None, _col: int) -> None:
|
||||
if item is not None and (grp := item.data(0, GRP_ROLE)):
|
||||
self.hovered.emit(int(grp))
|
||||
|
||||
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]
|
||||
if signature == self._last_render:
|
||||
return
|
||||
self._last_render = signature
|
||||
|
||||
for i in range(self.topLevelItemCount()):
|
||||
top = self.topLevelItem(i)
|
||||
self._expanded[top.data(0, GRP_ROLE + 10)] = top.isExpanded()
|
||||
scroll = self.verticalScrollBar().value()
|
||||
|
||||
self.clear()
|
||||
bold = QFont(self.font())
|
||||
bold.setBold(True)
|
||||
dim_brush = QBrush(self.palette().color(self.palette().ColorRole.PlaceholderText))
|
||||
for sec in sections:
|
||||
top = QTreeWidgetItem([sec.title, "", "", ""])
|
||||
top.setFirstColumnSpanned(True)
|
||||
top.setFont(0, bold)
|
||||
top.setData(0, GRP_ROLE + 10, sec.key)
|
||||
top.setFlags(Qt.ItemFlag.ItemIsEnabled)
|
||||
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.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}")
|
||||
it.setTextAlignment(COL_COUNT, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
it.setTextAlignment(COL_NOTE, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
if row.dim:
|
||||
for c in range(4):
|
||||
it.setForeground(c, dim_brush)
|
||||
top.addChild(it)
|
||||
top.setExpanded(self._expanded.get(sec.key, not sec.collapsed_by_default))
|
||||
self.verticalScrollBar().setValue(scroll)
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(340, 600)
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Main window: my deck | opponent | card preview."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..carddb import CardDB
|
||||
from ..gamestate import GameState, MatchTracker
|
||||
from ..images import ImageCache
|
||||
from ..logtail import LogFollower
|
||||
from .card_list import CardList, Row, Section
|
||||
from .preview import CardPreview
|
||||
|
||||
REFRESH_MS = 150
|
||||
|
||||
|
||||
def _panel(title: str, body: QWidget) -> QWidget:
|
||||
w = QWidget()
|
||||
lay = QVBoxLayout(w)
|
||||
lay.setContentsMargins(4, 4, 4, 4)
|
||||
label = QLabel(f"<b>{title}</b>")
|
||||
lay.addWidget(label)
|
||||
lay.addWidget(body, 1)
|
||||
return w
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self, cards: CardDB, images: ImageCache, log_path: Path | None,
|
||||
replay_rate: float | None = None):
|
||||
super().__init__()
|
||||
self.setWindowTitle("MTGA Assistant")
|
||||
self.cards = cards
|
||||
self.images = images
|
||||
self.tracker = MatchTracker(card_lookup=cards)
|
||||
self.follower: LogFollower | None = None
|
||||
self._rendered_version = -1
|
||||
|
||||
# Header
|
||||
self.header = QLabel("Waiting for a match…")
|
||||
self.header.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.warning = QLabel()
|
||||
self.warning.setStyleSheet("color: #d08770;")
|
||||
self.warning.setWordWrap(True)
|
||||
self.warning.hide()
|
||||
|
||||
# Panels
|
||||
self.mine = CardList()
|
||||
self.opp = CardList()
|
||||
self.preview = CardPreview(cards, images)
|
||||
self.mine.hovered.connect(self.preview.show_card)
|
||||
self.opp.hovered.connect(self.preview.show_card)
|
||||
|
||||
split = QSplitter(Qt.Orientation.Horizontal)
|
||||
split.addWidget(_panel("My deck", self.mine))
|
||||
split.addWidget(_panel("Opponent", self.opp))
|
||||
split.addWidget(self.preview)
|
||||
split.setStretchFactor(0, 3)
|
||||
split.setStretchFactor(1, 3)
|
||||
split.setStretchFactor(2, 2)
|
||||
|
||||
central = QWidget()
|
||||
lay = QVBoxLayout(central)
|
||||
top = QHBoxLayout()
|
||||
top.addWidget(self.header, 1)
|
||||
lay.addLayout(top)
|
||||
lay.addWidget(self.warning)
|
||||
lay.addWidget(split, 1)
|
||||
self.setCentralWidget(central)
|
||||
|
||||
# Status bar
|
||||
self.status_log = QLabel()
|
||||
self.status_cache = QLabel()
|
||||
self.statusBar().addWidget(self.status_log, 1)
|
||||
self.statusBar().addPermanentWidget(self.status_cache)
|
||||
images.size_changed.connect(self._on_cache_size)
|
||||
self._on_cache_size(images.disk_usage())
|
||||
if not cards.available:
|
||||
self.warning.setText("MTGA card database not found: card names are unavailable. "
|
||||
"Set mtga_dir in ~/.config/mtga-assistant/config.toml.")
|
||||
self.warning.show()
|
||||
|
||||
self._build_menu()
|
||||
|
||||
self.refresh_timer = QTimer(self)
|
||||
self.refresh_timer.setInterval(REFRESH_MS)
|
||||
self.refresh_timer.timeout.connect(self.refresh)
|
||||
self.refresh_timer.start()
|
||||
|
||||
self.resize(1280, 800)
|
||||
if log_path is not None:
|
||||
self.start_log(log_path, replay_rate)
|
||||
else:
|
||||
self.status_log.setText("Player.log not found: use File → Open log…")
|
||||
|
||||
# -- menu ----------------------------------------------------------------
|
||||
|
||||
def _build_menu(self) -> None:
|
||||
file_menu = self.menuBar().addMenu("&File")
|
||||
open_act = QAction("&Open log…", self)
|
||||
open_act.triggered.connect(lambda: self._choose_log(None))
|
||||
replay_act = QAction("&Replay log…", self)
|
||||
replay_act.triggered.connect(lambda: self._choose_log(40.0))
|
||||
quit_act = QAction("&Quit", self)
|
||||
quit_act.setShortcut(QKeySequence.StandardKey.Quit)
|
||||
quit_act.triggered.connect(self.close)
|
||||
file_menu.addActions([open_act, replay_act])
|
||||
file_menu.addSeparator()
|
||||
file_menu.addAction(quit_act)
|
||||
|
||||
cache_menu = self.menuBar().addMenu("&Cache")
|
||||
clear_act = QAction("&Clear image cache", self)
|
||||
clear_act.triggered.connect(self._clear_cache)
|
||||
cache_menu.addAction(clear_act)
|
||||
|
||||
def _choose_log(self, replay_rate: float | None) -> None:
|
||||
start = str(self.follower.path.parent) if self.follower else str(Path.home())
|
||||
path, _ = QFileDialog.getOpenFileName(self, "Choose an MTGA log", start, "Logs (*.log);;All files (*)")
|
||||
if path:
|
||||
self.start_log(Path(path), replay_rate)
|
||||
|
||||
def _clear_cache(self) -> None:
|
||||
if QMessageBox.question(self, "Clear image cache",
|
||||
f"Delete all cached card images in {self.images.dir}?") == QMessageBox.StandardButton.Yes:
|
||||
self.images.clear()
|
||||
|
||||
def _on_cache_size(self, size: int) -> None:
|
||||
self.status_cache.setText(f"Image cache: {size / 1_048_576:.1f} / {self.images.max_bytes / 1_048_576:.0f} MB")
|
||||
|
||||
# -- log -----------------------------------------------------------------
|
||||
|
||||
def start_log(self, path: Path, replay_rate: float | None = None) -> None:
|
||||
if self.follower:
|
||||
self.follower.stop()
|
||||
self.follower.wait(2000)
|
||||
self._reset_tracker()
|
||||
self.follower = LogFollower(path, replay_rate)
|
||||
self.follower.events.connect(self._on_events)
|
||||
self.follower.status.connect(self.status_log.setText)
|
||||
self.follower.reset.connect(self._reset_tracker)
|
||||
self.follower.start()
|
||||
|
||||
def _reset_tracker(self) -> None:
|
||||
self.tracker = MatchTracker(card_lookup=self.cards)
|
||||
self._rendered_version = -1
|
||||
|
||||
def _on_events(self, events: list) -> None:
|
||||
for ev in events:
|
||||
self.tracker.handle(ev)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if self.follower:
|
||||
self.follower.stop()
|
||||
self.follower.wait(2000)
|
||||
self.follower = None
|
||||
self.images.shutdown()
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
self.shutdown()
|
||||
super().closeEvent(event)
|
||||
|
||||
# -- rendering -------------------------------------------------------------
|
||||
|
||||
def _rows(self, counts: Counter[int], note=None, dim: bool = False) -> list[Row]:
|
||||
rows = []
|
||||
for grp, n in counts.items():
|
||||
if n <= 0:
|
||||
continue
|
||||
info = self.cards.get(grp)
|
||||
rows.append(Row(info=info, count=n, note=note(grp, n) if note else "", dim=dim))
|
||||
return rows
|
||||
|
||||
def refresh(self) -> None:
|
||||
t = self.tracker
|
||||
if t.version == self._rendered_version:
|
||||
return
|
||||
self._rendered_version = t.version
|
||||
g = t.game
|
||||
self._render_header(t, g)
|
||||
if g is None:
|
||||
self._render_idle(t)
|
||||
return
|
||||
self._render_mine(t, g)
|
||||
self._render_opp(t, g)
|
||||
|
||||
def _render_header(self, t: MatchTracker, g: GameState | None) -> None:
|
||||
if g is None:
|
||||
self.header.setText("Waiting for a match…")
|
||||
self.warning.setVisible(not self.cards.available)
|
||||
return
|
||||
opp = t.opponent_name or "opponent"
|
||||
parts = [f"<b>vs {opp}</b>"]
|
||||
if t.event_id:
|
||||
parts.append(t.event_id.replace("_", " "))
|
||||
parts.append(f"Game {g.game_number}")
|
||||
if g.turn:
|
||||
whose = "your" if g.active_player == g.my_seat else "their"
|
||||
parts.append(f"Turn {g.turn} ({whose})")
|
||||
if g.life:
|
||||
parts.append(f"Life: you {g.life.get(g.my_seat, '?')} · them {g.life.get(g.opp_seat, '?')}")
|
||||
if t.game_results:
|
||||
w = t.game_results.count("Won")
|
||||
parts.append(f"Games {w}-{len(t.game_results) - w}")
|
||||
if not t.in_match and t.last_result:
|
||||
parts.append(f"<b>Match {t.last_result.lower()}</b>")
|
||||
self.header.setText(" · ".join(parts))
|
||||
|
||||
lib_size = g.zone_size("ZoneType_Library", g.my_seat)
|
||||
unaccounted = sum(t.my_library_remaining().values()) - lib_size if g.zones else 0
|
||||
if g.desynced or unaccounted:
|
||||
self.warning.setText(
|
||||
"Part of the game log was skipped by MTGA (oversized message), so some zones may be "
|
||||
"out of date until they next change."
|
||||
+ (f" {unaccounted} card(s) of yours are unaccounted for." if unaccounted > 0 else "")
|
||||
)
|
||||
self.warning.show()
|
||||
else:
|
||||
self.warning.setVisible(not self.cards.available)
|
||||
|
||||
def _render_idle(self, t: MatchTracker) -> None:
|
||||
sections = []
|
||||
if d := t.submitted_deck:
|
||||
sections.append(Section("deck", f"{d.name or 'Last submitted deck'} ({sum(d.main.values())})",
|
||||
self._rows(d.main)))
|
||||
if d.sideboard:
|
||||
sections.append(Section("side", f"Sideboard ({sum(d.sideboard.values())})",
|
||||
self._rows(d.sideboard), collapsed_by_default=True))
|
||||
self.mine.set_sections(sections)
|
||||
self.opp.set_sections([])
|
||||
|
||||
def _render_mine(self, t: MatchTracker, g: GameState) -> None:
|
||||
me = g.my_seat
|
||||
lib_size = g.zone_size("ZoneType_Library", me)
|
||||
remaining = t.my_library_remaining()
|
||||
denom = lib_size or sum(remaining.values()) or 1
|
||||
|
||||
def pct(_grp: int, n: int) -> str:
|
||||
return f"{100 * n / denom:.0f}%"
|
||||
|
||||
# Every card in the deck; ones with no copies left stay listed, greyed out.
|
||||
lib_rows = [
|
||||
Row(self.cards.get(grp), n, pct(grp, n) if n else "", dim=not n)
|
||||
for grp in g.deck
|
||||
for n in [remaining.get(grp, 0)]
|
||||
]
|
||||
|
||||
sections = [
|
||||
Section("library", f"Library ({lib_size})", lib_rows),
|
||||
Section("hand", f"Hand ({g.zone_size('ZoneType_Hand', me)})", self._rows(g.cards_in("ZoneType_Hand", owner=me))),
|
||||
Section("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=me))),
|
||||
Section("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', me)})",
|
||||
self._rows(g.cards_in("ZoneType_Graveyard", owner=me))),
|
||||
Section("exile", "Exile", self._rows(g.cards_in("ZoneType_Exile", owner=me))),
|
||||
Section("sideboard", f"Sideboard ({sum(g.sideboard.values())})", self._rows(g.sideboard),
|
||||
collapsed_by_default=True),
|
||||
]
|
||||
self.mine.set_sections([s for s in sections if s.rows or s.key in ("library", "hand")])
|
||||
|
||||
def _render_opp(self, t: MatchTracker, g: GameState) -> None:
|
||||
opp = g.opp_seat
|
||||
hand_size = g.zone_size("ZoneType_Hand", opp)
|
||||
lib_size = g.zone_size("ZoneType_Library", opp)
|
||||
known_hand = g.opp_known_in("ZoneType_Hand")
|
||||
known_lib = g.opp_known_in("ZoneType_Library")
|
||||
seen = t.opp_seen_match
|
||||
sections = [
|
||||
Section("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=opp))),
|
||||
Section("stack", "Stack", self._rows(g.cards_in("ZoneType_Stack", owner=opp))),
|
||||
Section("hand", f"Hand ({hand_size}, {sum(known_hand.values())} known)", self._rows(known_hand)),
|
||||
Section("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', opp)})",
|
||||
self._rows(g.cards_in("ZoneType_Graveyard", owner=opp))),
|
||||
Section("exile", "Exile", self._rows(g.cards_in("ZoneType_Exile", owner=opp))),
|
||||
Section("library", f"Library ({lib_size}, {sum(known_lib.values())} known)", self._rows(known_lib)),
|
||||
Section("seen", f"Seen this match ({sum(seen.values())} cards)",
|
||||
[r for r in self._rows(seen) if not r.info.is_token]),
|
||||
]
|
||||
self.opp.set_sections([s for s in sections if s.rows or s.key in ("battlefield", "hand", "seen")])
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Large image of the hovered card, with a text fallback while it downloads."""
|
||||
|
||||
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 ..carddb import CardDB
|
||||
from ..images import ImageCache
|
||||
|
||||
|
||||
class CardPreview(QWidget):
|
||||
def __init__(self, cards: CardDB, images: ImageCache, parent=None):
|
||||
super().__init__(parent)
|
||||
self.cards = cards
|
||||
self.images = images
|
||||
self.grp_id: int | None = None
|
||||
self._pixmap: QPixmap | None = None
|
||||
|
||||
self.image = QLabel()
|
||||
self.image.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.image.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
|
||||
self.image.setMinimumSize(200, 280)
|
||||
self.caption = QLabel()
|
||||
self.caption.setWordWrap(True)
|
||||
self.caption.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(4, 4, 4, 4)
|
||||
lay.addWidget(self.image, 1)
|
||||
lay.addWidget(self.caption)
|
||||
|
||||
images.image_ready.connect(self._on_image_ready)
|
||||
self._show_placeholder("Hover a card to preview it")
|
||||
|
||||
def show_card(self, grp_id: int) -> None:
|
||||
if grp_id == self.grp_id:
|
||||
return
|
||||
self.grp_id = grp_id
|
||||
info = self.cards.get(grp_id)
|
||||
details = [info.type_line]
|
||||
if info.power or info.toughness:
|
||||
details.append(f"{info.power}/{info.toughness}")
|
||||
if info.set_code:
|
||||
details.append(f"{info.set_code} #{info.collector_number}")
|
||||
self.caption.setText(f"<b>{info.name}</b><br>{' · '.join(d for d in details if d)}")
|
||||
pm = self.images.pixmap(grp_id)
|
||||
if pm is not None:
|
||||
self._pixmap = pm
|
||||
self._rescale()
|
||||
elif self.images.is_failed(grp_id):
|
||||
self._show_placeholder("No image available")
|
||||
else:
|
||||
self._show_placeholder("Loading image…")
|
||||
|
||||
def _show_placeholder(self, text: str) -> None:
|
||||
self._pixmap = None
|
||||
self.image.setPixmap(QPixmap())
|
||||
self.image.setText(text)
|
||||
|
||||
def _on_image_ready(self, grp_id: int) -> None:
|
||||
if grp_id == self.grp_id:
|
||||
self.grp_id = None
|
||||
self.show_card(grp_id)
|
||||
|
||||
def _rescale(self) -> None:
|
||||
if self._pixmap is None:
|
||||
return
|
||||
self.image.setPixmap(
|
||||
self._pixmap.scaled(self.image.size(), Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation)
|
||||
)
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None:
|
||||
super().resizeEvent(event)
|
||||
self._rescale()
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(320, 600)
|
||||
Reference in new issue
Block a user