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:
yusufipk
2026-07-28 00:04:24 +07:00
parent 4e67b34977
commit fa45c19d3b
11 changed files with 1442 additions and 12 deletions
+600
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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: