Merge views into Match, expand history, and polish the UI

- Match page now holds players/match info, turn stats with a turn
  picker, and the deck lists (My deck · Preview · Opponent); the Deck
  and Turn tabs are gone. Section heights are draggable and remembered.
- History: sortable, resizable columns; deck and commander columns;
  right-click to show only or hide games with a value, with filter
  chips; stats tiles (win rate overall, last 10/100, last 7 days,
  play/draw, average length and turns) that follow the filters.
  Commanders are backfilled for games recorded before they were stored.
- Deck panel shows copies per zone against the deck total (e.g. 2/5).
- Preview can be cleared with ✕, a click on the image, or Esc.
- Customize menu stays open while toggling; Arrange covers deck
  sections and turn stats together.
- Fix squeezed nav buttons and the header pushing controls off-screen.
- Fix known opponent cards being double-counted when also revealed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aeth3randClaude Opus 5.5 committed 2026-10-16 12:34:02 -04:00
1 parent 5b3c8c8e63
commit b3325e8ecc
15 files changed
+760 -183

No files matched your search

+53 -49
View File
@@ -6,7 +6,7 @@ import time
from pathlib import Path
from PySide6.QtCore import QSettings, Qt, QThread, QTimer, Signal
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtGui import QAction, QFont, QFontMetrics, QKeySequence
from PySide6.QtWidgets import (
QButtonGroup,
QFileDialog,
@@ -17,6 +17,7 @@ from PySide6.QtWidgets import (
QMenu,
QMessageBox,
QPushButton,
QSizePolicy,
QStackedWidget,
QToolButton,
QVBoxLayout,
@@ -38,8 +39,8 @@ from .turn_view import TURN_DEFAULTS_OFF, TURN_TOGGLES, TurnView
REFRESH_MS = 150
DECK_TOGGLES = [("preview", "Card preview pane")]
VIEWS = ["Deck", "Match", "Turn", "History"]
HISTORY = 3
VIEWS = ["Match", "History"]
MATCH, HISTORY = 0, 1
class HistoryImport(QThread):
@@ -52,17 +53,21 @@ class HistoryImport(QThread):
self.path, self.store, self.cards = path, store, cards
def run(self) -> None:
# Own card DB connection: this runs off the GUI thread.
cards = CardDB(self.cards.path)
saved = 0
try:
st = self.path.stat()
except OSError:
return
stamp = f"{st.st_size}:{st.st_mtime_ns}"
key = f"imported:{self.path}"
if self.store.meta(key) == stamp:
return
# Own card DB connection: this runs off the GUI thread.
saved = import_log(self.path, self.store, CardDB(self.cards.path))
self.store.set_meta(key, stamp)
st = None
if st is not None:
stamp = f"{st.st_size}:{st.st_mtime_ns}"
key = f"imported:{self.path}"
if self.store.meta(key) != stamp:
saved = import_log(self.path, self.store, cards)
self.store.set_meta(key, stamp)
# Games recorded before commanders were stored.
saved += self.store.backfill_commanders(cards)
self.done.emit(saved)
@@ -97,16 +102,20 @@ class MainWindow(QMainWindow):
# Views. One DeckView is shared by the Deck page and the bottom half of the Match page.
self.deck_view = DeckView(cards, images, self.my_sections, self.opp_sections)
self.deck_page = QWidget()
self.deck_page_layout = QVBoxLayout(self.deck_page)
self.deck_page_layout.setContentsMargins(0, 0, 0, 0)
self.match_view = MatchView(cards, self.toggles[1])
self.match_view.hovered.connect(self.deck_view.preview.show_card)
self.turn_view = TurnView(self.toggles[2])
self.match_view = MatchView(cards, self.toggles[1], self.turn_view, self.deck_view)
self.match_view.hovered.connect(self.deck_view.preview.show_card)
if (state := self._settings.value("match/split")) is not None:
self.match_view.split.restoreState(state)
else:
# Players / turn stats / deck lists: enough room for a useful number of stat rows.
self.match_view.split.setSizes([250, 360, 560])
self.match_view.split.splitterMoved.connect(
lambda *_: self._settings.setValue("match/split", self.match_view.split.saveState()))
self.history_view = HistoryView()
self.history_view.open_game.connect(self.open_past_game)
self.stack = QStackedWidget()
for page in (self.deck_page, self.match_view, self.turn_view, self.history_view):
for page in (self.match_view, self.history_view):
self.stack.addWidget(page)
# Top bar
@@ -120,12 +129,19 @@ class MainWindow(QMainWindow):
btn.setCheckable(True)
btn.setShortcut(QKeySequence(f"Ctrl+{i + 1}"))
btn.setToolTip(f"{name} view (Ctrl+{i + 1})")
# The selected button's label is bold; reserve room for that so it isn't squeezed.
bold = QFont(btn.font())
bold.setBold(True)
btn.setMinimumWidth(QFontMetrics(bold).horizontalAdvance(name) + 2 * theme.NAV_PADDING_X + 4)
btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.nav.addButton(btn, i)
bar.addWidget(btn)
self.nav.idClicked.connect(self.show_view)
bar.addSpacing(16)
self.header = QLabel("Waiting for a match…")
self.header.setTextFormat(Qt.TextFormat.RichText)
# Let long headers be cut off rather than squeezing the buttons around them.
self.header.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
bar.addWidget(self.header, 1)
self.customize = QToolButton()
self.customize.setText("⚙ Customize")
@@ -188,7 +204,7 @@ class MainWindow(QMainWindow):
self.resize(1400, 880)
self.history_view.set_games(history.games())
self.show_view(int(self._settings.value("view", 0, type=int)))
self.show_view(int(self._settings.value("view2", MATCH, type=int)))
if log_path is not None:
self.start_log(log_path, replay_rate)
else:
@@ -213,49 +229,38 @@ class MainWindow(QMainWindow):
quit_act.setShortcut(QKeySequence.StandardKey.Quit)
quit_act.triggered.connect(self.close)
self.addAction(quit_act) # keep the shortcut active while the menu is closed
unpreview = QAction("Stop previewing", self)
unpreview.setShortcut(QKeySequence(Qt.Key.Key_Escape))
unpreview.triggered.connect(self.deck_view.preview.clear)
self.addAction(unpreview)
menu.addAction(quit_act)
btn.setMenu(menu)
return btn
def _arrangeable(self) -> list[Toggles]:
"""Toggle groups whose order can be arranged in the current view (deck view in match view too)."""
index = self.stack.currentIndex()
if index in (0, 1):
return [self.my_sections, self.opp_sections]
return [self.toggles[2]]
return [self.my_sections, self.opp_sections, self.toggles[2]]
def _fill_customize(self) -> None:
menu = self.customize_menu
menu.clear()
index = self.stack.currentIndex()
if index == 2:
self.toggles[2].fill_menu(menu)
else:
self.toggles[index].fill_menu(menu)
for tg in (self.my_sections, self.opp_sections):
sub = StayOpenMenu(f"{tg.title} sections", menu)
tg.fill_menu(sub)
menu.addMenu(sub)
self.toggles[1].fill_menu(menu) # match panels
for title, tg in (("Turn stats", self.toggles[2]), ("My deck sections", self.my_sections),
("Opponent sections", self.opp_sections), ("Preview", self.toggles[0])):
sub = StayOpenMenu(title, menu)
tg.fill_menu(sub)
menu.addMenu(sub)
menu.addSeparator()
arrange = menu.addAction("Arrange " + ("stats" if index == 2 else "deck sections") + "…")
arrange.triggered.connect(self._open_arrange)
menu.addAction("Arrange sections and stats…").triggered.connect(self._open_arrange)
def _open_arrange(self) -> None:
index = self.stack.currentIndex()
title = "Arrange turn stats" if index == 2 else "Arrange deck sections"
ArrangeDialog(self._arrangeable(), title, self).exec()
ArrangeDialog(self._arrangeable(), "Arrange sections and stats", self).exec()
def show_view(self, index: int) -> None:
index = max(0, min(len(VIEWS) - 1, index))
self.customize.setEnabled(index != HISTORY)
# Move the shared deck view to wherever it is needed.
if index == 1:
self.match_view.deck_slot_layout.addWidget(self.deck_view)
else:
self.deck_page_layout.addWidget(self.deck_view)
self.customize.setEnabled(index == MATCH)
self.stack.setCurrentIndex(index)
self.nav.button(index).setChecked(True)
self._settings.setValue("view", index)
self._settings.setValue("view2", index)
self._force_refresh()
# -- menu actions ----------------------------------------------------------
@@ -326,7 +331,7 @@ class MainWindow(QMainWindow):
self.past_label.setText(text)
self.past_bar.show()
self.turn_view.selected = None
self.show_view(0)
self.show_view(MATCH)
def close_past_game(self) -> None:
self.past = None
@@ -364,7 +369,7 @@ class MainWindow(QMainWindow):
self.refresh()
def _tick(self) -> None:
if self.stack.currentIndex() == 1:
if self.stack.currentIndex() == MATCH:
self.match_view.tick(self.shown_tracker, self.now_ms())
def refresh(self) -> None:
@@ -376,11 +381,9 @@ class MainWindow(QMainWindow):
self._rendered[index] = key
self._render_header(t)
self.deck_view.preview.parentWidget().setVisible(self.toggles[0].on("preview"))
if index in (0, 1):
if index == MATCH:
self.deck_view.render(t)
if index == 1:
self.match_view.render(t, self.now_ms())
elif index == 2:
self.turn_view.render(t)
def _render_header(self, t: MatchTracker) -> None:
@@ -397,6 +400,7 @@ class MainWindow(QMainWindow):
if not t.in_match and t.last_result:
parts.append(f"<b>Match {t.last_result.lower()}</b>")
self.header.setText(" · ".join(parts))
self.header.setToolTip(" · ".join(parts))
messages = []
if not self.cards.available: