"""Main window: a top bar switching between the Deck, Match and Turn views."""
from __future__ import annotations
import time
from pathlib import Path
from PySide6.QtCore import QSettings, Qt, QThread, QTimer, Signal
from PySide6.QtGui import QAction, QFont, QFontMetrics, QKeySequence
from PySide6.QtWidgets import (
QApplication,
QButtonGroup,
QFileDialog,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QMenu,
QMessageBox,
QPushButton,
QSizePolicy,
QStackedWidget,
QSystemTrayIcon,
QToolButton,
QVBoxLayout,
QWidget,
)
from .. import paths
from ..background import LaunchWatcher, autostart_enabled, set_autostart
from ..carddb import CardDB
from ..gamestate import MatchTracker
from ..history import HistoryRecorder, HistoryStore, import_log, replay_game
from ..images import ImageCache
from ..logtail import LogFollower
from . import theme
from .arrange import ArrangeDialog
from .deck_view import MY_SECTIONS, OPP_SECTIONS, DeckView
from .history_view import HistoryView, fmt_when
from .match_view import MATCH_TOGGLES, MatchView
from .toggles import StayOpenMenu, Toggles
from .turn_view import TURN_DEFAULTS_OFF, TURN_TOGGLES, TurnView
REFRESH_MS = 150
# Panels of the Match page that can be hidden (rows disappear when all their panels are hidden).
SECTION_TOGGLES = [
("you", "You"),
("match", "Match"),
("opponent", "Opponent"),
("turns", "Turn stats"),
("my_deck", "My deck"),
("preview", "Preview"),
("opp_deck", "Opponent's cards"),
]
VIEWS = ["Match", "History"]
MATCH, HISTORY = 0, 1
class HistoryImport(QThread):
"""Records completed matches from an older log (Player-prev.log) in the background."""
done = Signal(int)
def __init__(self, path: Path, store: HistoryStore, cards: CardDB):
super().__init__()
self.path, self.store, self.cards = path, store, cards
def run(self) -> None:
# Own card DB connection: this runs off the GUI thread.
cards = CardDB(self.cards.path)
saved = 0
try:
st = self.path.stat()
except OSError:
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)
class MainWindow(QMainWindow):
def __init__(self, cards: CardDB, images: ImageCache, history: HistoryStore, log_path: Path | None,
replay_rate: float | None = None):
super().__init__()
self.setWindowTitle("MTGA Assistant")
self.cards = cards
self.images = images
self.history = history
self.recorder = HistoryRecorder(history)
self.tracker = MatchTracker(card_lookup=cards)
# A past game opened from the History view: (tracker, summary text). None = live.
self.past: tuple[MatchTracker, str] | None = None
self._importers: list[HistoryImport] = []
self.follower: LogFollower | None = None
self.replaying = False
self._rendered: dict[int, int] = {} # view index -> tracker version last rendered
self._settings = QSettings("mtga-assistant", "mtga-assistant")
self.toggles = [
Toggles("sections", SECTION_TOGGLES, parent=self),
Toggles("match", MATCH_TOGGLES, parent=self),
Toggles("turn", TURN_TOGGLES, TURN_DEFAULTS_OFF, parent=self),
]
self.my_sections = Toggles("deck_mine", MY_SECTIONS, title="My deck", parent=self)
self.opp_sections = Toggles("deck_opp", OPP_SECTIONS, title="Opponent", parent=self)
self.toggles[2].title = "Turn stats"
for tg in (*self.toggles, self.my_sections, self.opp_sections):
tg.changed.connect(self._force_refresh)
# Views. One DeckView is shared by the Deck page and the bottom half of the Match page.
self.deck_view = DeckView(cards, images, self.my_sections, self.opp_sections)
self.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.match_view, self.history_view):
self.stack.addWidget(page)
# Top bar
self.nav = QButtonGroup(self)
self.nav.setExclusive(True)
bar = QHBoxLayout()
bar.setSpacing(8)
for i, name in enumerate(VIEWS):
btn = QPushButton(name)
btn.setObjectName("navButton")
btn.setCheckable(True)
btn.setShortcut(QKeySequence(f"Ctrl+{i + 1}"))
btn.setToolTip(f"{name} view (Ctrl+{i + 1})")
# 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")
self.customize.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
self.customize_menu = StayOpenMenu(self)
self.customize_menu.aboutToShow.connect(self._fill_customize)
self.customize.setMenu(self.customize_menu)
bar.addWidget(self.customize)
bar.addWidget(self._app_menu_button())
self.warning = QLabel()
self.warning.setObjectName("banner")
self.warning.setWordWrap(True)
self.warning.hide()
# Shown while looking at a past game.
self.past_bar = QFrame()
self.past_bar.setObjectName("pastBar")
past_lay = QHBoxLayout(self.past_bar)
past_lay.setContentsMargins(12, 6, 8, 6)
self.past_label = QLabel()
self.past_label.setTextFormat(Qt.TextFormat.RichText)
back = QPushButton("Back to live")
back.clicked.connect(self.close_past_game)
past_lay.addWidget(self.past_label, 1)
past_lay.addWidget(back)
self.past_bar.hide()
central = QWidget()
central.setObjectName("page")
lay = QVBoxLayout(central)
lay.setContentsMargins(12, 10, 12, 6)
lay.setSpacing(10)
lay.addLayout(bar)
lay.addWidget(self.warning)
lay.addWidget(self.past_bar)
lay.addWidget(self.stack, 1)
self.setCentralWidget(central)
# Status bar
self.status_log = QLabel()
self.status_cache = QLabel()
self.status_gap = QLabel()
self.status_gap.setStyleSheet(f"color: {theme.DANGER};")
self.status_gap.hide()
self.statusBar().addWidget(self.status_log, 1)
self.statusBar().addPermanentWidget(self.status_gap)
self.statusBar().addPermanentWidget(self.status_cache)
images.size_changed.connect(self._on_cache_size)
self._on_cache_size(images.disk_usage())
self.refresh_timer = QTimer(self)
self.refresh_timer.setInterval(REFRESH_MS)
self.refresh_timer.timeout.connect(self.refresh)
self.refresh_timer.start()
self.clock_timer = QTimer(self)
self.clock_timer.setInterval(1000)
self.clock_timer.timeout.connect(self._tick)
self.clock_timer.start()
self.resize(1400, 880)
self._quitting = False
self.launch_watcher: LaunchWatcher | None = None
self._setup_tray()
self.history_view.set_games(history.games())
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:
self.status_log.setText("Player.log not found: use ☰ → Open log…")
# -- top bar -------------------------------------------------------------
def _app_menu_button(self) -> QToolButton:
btn = QToolButton()
btn.setText("☰")
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
menu = QMenu(self)
open_act = menu.addAction("Open log…")
open_act.triggered.connect(lambda: self._choose_log(None))
replay_act = menu.addAction("Replay log…")
replay_act.triggered.connect(lambda: self._choose_log(40.0))
menu.addSeparator()
clear_act = menu.addAction("Clear image cache")
clear_act.triggered.connect(self._clear_cache)
menu.addSeparator()
for label, key, default, tip in (
("Keep running in the background when closed", "background/close_to_tray", True,
"Closing the window hides it; games keep being recorded. Quit from the tray or this menu."),
("Show the window when MTGA starts", "background/show_on_launch", True,
"Brings the window up when MTGA starts a new log."),
):
act = menu.addAction(label)
act.setCheckable(True)
act.setChecked(self._settings.value(key, default, type=bool))
act.setToolTip(tip)
act.toggled.connect(lambda on, k=key: self._settings.setValue(k, on))
login = menu.addAction("Start at login (in the background)")
login.setCheckable(True)
login.setChecked(autostart_enabled())
login.toggled.connect(self._set_autostart)
menu.setToolTipsVisible(True)
menu.addSeparator()
quit_act = QAction("Quit", self)
quit_act.setShortcut(QKeySequence.StandardKey.Quit)
quit_act.triggered.connect(self.quit_app)
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]:
return [self.my_sections, self.opp_sections, self.toggles[2]]
def _fill_customize(self) -> None:
menu = self.customize_menu
menu.clear()
for heading, tg in (("Sections", self.toggles[0]), ("Player panel details", self.toggles[1])):
title = menu.addAction(heading)
title.setEnabled(False) # a heading, not an option
font = title.font()
font.setBold(True)
title.setFont(font)
tg.fill_menu(menu)
menu.addSeparator()
# Deck sections and turn stats (order and visibility) live in the Arrange dialog.
menu.addAction("Arrange deck sections and turn stats…").triggered.connect(self._open_arrange)
def _apply_sections(self) -> None:
on = self.toggles[0].on
self.match_view.show_panels(you=on("you"), match=on("match"), opponent=on("opponent"),
turns=on("turns"))
self.deck_view.show_panels(my_deck=on("my_deck"), preview=on("preview"), opp_deck=on("opp_deck"))
def _open_arrange(self) -> None:
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 == MATCH)
self.stack.setCurrentIndex(index)
self.nav.button(index).setChecked(True)
self._settings.setValue("view2", index)
self._force_refresh()
# -- menu actions ----------------------------------------------------------
def _choose_log(self, replay_rate: float | None) -> None:
start = str(self.follower.path.parent) if self.follower else str(Path.home())
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.replaying = replay_rate is not None
self.follower = LogFollower(path, replay_rate)
self.follower.events.connect(self._on_events)
self.follower.status.connect(self.status_log.setText)
self.follower.reset.connect(self._reset_tracker)
self.follower.start()
if self.launch_watcher is not None:
self.launch_watcher.deleteLater()
self.launch_watcher = None
if not self.replaying:
self.launch_watcher = LaunchWatcher(path, self)
self.launch_watcher.launched.connect(self._on_mtga_launched)
def _reset_tracker(self) -> None:
self.tracker = MatchTracker(card_lookup=self.cards)
self.recorder.reset()
self._force_refresh()
def _on_events(self, events: list) -> None:
saved = False
for ev in events:
self.tracker.handle(ev)
saved |= self.recorder.observe(ev, self.tracker)
if saved:
self.history_view.set_games(self.history.games())
# -- history ---------------------------------------------------------------
def import_history(self, path: Path) -> None:
worker = HistoryImport(path, self.history, self.cards)
worker.done.connect(lambda n: n and self.history_view.set_games(self.history.games()))
worker.finished.connect(lambda: self._importers.remove(worker))
self._importers.append(worker)
worker.start()
def open_past_game(self, match_id: str, game_number: int) -> None:
summary = next((g for g in self.history.games()
if g.match_id == match_id and g.game_number == game_number), None)
events = self.history.events(match_id)
if summary is None or not events:
return
tracker = replay_game(events, game_number, self.cards)
when = fmt_when(summary.started_ms)
result = {"Won": "won", "Lost": "lost"}.get(summary.result, "")
text = (f"Viewing a past game: vs {summary.opponent or '?'} · {when}"
+ (f" · game {game_number} of 3" if summary.best_of == 3 else "")
+ (f" · you {result}" if result else ""))
self.past = (tracker, text)
self.past_label.setText(text)
self.past_bar.show()
self.turn_view.selected = None
self.show_view(MATCH)
def close_past_game(self) -> None:
self.past = None
self.past_bar.hide()
self._force_refresh()
@property
def shown_tracker(self) -> MatchTracker:
return self.past[0] if self.past else self.tracker
def shutdown(self) -> None:
for worker in list(self._importers):
worker.wait(5000)
if self.follower:
self.follower.stop()
self.follower.wait(2000)
self.follower = None
self.images.shutdown()
# -- background ------------------------------------------------------------
def _setup_tray(self) -> None:
self.tray: QSystemTrayIcon | None = None
if not QSystemTrayIcon.isSystemTrayAvailable():
return
self.tray = QSystemTrayIcon(QApplication.windowIcon(), self)
self.tray.setToolTip("MTGA Assistant")
menu = QMenu(self)
menu.addAction("Show window").triggered.connect(self.bring_to_front)
menu.addAction("Hide window").triggered.connect(self.hide)
menu.addSeparator()
menu.addAction("Quit").triggered.connect(self.quit_app)
self.tray.setContextMenu(menu)
self.tray.activated.connect(self._on_tray_activated)
self.tray.show()
def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
if reason == QSystemTrayIcon.ActivationReason.Trigger:
if self.isVisible():
self.hide()
else:
self.bring_to_front()
def bring_to_front(self) -> None:
if self.isMinimized():
self.showNormal()
self.show()
self.raise_()
self.activateWindow()
def _on_mtga_launched(self) -> None:
if self._settings.value("background/show_on_launch", True, type=bool):
self.bring_to_front()
def _set_autostart(self, enabled: bool) -> None:
try:
set_autostart(enabled)
except OSError as e:
QMessageBox.warning(self, "Start at login", f"Couldn't change the login item: {e}")
def quit_app(self) -> None:
self._quitting = True
QApplication.quit()
def closeEvent(self, event) -> None:
keep = self._settings.value("background/close_to_tray", True, type=bool)
if keep and not self.replaying and not self._quitting:
event.ignore()
self.hide()
if self.tray is not None and not self._settings.value("background/told", False, type=bool):
self.tray.showMessage("MTGA Assistant", "Still recording games in the background. "
"Use the tray icon to show the window or quit.",
QApplication.windowIcon(), 6000)
self._settings.setValue("background/told", True)
return
super().closeEvent(event)
QApplication.quit() # not running in the background: closing the window quits
def showEvent(self, event) -> None:
super().showEvent(event)
self._force_refresh() # rendering is skipped while hidden
# -- rendering -------------------------------------------------------------
def now_ms(self) -> int:
"""Wall clock when following live; the newest log time when replaying or viewing the past."""
t = self.shown_tracker
if (self.replaying or self.past) and t.last_ms:
return t.last_ms
return int(time.time() * 1000)
def _force_refresh(self) -> None:
self._rendered.clear()
self.refresh()
def _tick(self) -> None:
if self.stack.currentIndex() == MATCH:
self.match_view.tick(self.shown_tracker, self.now_ms())
def refresh(self) -> None:
if not self.isVisible():
return # tracking continues; the views catch up when shown
t = self.shown_tracker
index = self.stack.currentIndex()
key = (id(t), t.version)
if self._rendered.get(index) == key:
return
self._rendered[index] = key
self._render_header(t, show_match=index == MATCH)
self._apply_sections()
if index == MATCH:
self.deck_view.render(t)
self.match_view.render(t, self.now_ms())
self.turn_view.render(t)
def _render_header(self, t: MatchTracker, show_match: bool = True) -> None:
g = t.game
if not show_match:
# The match summary belongs to the Match tab only.
self.header.setText("")
self.header.setToolTip("")
elif g is None:
self.header.setText("Waiting for a match…")
else:
parts = [f"vs {t.opponent_name or 'opponent'}"]
if t.event_id:
parts.append(t.event_id.replace("_", " "))
parts.append(f"Game {g.game_number}")
if g.turn:
parts.append(f"Turn {g.turn}")
if not t.in_match and t.last_result:
parts.append(f"Match {t.last_result.lower()}")
self.header.setText(" · ".join(parts))
self.header.setToolTip(" · ".join(parts))
messages = []
if not self.cards.available:
messages.append("MTGA card database not found: card names are unavailable. "
f"Set mtga_dir in {paths.CONFIG_FILE}.")
# MTGA sometimes leaves oversized messages out of the log. Usually later messages fill the
# gap in; only flag it (quietly) while some of my cards are still not accounted for.
unaccounted = 0
if g is not None and g.zones:
lib_size = g.zone_size("ZoneType_Library", g.my_seat)
unaccounted = sum(t.my_library_remaining().values()) - lib_size
if unaccounted > 0:
self.status_gap.setText(f"⚠ {unaccounted} of your cards not tracked")
self.status_gap.setToolTip(
"MTGA left part of this game out of its log (an oversized message), so "
f"{unaccounted} of your cards that left the library weren't seen. Library odds may be "
"slightly off until those cards show up again."
)
self.status_gap.setVisible(unaccounted > 0)
self.warning.setText("\n".join(messages))
self.warning.setVisible(bool(messages))