"""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))