mirror of
https://github.com/yusufipk/deste-hearthstone-linux-tracker.git
synced 2026-09-11 10:46:12 +00:00
Match history and win rate
Every finished match goes into a small sqlite database: mode, deck, both classes, result, turn count and who started. The result tag arrives in the last turn of the match, so the record is written then, not when the game object is closed (that only happens once the next match starts). The unique key is the session directory plus the match start time, so importing old session logs into the same database can be repeated without duplicates. The new window (from the menu) shows the totals, the win rate by deck and by opponent class, and the recent matches. Classes are drawn as hero portraits cut from the card render, with the class colour as a placeholder until the image is downloaded. Every table sorts by any column: the sort key is hidden data, not the visible text, so dates, turn counts and results sort correctly, and numeric columns start descending. tools/import_history.py backfills the database from the session logs already on disk, tests/test_history.py covers the date handling, the idempotency and the win rate maths.
This commit is contained in:
@@ -0,0 +1,600 @@
|
||||
"""Maç geçmişi penceresi.
|
||||
|
||||
Üç bölüm: üstte toplam döküm, ortada deste ve rakip sınıfı kırılımları, altta
|
||||
son maçlar. Sayı yerine çubuk çizmenin sebebi, "hangi eşleşme kötü gidiyor"
|
||||
sorusunun listeye bakar bakmaz cevaplanması.
|
||||
|
||||
Panelin aksine bu pencere saydam değil ve üstte durmaz: oyunun üstünde değil,
|
||||
oyun dışında okunuyor.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from PyQt6.QtCore import QRect, QSize, Qt, QTimer
|
||||
from PyQt6.QtGui import QColor, QIcon, QPainter, QPainterPath, QPen
|
||||
from PyQt6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QStyledItemDelegate,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from core import config
|
||||
from data.cards import CLASS_HEROES
|
||||
from data.images import RENDER
|
||||
|
||||
from . import i18n, theme
|
||||
from .i18n import t
|
||||
from .icon import app_icon
|
||||
from .widgets import color_dot, hero_portrait
|
||||
|
||||
RECENT_LIMIT = 300
|
||||
ROW_HEIGHT = 24
|
||||
ICON_SIZE = 18
|
||||
# Portreler arka planda indiyor, geldiklerinde tabloyu yeniden kurmadan
|
||||
# yalnızca ikonları değiştiriyoruz.
|
||||
ART_POLL_MS = 700
|
||||
WINRATE_WIDTH = 132
|
||||
# Kırılım tabloları içeriği kadar yer kaplar, uzunsa kaydırılır. Böylece iki
|
||||
# satırlık deste listesinin altında koca bir boşluk kalmıyor. Sınır oyundaki
|
||||
# sınıf sayısı: rakip kırılımı hiç kaydırılmadan okunabilsin.
|
||||
GROUP_MAX_ROWS = 11
|
||||
|
||||
# Hücrenin gizli verileri: sıralama anahtarı, sınıf (ikon için), oran (çubuk).
|
||||
SORT_ROLE = Qt.ItemDataRole.UserRole + 1
|
||||
RATE_ROLE = Qt.ItemDataRole.UserRole + 2
|
||||
# Sonuç sütununun sıralaması işaretin harfine göre olmasın.
|
||||
RESULT_ORDER = {"WON": 2, "TIED": 1, "LOST": 0}
|
||||
|
||||
|
||||
def mode_label(mode: str) -> str:
|
||||
"""GT_ öneki atılmış mod adını okunur biçime çevirir."""
|
||||
key = f"mode_{mode}"
|
||||
if i18n.has(key):
|
||||
return t(key)
|
||||
return mode.replace("_", " ").title() if mode else "?"
|
||||
|
||||
|
||||
def date_label(played_at: str) -> str:
|
||||
try:
|
||||
stamp = datetime.strptime(played_at, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return played_at
|
||||
return stamp.strftime(t("date_format"))
|
||||
|
||||
|
||||
def class_label(player_class: str) -> str:
|
||||
return theme.CLASS_NAMES.get(player_class, player_class or "?")
|
||||
|
||||
|
||||
class ClassIcons:
|
||||
"""Sınıf portrelerini üretir ve saklar.
|
||||
|
||||
Portre, sınıfın temel kahramanının kart görselinden kesiliyor; görsel henüz
|
||||
inmediyse yerini sınıf renginde bir daire tutuyor. Böylece liste ilk açılışta
|
||||
da ikonlu görünüyor, indikçe portreye dönüyor.
|
||||
"""
|
||||
|
||||
def __init__(self, images=None):
|
||||
self.images = images
|
||||
self.generation = -1
|
||||
self._cache: dict[str, QIcon] = {}
|
||||
|
||||
def refreshed(self) -> bool:
|
||||
"""Yeni görsel indiyse önbelleği boşaltır ve True döner."""
|
||||
generation = self.images.generation if self.images is not None else 0
|
||||
if generation == self.generation:
|
||||
return False
|
||||
self.generation = generation
|
||||
self._cache.clear()
|
||||
return True
|
||||
|
||||
def get(self, player_class: str) -> QIcon:
|
||||
icon = self._cache.get(player_class)
|
||||
if icon is None:
|
||||
icon = QIcon(self._pixmap(player_class))
|
||||
self._cache[player_class] = icon
|
||||
return icon
|
||||
|
||||
def _pixmap(self, player_class: str):
|
||||
card_id = CLASS_HEROES.get(player_class, "")
|
||||
path = self.images.get(card_id, RENDER) if self.images and card_id else None
|
||||
# İkon iki katı boyutta üretiliyor, yoğun ekranlarda bulanıklaşmasın.
|
||||
if path is not None:
|
||||
portrait = hero_portrait(path, ICON_SIZE * 2)
|
||||
if portrait is not None:
|
||||
return portrait
|
||||
return color_dot(theme.CLASS_COLORS.get(player_class, theme.TEXT_DIM), ICON_SIZE * 2)
|
||||
|
||||
|
||||
class WinrateDelegate(QStyledItemDelegate):
|
||||
"""Galibiyet oranını hücrenin içine çubuk olarak çizer.
|
||||
|
||||
Hücre widget'ı (setCellWidget) yerine delegate olmasının sebebi sıralama:
|
||||
widget'lar satırla birlikte taşınmıyor, tablo sıralanınca çubuklar yanlış
|
||||
satırda kalıyor. Delegate hücrenin verisinden çizdiği için bu sorun yok.
|
||||
"""
|
||||
|
||||
def paint(self, painter, option, index) -> None:
|
||||
rate = index.data(RATE_ROLE)
|
||||
painter.save()
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
rect = option.rect.adjusted(6, 0, -6, 0)
|
||||
|
||||
# Yazı alanı en uzun değere göre ölçülüyor: "%100" sığmazsa yüzde
|
||||
# kırpılıyor ve %77 ile %7 aynı görünüyor.
|
||||
text_width = painter.fontMetrics().horizontalAdvance(t("percent", value="100")) + 2
|
||||
bar_width = float(max(rect.width() - text_width - 8, 10))
|
||||
thickness = 6.0
|
||||
top = rect.y() + (rect.height() - thickness) / 2
|
||||
|
||||
track = QPainterPath()
|
||||
track.addRoundedRect(float(rect.x()), top, bar_width, thickness, 3.0, 3.0)
|
||||
painter.fillPath(track, QColor(theme.BORDER))
|
||||
|
||||
if rate is not None:
|
||||
fill = QPainterPath()
|
||||
# En küçük dolgu bile görünsün: %0 da bir bilgidir.
|
||||
fill.addRoundedRect(
|
||||
float(rect.x()), top, max(bar_width * rate / 100.0, 3.0), thickness, 3.0, 3.0
|
||||
)
|
||||
painter.fillPath(fill, QColor(theme.WIN if rate >= 50 else theme.LOSS))
|
||||
text = t("percent", value=f"{rate:.0f}")
|
||||
else:
|
||||
text = "–"
|
||||
|
||||
painter.setPen(QPen(QColor(theme.TEXT if rate is not None else theme.TEXT_DIM)))
|
||||
painter.drawText(
|
||||
QRect(rect.x() + int(bar_width) + 8, rect.y(), text_width, rect.height()),
|
||||
int(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight),
|
||||
text,
|
||||
)
|
||||
painter.restore()
|
||||
|
||||
def sizeHint(self, option, index) -> QSize: # noqa: N802
|
||||
return QSize(WINRATE_WIDTH, ROW_HEIGHT)
|
||||
|
||||
|
||||
class Cell(QTableWidgetItem):
|
||||
"""Görünen metne değil, gizli anahtara göre sıralanan hücre.
|
||||
|
||||
Tarih "27.07 22:41" biçiminde yazılıyor, tur bir sayı, sonuç bir işaret:
|
||||
üçünü de metne göre sıralamak yanlış sonuç verir.
|
||||
"""
|
||||
|
||||
def __lt__(self, other) -> bool:
|
||||
mine, theirs = self.data(SORT_ROLE), other.data(SORT_ROLE)
|
||||
if mine is None or theirs is None:
|
||||
return super().__lt__(other)
|
||||
return mine < theirs
|
||||
|
||||
|
||||
def _table(column_count: int, stretch: int = 0) -> QTableWidget:
|
||||
table = QTableWidget(0, column_count)
|
||||
table.verticalHeader().setVisible(False)
|
||||
table.verticalHeader().setDefaultSectionSize(ROW_HEIGHT)
|
||||
table.setShowGrid(False)
|
||||
table.setWordWrap(False)
|
||||
table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
|
||||
table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
table.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
table.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
|
||||
header = table.horizontalHeader()
|
||||
header.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
header.setSectionsClickable(True)
|
||||
for column in range(column_count):
|
||||
header.setSectionResizeMode(
|
||||
column,
|
||||
QHeaderView.ResizeMode.Stretch
|
||||
if column == stretch
|
||||
else QHeaderView.ResizeMode.ResizeToContents,
|
||||
)
|
||||
return table
|
||||
|
||||
|
||||
def _fit(table: QTableWidget, max_rows: int) -> None:
|
||||
"""Tabloyu satır sayısı kadar yükseltir, taşarsa kaydırma çubuğuna bırakır."""
|
||||
rows = min(max(table.rowCount(), 1), max_rows)
|
||||
table.setFixedHeight(table.horizontalHeader().height() + rows * ROW_HEIGHT + 2)
|
||||
|
||||
|
||||
def _align_right(table: QTableWidget, columns) -> None:
|
||||
"""Sayı sütunlarının başlığı da sağa yaslansın, veriyle hizalansın."""
|
||||
for column in columns:
|
||||
item = table.horizontalHeaderItem(column)
|
||||
if item is not None:
|
||||
item.setTextAlignment(
|
||||
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
)
|
||||
|
||||
|
||||
def _item(
|
||||
text: str,
|
||||
color: str = "",
|
||||
align_right: bool = False,
|
||||
player_class: str | None = None,
|
||||
sort=None,
|
||||
) -> Cell:
|
||||
item = Cell(text)
|
||||
if color:
|
||||
item.setForeground(QColor(color))
|
||||
if align_right:
|
||||
item.setTextAlignment(
|
||||
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
)
|
||||
if player_class is not None:
|
||||
# İkonun kendisi sonradan basılıyor: portre indiğinde satırı yeniden
|
||||
# kurmadan değiştirebilmek için hücre hangi sınıfı gösterdiğini saklıyor.
|
||||
item.setData(Qt.ItemDataRole.UserRole, player_class)
|
||||
if sort is not None:
|
||||
item.setData(SORT_ROLE, sort)
|
||||
return item
|
||||
|
||||
|
||||
def _section(title: str, table: QTableWidget, fill: bool = False) -> tuple[QWidget, QLabel]:
|
||||
box = QWidget()
|
||||
layout = QVBoxLayout(box)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(4)
|
||||
label = QLabel(title)
|
||||
label.setObjectName("section")
|
||||
layout.addWidget(label)
|
||||
layout.addWidget(table)
|
||||
if not fill:
|
||||
# Boyu içeriğine sabitlenmiş tablo yukarı yaslansın.
|
||||
layout.addStretch(1)
|
||||
return box, label
|
||||
|
||||
|
||||
class HistoryWindow(QWidget):
|
||||
def __init__(self, store, settings: dict, images=None, parent=None):
|
||||
super().__init__(parent, Qt.WindowType.Window)
|
||||
self.store = store
|
||||
self.settings = settings
|
||||
self.icons = ClassIcons(images)
|
||||
self.mode_filter = ""
|
||||
# Tablo -> (sütun, yön). Kullanıcının seçtiği sıralama, tablo yeniden
|
||||
# dolduğunda (mod süzgeci, yeni maç, dil değişimi) korunuyor.
|
||||
self._sort: dict[QTableWidget, tuple[int, Qt.SortOrder]] = {}
|
||||
self._desc_first: dict[QTableWidget, set[int]] = {}
|
||||
self._headers: dict[QTableWidget, list[str]] = {}
|
||||
self.setObjectName("history")
|
||||
self.setWindowIcon(app_icon())
|
||||
self.setStyleSheet(theme.STYLESHEET + theme.TABLE_STYLESHEET)
|
||||
self._art_timer = QTimer(self)
|
||||
self._art_timer.timeout.connect(self._check_art)
|
||||
self._build()
|
||||
self._restore_geometry()
|
||||
self.reload()
|
||||
|
||||
# --- kurulum --------------------------------------------------------
|
||||
|
||||
def _build(self) -> None:
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(14, 12, 14, 12)
|
||||
outer.setSpacing(10)
|
||||
|
||||
top = QHBoxLayout()
|
||||
top.setSpacing(8)
|
||||
self.title_label = QLabel()
|
||||
self.title_label.setObjectName("title")
|
||||
top.addWidget(self.title_label)
|
||||
self.totals_label = QLabel()
|
||||
self.totals_label.setObjectName("total")
|
||||
top.addWidget(self.totals_label, 1)
|
||||
self.mode_box = QComboBox()
|
||||
self.mode_box.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self.mode_box.currentIndexChanged.connect(self._on_mode_changed)
|
||||
top.addWidget(self.mode_box)
|
||||
outer.addLayout(top)
|
||||
|
||||
self.empty_label = QLabel()
|
||||
self.empty_label.setObjectName("empty")
|
||||
self.empty_label.setWordWrap(True)
|
||||
self.empty_label.setVisible(False)
|
||||
outer.addWidget(self.empty_label)
|
||||
|
||||
groups = QHBoxLayout()
|
||||
groups.setSpacing(14)
|
||||
self.deck_table = _table(3)
|
||||
self.deck_box, self.deck_title = _section("", self.deck_table)
|
||||
groups.addWidget(self.deck_box, 1)
|
||||
self.opponent_table = _table(3)
|
||||
self.opponent_box, self.opponent_title = _section("", self.opponent_table)
|
||||
groups.addWidget(self.opponent_box, 1)
|
||||
for table in (self.deck_table, self.opponent_table):
|
||||
table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
|
||||
table.setColumnWidth(2, WINRATE_WIDTH)
|
||||
table.setItemDelegateForColumn(2, WinrateDelegate(table))
|
||||
# Varsayılan: en çok oynanan üstte.
|
||||
self._init_sort(table, 1, Qt.SortOrder.DescendingOrder, desc_first=(1, 2))
|
||||
outer.addLayout(groups)
|
||||
|
||||
# Esneyen sütun rakip: tarih, mod, deste ve rakip solda birbirine
|
||||
# yapışık kalıyor, artan yer sonuçtan önceye düşüyor.
|
||||
self.recent_table = _table(6, stretch=3)
|
||||
self.recent_box, self.recent_title = _section("", self.recent_table, fill=True)
|
||||
outer.addWidget(self.recent_box, 1)
|
||||
self._init_sort(
|
||||
self.recent_table, 0, Qt.SortOrder.DescendingOrder, desc_first=(0, 4, 5)
|
||||
)
|
||||
|
||||
# Tablolar gizlendiğinde artan yeri bu boş dolgu yutar, yoksa Qt boşluğu
|
||||
# başlık ile mesaj arasına dağıtıp ikisini pencerenin ortasına itiyor.
|
||||
self._tail = QWidget()
|
||||
outer.addWidget(self._tail)
|
||||
|
||||
# --- sıralama -------------------------------------------------------
|
||||
|
||||
def _init_sort(
|
||||
self, table: QTableWidget, column: int, order: Qt.SortOrder, desc_first=()
|
||||
) -> None:
|
||||
"""Sıralamayı Qt'ye bırakmıyoruz.
|
||||
|
||||
Qt her sütuna artan sırayla başlıyor, oysa sayı sütunlarında beklenen
|
||||
"önce en iyisi": galibiyete tıklayınca en iyi eşleşme, tura tıklayınca
|
||||
en uzun maç üstte olmalı. Yön seçimi bu yüzden burada.
|
||||
"""
|
||||
self._sort[table] = (column, order)
|
||||
self._desc_first[table] = set(desc_first)
|
||||
table.horizontalHeader().sectionClicked.connect(
|
||||
lambda index, target=table: self._on_header_clicked(target, index)
|
||||
)
|
||||
|
||||
def _on_header_clicked(self, table: QTableWidget, column: int) -> None:
|
||||
current, order = self._sort[table]
|
||||
if column == current:
|
||||
order = (
|
||||
Qt.SortOrder.AscendingOrder
|
||||
if order == Qt.SortOrder.DescendingOrder
|
||||
else Qt.SortOrder.DescendingOrder
|
||||
)
|
||||
elif column in self._desc_first[table]:
|
||||
order = Qt.SortOrder.DescendingOrder
|
||||
else:
|
||||
order = Qt.SortOrder.AscendingOrder
|
||||
self._sort[table] = (column, order)
|
||||
self._apply_sort(table)
|
||||
|
||||
def _apply_sort(self, table: QTableWidget) -> None:
|
||||
column, order = self._sort[table]
|
||||
table.sortItems(column, order)
|
||||
self._mark_sorted_header(table)
|
||||
|
||||
def _mark_sorted_header(self, table: QTableWidget) -> None:
|
||||
"""Sıralı sütunu başlıktaki okla gösterir.
|
||||
|
||||
Qt'nin kendi sıralama ibresi, başlığa stil verildiğinde çizilmiyor.
|
||||
Oku metnin parçası yapmak hem her temada görünüyor hem de panelin deste
|
||||
düğmesindeki dille aynı.
|
||||
"""
|
||||
column, order = self._sort[table]
|
||||
arrow = " ▾" if order == Qt.SortOrder.DescendingOrder else " ▴"
|
||||
for index, label in enumerate(self._headers.get(table, [])):
|
||||
item = table.horizontalHeaderItem(index)
|
||||
if item is not None:
|
||||
item.setText(label + (arrow if index == column else ""))
|
||||
|
||||
def _set_headers(self, table: QTableWidget, labels: list[str], right=()) -> None:
|
||||
self._headers[table] = labels
|
||||
table.setHorizontalHeaderLabels(labels)
|
||||
_align_right(table, right)
|
||||
self._mark_sorted_header(table)
|
||||
|
||||
# --- veri -----------------------------------------------------------
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Metinleri ve verileri baştan kurar. Dil değişiminde de çağrılır."""
|
||||
self.setWindowTitle(t("history_title"))
|
||||
self.title_label.setText(t("history_title"))
|
||||
self.deck_title.setText(t("history_by_deck"))
|
||||
self.opponent_title.setText(t("history_by_opponent"))
|
||||
self.recent_title.setText(t("history_recent"))
|
||||
self.empty_label.setText(t("history_empty"))
|
||||
self._set_headers(
|
||||
self.deck_table,
|
||||
[t("column_deck"), t("column_record"), t("column_winrate")],
|
||||
right=(1,),
|
||||
)
|
||||
self._set_headers(
|
||||
self.opponent_table,
|
||||
[t("column_opponent"), t("column_record"), t("column_winrate")],
|
||||
right=(1,),
|
||||
)
|
||||
self._set_headers(
|
||||
self.recent_table,
|
||||
[
|
||||
t("column_date"),
|
||||
t("column_mode"),
|
||||
t("column_deck"),
|
||||
t("column_opponent"),
|
||||
t("column_result"),
|
||||
t("column_turns"),
|
||||
],
|
||||
right=(4, 5),
|
||||
)
|
||||
self._fill_modes()
|
||||
|
||||
totals = self.store.totals(self.mode_filter)
|
||||
self.totals_label.setText(
|
||||
t("history_totals", total=totals.total, wins=totals.wins, losses=totals.losses)
|
||||
+ (" " + t("percent", value=f"{totals.winrate:.0f}") if totals.winrate is not None else "")
|
||||
)
|
||||
# Hiç maç yoksa boş tablo iskeleti göstermek yerine tek cümle kalsın.
|
||||
empty = totals.total == 0
|
||||
self.empty_label.setVisible(empty)
|
||||
for box in (self.deck_box, self.opponent_box, self.recent_box):
|
||||
box.setVisible(not empty)
|
||||
self.layout().setStretchFactor(self._tail, 1 if empty else 0)
|
||||
|
||||
deck_classes = self.store.deck_classes()
|
||||
self._fill_groups(
|
||||
self.deck_table,
|
||||
self.store.summary("deck", self.mode_filter),
|
||||
lambda key: key or t("history_no_deck"),
|
||||
lambda key: deck_classes.get(key, ""),
|
||||
)
|
||||
self._fill_groups(
|
||||
self.opponent_table,
|
||||
self.store.summary("opponent_class", self.mode_filter),
|
||||
class_label,
|
||||
lambda key: key,
|
||||
)
|
||||
self._fill_recent()
|
||||
self._apply_icons()
|
||||
|
||||
def _fill_modes(self) -> None:
|
||||
modes = self.store.modes()
|
||||
self.mode_box.blockSignals(True)
|
||||
self.mode_box.clear()
|
||||
self.mode_box.addItem(t("history_all_modes"), "")
|
||||
for mode in modes:
|
||||
self.mode_box.addItem(mode_label(mode), mode)
|
||||
index = self.mode_box.findData(self.mode_filter)
|
||||
# Süzgeçteki mod artık veride yoksa (mesela silinmişse) hepsine dön.
|
||||
if index < 0:
|
||||
index, self.mode_filter = 0, ""
|
||||
self.mode_box.setCurrentIndex(index)
|
||||
self.mode_box.blockSignals(False)
|
||||
self.mode_box.setVisible(bool(modes))
|
||||
|
||||
def _on_mode_changed(self, _index: int) -> None:
|
||||
self.mode_filter = self.mode_box.currentData() or ""
|
||||
self.reload()
|
||||
|
||||
def _fill_groups(self, table, stats, label_for, class_for) -> None:
|
||||
table.setRowCount(len(stats))
|
||||
for row, stat in enumerate(stats):
|
||||
player_class = class_for(stat.key)
|
||||
table.setItem(
|
||||
row,
|
||||
0,
|
||||
_item(
|
||||
label_for(stat.key),
|
||||
theme.CLASS_COLORS.get(player_class, ""),
|
||||
player_class=player_class,
|
||||
),
|
||||
)
|
||||
record = f"{stat.wins}-{stat.losses}"
|
||||
if stat.ties:
|
||||
record += f"-{stat.ties}"
|
||||
# Sıralama anahtarları çift: G-M sütunu maç sayısına göre sıralanır
|
||||
# (satırın ağırlığı bu), eşitlikte orana bakılır, oran sütununda tersi.
|
||||
# Hiç sonuçlanmamış satırın oranı yok, en dibe insin diye -1.
|
||||
rate_key = stat.winrate if stat.winrate is not None else -1.0
|
||||
table.setItem(
|
||||
row, 1, _item(record, theme.TEXT_DIM, True, sort=(stat.total, rate_key))
|
||||
)
|
||||
rate = _item("", align_right=True, sort=(rate_key, stat.total))
|
||||
rate.setData(RATE_ROLE, stat.winrate)
|
||||
table.setItem(row, 2, rate)
|
||||
self._apply_sort(table)
|
||||
_fit(table, GROUP_MAX_ROWS)
|
||||
|
||||
def _fill_recent(self) -> None:
|
||||
records = self.store.matches(RECENT_LIMIT, self.mode_filter)
|
||||
table = self.recent_table
|
||||
table.setRowCount(len(records))
|
||||
for row, record in enumerate(records):
|
||||
deck = record.deck or class_label(record.my_class)
|
||||
opponent = class_label(record.opponent_class)
|
||||
# Her anahtarın ikinci parçası zaman damgası: aynı sınıfa karşı ya da
|
||||
# aynı tur sayısında oynanan maçlar kendi içinde tarihe göre dizilsin,
|
||||
# sıra tesadüfe kalmasın. Tarih sütununda ekrandaki kısa yazı değil
|
||||
# tam damga sıralanıyor.
|
||||
when = record.played_at
|
||||
table.setItem(
|
||||
row, 0, _item(date_label(when), theme.TEXT_DIM, sort=when)
|
||||
)
|
||||
mode = mode_label(record.mode)
|
||||
table.setItem(row, 1, _item(mode, theme.TEXT_DIM, sort=(mode, when)))
|
||||
table.setItem(
|
||||
row, 2, _item(deck, player_class=record.my_class, sort=(deck, when))
|
||||
)
|
||||
table.setItem(
|
||||
row,
|
||||
3,
|
||||
_item(
|
||||
opponent,
|
||||
theme.CLASS_COLORS.get(record.opponent_class, ""),
|
||||
player_class=record.opponent_class,
|
||||
sort=(opponent, when),
|
||||
),
|
||||
)
|
||||
mark, color = theme.RESULT_MARKS.get(record.result, ("?", theme.TEXT_DIM))
|
||||
result_item = _item(
|
||||
mark, color, True, sort=(RESULT_ORDER.get(record.result, -1), when)
|
||||
)
|
||||
result_item.setToolTip(
|
||||
t(i18n.RESULT_KEYS.get(record.result, "result_tied"))
|
||||
+ " · "
|
||||
+ t("coin_first" if record.went_first else "coin_second")
|
||||
)
|
||||
table.setItem(row, 4, result_item)
|
||||
table.setItem(
|
||||
row,
|
||||
5,
|
||||
_item(str(record.turns), theme.TEXT_DIM, True, sort=(record.turns, when)),
|
||||
)
|
||||
self._apply_sort(table)
|
||||
|
||||
# --- ikonlar --------------------------------------------------------
|
||||
|
||||
def _apply_icons(self) -> None:
|
||||
"""Sınıf saklayan hücrelere portreyi basar.
|
||||
|
||||
Portre diskte yoksa ClassIcons indirmeye alıyor, o ana kadar sınıf
|
||||
renginde daire duruyor.
|
||||
"""
|
||||
for table in (self.deck_table, self.opponent_table, self.recent_table):
|
||||
for row in range(table.rowCount()):
|
||||
for column in range(table.columnCount()):
|
||||
item = table.item(row, column)
|
||||
if item is None:
|
||||
continue
|
||||
player_class = item.data(Qt.ItemDataRole.UserRole)
|
||||
if player_class:
|
||||
item.setIcon(self.icons.get(player_class))
|
||||
|
||||
def _check_art(self) -> None:
|
||||
"""Yeni portre indiyse yalnızca ikonları değiştir.
|
||||
|
||||
Tabloyu yeniden kurmuyoruz: kullanıcı listeyi kaydırmışken kendiliğinden
|
||||
başa dönmesi rahatsız edici.
|
||||
"""
|
||||
if self.icons.refreshed():
|
||||
self._apply_icons()
|
||||
|
||||
def showEvent(self, event) -> None: # noqa: N802
|
||||
self._art_timer.start(ART_POLL_MS)
|
||||
super().showEvent(event)
|
||||
|
||||
def hideEvent(self, event) -> None: # noqa: N802
|
||||
self._art_timer.stop()
|
||||
super().hideEvent(event)
|
||||
|
||||
# --- geometri -------------------------------------------------------
|
||||
|
||||
def _restore_geometry(self) -> None:
|
||||
saved = self.settings.get("geometry", {}).get("history")
|
||||
if saved and len(saved) == 2:
|
||||
self.resize(max(saved[0], 520), max(saved[1], 380))
|
||||
else:
|
||||
self.resize(760, 620)
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
geometry = self.settings.setdefault("geometry", {})
|
||||
rect = self.geometry()
|
||||
geometry["history"] = [rect.width(), rect.height()]
|
||||
config.save(self.settings)
|
||||
super().closeEvent(event)
|
||||
+43
@@ -37,6 +37,41 @@ STRINGS: dict[str, dict[str, str]] = {
|
||||
"menu_draw_chance": {"tr": "Çekme yüzdesi", "en": "Draw chance"},
|
||||
"menu_language": {"tr": "Dil", "en": "Language"},
|
||||
"menu_reload_decks": {"tr": "Desteleri yeniden yükle", "en": "Reload decks"},
|
||||
"menu_history": {"tr": "Maç geçmişi", "en": "Match history"},
|
||||
"history_title": {"tr": "Maç geçmişi", "en": "Match history"},
|
||||
"history_empty": {
|
||||
"tr": "Henüz kayıtlı maç yok. Uygulama açıkken oynadığın maçlar buraya düşer.",
|
||||
"en": "No matches recorded yet. Games you play with the app open land here.",
|
||||
},
|
||||
"history_totals": {
|
||||
"tr": "{total} maç {wins}G {losses}M",
|
||||
"en": "{total} matches {wins}W {losses}L",
|
||||
},
|
||||
"history_by_deck": {"tr": "DESTEYE GÖRE", "en": "BY DECK"},
|
||||
"history_by_opponent": {"tr": "RAKİP SINIFINA GÖRE", "en": "BY OPPONENT CLASS"},
|
||||
"history_recent": {"tr": "SON MAÇLAR", "en": "RECENT MATCHES"},
|
||||
"history_all_modes": {"tr": "Tüm modlar", "en": "All modes"},
|
||||
"history_no_deck": {"tr": "Deste yok", "en": "No deck"},
|
||||
"column_date": {"tr": "Tarih", "en": "Date"},
|
||||
"column_mode": {"tr": "Mod", "en": "Mode"},
|
||||
"column_deck": {"tr": "Deste", "en": "Deck"},
|
||||
"column_opponent": {"tr": "Rakip", "en": "Opponent"},
|
||||
"column_result": {"tr": "Sonuç", "en": "Result"},
|
||||
"column_turns": {"tr": "Tur", "en": "Turns"},
|
||||
"column_record": {"tr": "G-M", "en": "W-L"},
|
||||
"column_winrate": {"tr": "Galibiyet", "en": "Win rate"},
|
||||
"percent": {"tr": "%{value}", "en": "{value}%"},
|
||||
# strftime kalıbı. Ay adı yok: adlar sistem yerelinden geliyor, İngilizce
|
||||
# arayüzde Türkçe ay kısaltması çıkıyordu.
|
||||
"date_format": {"tr": "%d.%m %H:%M", "en": "%m-%d %H:%M"},
|
||||
"coin_first": {"tr": "Önce başladın", "en": "You went first"},
|
||||
"coin_second": {"tr": "Sonra başladın", "en": "You went second"},
|
||||
"mode_RANKED": {"tr": "Dereceli", "en": "Ranked"},
|
||||
"mode_CASUAL": {"tr": "Serbest", "en": "Casual"},
|
||||
"mode_ARENA": {"tr": "Arena", "en": "Arena"},
|
||||
"mode_VS_AI": {"tr": "Yapay zeka", "en": "vs AI"},
|
||||
"mode_TAVERNBRAWL": {"tr": "Meyhane kapışması", "en": "Tavern Brawl"},
|
||||
"mode_BATTLEGROUNDS": {"tr": "Battlegrounds", "en": "Battlegrounds"},
|
||||
"menu_hide": {"tr": "Gizle (tepsiye)", "en": "Hide to tray"},
|
||||
"menu_quit": {"tr": "Çıkış", "en": "Quit"},
|
||||
"tray_toggle": {"tr": "Göster / Gizle", "en": "Show / Hide"},
|
||||
@@ -62,6 +97,9 @@ STRINGS: dict[str, dict[str, str]] = {
|
||||
},
|
||||
}
|
||||
|
||||
# Maç sonucunun metin anahtarı. İşaretin kendisi theme.RESULT_MARKS içinde.
|
||||
RESULT_KEYS = {"WON": "result_won", "LOST": "result_lost", "TIED": "result_tied"}
|
||||
|
||||
_current = "en"
|
||||
|
||||
|
||||
@@ -82,6 +120,11 @@ def current() -> str:
|
||||
return _current
|
||||
|
||||
|
||||
def has(key: str) -> bool:
|
||||
"""Anahtar sözlükte var mı? Çevirisi olmayan değerler ham gösterilsin diye."""
|
||||
return key in STRINGS
|
||||
|
||||
|
||||
def t(key: str, **kwargs) -> str:
|
||||
text = STRINGS.get(key, {}).get(_current, key)
|
||||
return text.format(**kwargs) if kwargs else text
|
||||
|
||||
+31
@@ -14,6 +14,9 @@ MANA_BLUE = "#1f4e8c"
|
||||
WIN = "#5fbf6a"
|
||||
LOSS = "#d0605a"
|
||||
|
||||
# Maç sonucu: yazı yerine işaret. Hem panelde hem maç geçmişinde aynı dil.
|
||||
RESULT_MARKS = {"WON": ("✓", WIN), "LOST": ("✗", LOSS), "TIED": ("=", TEXT_DIM)}
|
||||
|
||||
RARITY_COLORS = {
|
||||
"COMMON": "#c9cedb",
|
||||
"FREE": "#c9cedb",
|
||||
@@ -131,3 +134,31 @@ QScrollBar::handle:vertical:hover {{ background: {TEXT_DIM}; }}
|
||||
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
|
||||
QScrollBar::add-page, QScrollBar::sub-page {{ background: transparent; }}
|
||||
"""
|
||||
|
||||
# Maç geçmişi penceresi. Panelin aksine saydam değil: oyunun üstünde değil,
|
||||
# yanında okunan bir pencere.
|
||||
TABLE_STYLESHEET = f"""
|
||||
QWidget#history {{ background: {BACKGROUND}; }}
|
||||
QLabel#title {{ font-size: 15px; font-weight: 600; }}
|
||||
QLabel#total {{ color: {TEXT_DIM}; font-size: 12px; }}
|
||||
QTableWidget {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}}
|
||||
QTableWidget::item {{ padding: 2px 6px; border: none; }}
|
||||
QTableWidget::item:selected {{ background: {SURFACE_ALT}; color: {TEXT}; }}
|
||||
QHeaderView::section {{
|
||||
background: transparent;
|
||||
color: {TEXT_DIM};
|
||||
border: none;
|
||||
border-bottom: 1px solid {BORDER};
|
||||
padding: 4px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}}
|
||||
/* Başlıklar tıklanınca sıralıyor, imleç üstüne gelince belli olsun. */
|
||||
QHeaderView::section:hover {{ color: {TEXT}; }}
|
||||
QHeaderView::down-arrow, QHeaderView::up-arrow {{ width: 9px; height: 9px; }}
|
||||
"""
|
||||
|
||||
+52
-1
@@ -8,7 +8,7 @@ görselle birlikte satır tanınabilir oluyor.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QPoint, QRect, QSize, Qt, QTimer
|
||||
from PyQt6.QtCore import QPoint, QRect, QRectF, QSize, Qt, QTimer
|
||||
from PyQt6.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
@@ -31,6 +31,57 @@ PREVIEW_DELAY_MS = 200
|
||||
HIDE_DELAY_MS = 140
|
||||
PREVIEW_GAP = 8
|
||||
|
||||
# Kahraman kartının render'ında yüzün bulunduğu kare: sol kenar, üst kenar ve
|
||||
# kenar uzunluğu, kart genişliğinin oranı olarak. Altın çerçeve dışarıda kalsın
|
||||
# diye deneyerek bulundu.
|
||||
PORTRAIT_CROP = (0.33, 0.18, 0.34)
|
||||
|
||||
|
||||
def _circle(size: int) -> QPainterPath:
|
||||
path = QPainterPath()
|
||||
path.addEllipse(QRectF(0.0, 0.0, float(size), float(size)))
|
||||
return path
|
||||
|
||||
|
||||
def color_dot(color: str, size: int) -> QPixmap:
|
||||
"""Sınıf rengiyle dolu daire. Portre inene kadarki yer tutucu."""
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(QColor(0, 0, 0, 0))
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.fillPath(_circle(size), QColor(color))
|
||||
painter.end()
|
||||
return pixmap
|
||||
|
||||
|
||||
def hero_portrait(path, size: int) -> QPixmap | None:
|
||||
"""Kahraman kartının render'ından daire portre keser."""
|
||||
source = QPixmap(str(path))
|
||||
if source.isNull():
|
||||
return None
|
||||
left, top, side = PORTRAIT_CROP
|
||||
crop = source.copy(
|
||||
QRect(
|
||||
int(source.width() * left),
|
||||
int(source.height() * top),
|
||||
int(source.width() * side),
|
||||
int(source.width() * side),
|
||||
)
|
||||
).scaled(
|
||||
size,
|
||||
size,
|
||||
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(QColor(0, 0, 0, 0))
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setClipPath(_circle(size))
|
||||
painter.drawPixmap(0, 0, crop)
|
||||
painter.end()
|
||||
return pixmap
|
||||
|
||||
|
||||
class ElidedLabel(QLabel):
|
||||
"""Metni kısaltarak gösteren, genişliği pencereye dayatmayan etiket.
|
||||
|
||||
+54
-5
@@ -28,7 +28,7 @@ from PyQt6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from core import config
|
||||
from core import config, history
|
||||
from core.state import Game
|
||||
from data.images import TILE, ImageCache
|
||||
|
||||
@@ -40,8 +40,6 @@ from .widgets import CardList, CardPreview, ElidedLabel
|
||||
POLL_INTERVAL_MS = 400
|
||||
MIN_WIDTH = 210
|
||||
MIN_HEIGHT = 260
|
||||
RESULT_MARK = {"WON": ("✓", theme.WIN), "LOST": ("✗", theme.LOSS), "TIED": ("=", theme.TEXT_DIM)}
|
||||
RESULT_TOOLTIP = {"WON": "result_won", "LOST": "result_lost", "TIED": "result_tied"}
|
||||
OPACITY_STEPS = [1.00, 0.90, 0.75, 0.60, 0.45]
|
||||
|
||||
|
||||
@@ -65,6 +63,11 @@ class TrackerWindow(QWidget):
|
||||
self.settings = settings
|
||||
self.mode = settings.get("window_mode", "overlay")
|
||||
self.images = ImageCache()
|
||||
self.history = history.History()
|
||||
self.history_window = None
|
||||
# En son geçmişe yazılan maç. Sonuç geldikten sonra panel daha birkaç
|
||||
# kez tazeleniyor, aynı maçı her seferinde yazmaya kalkmayalım.
|
||||
self._recorded_key: tuple[str, str] | None = None
|
||||
# Önizleme pencereye bağlı: Wayland'da konumlandırılabilmesi için
|
||||
# bir ebeveyne bağlı popup olması gerekiyor, başıboş pencere taşınamaz.
|
||||
self.preview = CardPreview(self.images, self)
|
||||
@@ -243,6 +246,9 @@ class TrackerWindow(QWidget):
|
||||
language_menu.addAction(action)
|
||||
|
||||
menu.addSeparator()
|
||||
history_action = QAction(t("menu_history"), self)
|
||||
history_action.triggered.connect(self._show_history)
|
||||
menu.addAction(history_action)
|
||||
reload_action = QAction(t("menu_reload_decks"), self)
|
||||
reload_action.triggered.connect(self._reload_decks)
|
||||
menu.addAction(reload_action)
|
||||
@@ -292,6 +298,8 @@ class TrackerWindow(QWidget):
|
||||
self.opponent_list.set_empty_text(t("opponent_empty"))
|
||||
self.opponent_title.setText(t("opponent_title"))
|
||||
self._rebuild_deck_menu()
|
||||
if self.history_window is not None:
|
||||
self.history_window.reload()
|
||||
self._refresh(force=True)
|
||||
|
||||
def _on_tray_activated(self, reason) -> None:
|
||||
@@ -308,6 +316,9 @@ class TrackerWindow(QWidget):
|
||||
def _quit(self) -> None:
|
||||
self._save_geometry()
|
||||
self.images.shutdown()
|
||||
self.history.close()
|
||||
if self.history_window is not None:
|
||||
self.history_window.close()
|
||||
self.tray.hide()
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
@@ -343,12 +354,12 @@ class TrackerWindow(QWidget):
|
||||
self.turn_label.setText("")
|
||||
self.turn_label.setToolTip("")
|
||||
return
|
||||
mark, color = RESULT_MARK.get(game.result, ("", ""))
|
||||
mark, color = theme.RESULT_MARKS.get(game.result, ("", ""))
|
||||
if mark:
|
||||
self.turn_label.setText(
|
||||
f'T{game.game_turn} <span style="color:{color}">{mark}</span>'
|
||||
)
|
||||
self.turn_label.setToolTip(t(RESULT_TOOLTIP[game.result]))
|
||||
self.turn_label.setToolTip(t(i18n.RESULT_KEYS[game.result]))
|
||||
else:
|
||||
self.turn_label.setText(f"T{game.game_turn}")
|
||||
self.turn_label.setToolTip("")
|
||||
@@ -365,6 +376,17 @@ class TrackerWindow(QWidget):
|
||||
x = button.width() - width if align_right else 0
|
||||
menu.popup(button.mapToGlobal(QPoint(x, button.height() + 3)))
|
||||
|
||||
def _show_history(self) -> None:
|
||||
from .history_window import HistoryWindow
|
||||
|
||||
if self.history_window is None:
|
||||
self.history_window = HistoryWindow(self.history, self.settings, self.images)
|
||||
else:
|
||||
self.history_window.reload()
|
||||
self.history_window.show()
|
||||
self.history_window.raise_()
|
||||
self.history_window.activateWindow()
|
||||
|
||||
def _reload_decks(self) -> None:
|
||||
self.library.load()
|
||||
self._rebuild_deck_menu()
|
||||
@@ -592,6 +614,33 @@ class TrackerWindow(QWidget):
|
||||
self.opponent_list.set_opacity(float(self.settings.get("opacity", 0.90)))
|
||||
self.images.prefetch([e["card_id"] for e in opponent_entries], TILE)
|
||||
|
||||
self._record_history(game, deck, my_class, opponent_class)
|
||||
|
||||
def _record_history(self, game: Game, deck, my_class: str, opponent_class: str) -> None:
|
||||
"""Sonucu belli olan maçı geçmişe yazar.
|
||||
|
||||
Tracker bir maçı ancak bir sonraki maç başlayınca kapatıyor, oysa sonuç
|
||||
etiketi maçın son anında geliyor; o yüzden maç bitişini beklemeden
|
||||
sonucu görür görmez yazıyoruz. Aynı maçın ikinci kez yazılmasını hem
|
||||
buradaki anahtar hem de veritabanındaki benzersizlik engelliyor.
|
||||
"""
|
||||
if not game.result:
|
||||
return
|
||||
source = self.watcher.log_dir.name if self.watcher.log_dir else ""
|
||||
key = (source, game.started_ts)
|
||||
if key == self._recorded_key:
|
||||
return
|
||||
self._recorded_key = key
|
||||
record = history.from_game(
|
||||
game,
|
||||
source,
|
||||
my_class=my_class,
|
||||
opponent_class=opponent_class,
|
||||
deck=deck.name if deck is not None else "",
|
||||
)
|
||||
if self.history.add(record) and self.history_window is not None:
|
||||
self.history_window.reload()
|
||||
|
||||
@staticmethod
|
||||
def _signature(game: Game | None):
|
||||
if game is None:
|
||||
|
||||
Reference in New Issue
Block a user