- Closing the window hides it to the system tray; tracking and history recording continue. Quit from the tray, the menu or Ctrl+Q. - Show the window when MTGA starts a new Player.log, detected with OS file notifications on the log folder (QFileSystemWatcher), not polling. - Single instance over a local socket: launching again shows the running window (or stays hidden with --background). - Menu options for background mode, show-on-launch and start at login (XDG autostart on Linux, HKCU Run key on Windows); --background flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
537 lines
22 KiB
Python
537 lines
22 KiB
Python
"""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: <b>vs {summary.opponent or '?'}</b> · {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("<span style='color:#8b949c'>Waiting for a match…</span>")
|
|
else:
|
|
parts = [f"<b>vs {t.opponent_name or 'opponent'}</b>"]
|
|
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"<b>Match {t.last_result.lower()}</b>")
|
|
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))
|