Files
deste-hearthstone-linux-tra…/ui/widgets.py
T
yusufipk fa45c19d3b 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.
2026-07-28 00:04:24 +07:00

458 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Kart satırı, kart listesi ve fareyle üzerine gelince açılan kart önizlemesi.
Satır tasarımı: kart sanatının şeridi (256x59) satırın arka planında durur,
soldan sağa koyu bir gradyanla kararır ki üstündeki yazı okunsun. Solda mana
bedeli, sağda adet. Bu düzen deck tracker'ların oturmuş dili, oyundan gelen
görselle birlikte satır tanınabilir oluyor.
"""
from __future__ import annotations
from PyQt6.QtCore import QPoint, QRect, QRectF, QSize, Qt, QTimer
from PyQt6.QtGui import (
QColor,
QFont,
QLinearGradient,
QPainter,
QPainterPath,
QPen,
QPixmap,
)
from PyQt6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
from data.images import RENDER, TILE
from . import theme
ROW_HEIGHT = 26
COST_WIDTH = 26
COUNT_WIDTH = 30
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.
Düz QLabel uzun metinde sizeHint'ini büyütüyor, üst düzey pencerede bu
minimum genişliğe dönüşüyor ve pencere kendiliğinden genişliyordu (maç
sonucu yazısı geldiğinde panel şişiyordu). Burada sizeHint yok sayılıyor,
metin sığmazsa üç noktayla kesiliyor.
"""
def __init__(self, text: str = "", parent=None):
super().__init__(parent)
self._full = text
self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
self.setMinimumWidth(0)
self._apply()
def minimumSizeHint(self) -> QSize: # noqa: N802
return QSize(0, super().minimumSizeHint().height())
def set_full_text(self, text: str) -> None:
if text != self._full:
self._full = text
self._apply()
def resizeEvent(self, event) -> None: # noqa: N802
super().resizeEvent(event)
self._apply()
def _apply(self) -> None:
width = self.width()
if width <= 0:
super().setText(self._full)
return
super().setText(
self.fontMetrics().elidedText(self._full, Qt.TextElideMode.ElideRight, width)
)
class CardRow(QWidget):
def __init__(self, images, parent=None):
super().__init__(parent)
self.images = images
self.setFixedHeight(ROW_HEIGHT)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.setMouseTracking(True)
self.card_id = ""
self.card_name = ""
self.cost = 0
self.count = 1
self.rarity = ""
self.chance = None
self.faded = False
# Panelin saydamlığı satırlara da uygulanır, yoksa oyun yalnızca
# kenarlardan görünür ve overlay opak bir blok gibi durur.
self.opacity = 1.0
self._pixmap: QPixmap | None = None
self._pixmap_source = ""
def set_card(
self,
card_id: str,
name: str,
cost: int,
count: int,
rarity: str = "",
chance=None,
faded: bool = False,
) -> None:
changed = card_id != self.card_id
self.card_id = card_id
self.card_name = name
self.cost = cost
self.count = count
self.rarity = rarity
self.chance = chance
self.faded = faded
if changed:
self._pixmap = None
self._pixmap_source = ""
self.update()
def _tile(self) -> QPixmap | None:
"""Sanat şeridi. Diskte yoksa indirmeye alınır, o ana kadar düz zemin."""
path = self.images.get(self.card_id, TILE) if self.images else None
if path is None:
return None
if self._pixmap is not None and self._pixmap_source == str(path):
return self._pixmap
pixmap = QPixmap(str(path))
if pixmap.isNull():
return None
self._pixmap = pixmap
self._pixmap_source = str(path)
return pixmap
def paintEvent(self, event) -> None: # noqa: N802 (Qt adlandırması)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
width, height = self.width(), self.height() - 2
if width <= 0 or height <= 0:
return
path = QPainterPath()
path.addRoundedRect(0.0, 0.0, float(width), float(height), 3.0, 3.0)
painter.setClipPath(path)
# Zemin ve sanat panel saydamlığını izler, yazı biraz daha opak kalır
# ki oyunun üstünde okunabilirliği düşmesin.
base_opacity = self.opacity
text_opacity = min(1.0, self.opacity + 0.22)
painter.setOpacity(base_opacity)
painter.fillRect(0, 0, width, height, QColor(theme.ROW_BASE))
tile = self._tile()
if tile is not None:
# Şerit satırın tamamını kaplar, dikeyde ortadan kırpılır. Sağa
# yaslamak satırın ortasında sert bir kesik bırakıyordu.
scaled = tile.scaled(
width,
height,
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation,
)
top = max((scaled.height() - height) // 2, 0)
painter.setOpacity(base_opacity * (0.62 if self.faded else 0.95))
painter.drawPixmap(
QRect(0, 0, width, height), scaled, QRect(0, top, width, height)
)
painter.setOpacity(base_opacity)
# Soldan sağa kararan gradyan: isim her zaman okunur kalsın.
gradient = QLinearGradient(0, 0, width, 0)
gradient.setColorAt(0.0, QColor(theme.ROW_BASE))
gradient.setColorAt(0.30, QColor(*theme.rgb(theme.ROW_BASE), 240))
gradient.setColorAt(0.62, QColor(*theme.rgb(theme.ROW_BASE), 170))
gradient.setColorAt(1.0, QColor(*theme.rgb(theme.ROW_BASE), 55))
painter.fillRect(0, 0, width, height, gradient)
if self.faded:
# Kullanılmış kart: listeden düşmez, kararır. Tamamen silmek yerine
# yerinde bırakmak "bunu çektim" bilgisini görünür tutuyor.
painter.fillRect(0, 0, width, height, QColor(0, 0, 0, 95))
# Mana bedeli kutusu
painter.setOpacity(base_opacity)
cost_color = QColor(theme.MANA_BLUE)
painter.fillRect(0, 0, COST_WIDTH, height, cost_color)
font = QFont(self.font())
font.setBold(True)
font.setPointSizeF(font.pointSizeF() + 0.5)
painter.setFont(font)
painter.setOpacity(text_opacity)
painter.setPen(QPen(QColor("#ffffff" if not self.faded else theme.TEXT_DIM)))
painter.drawText(
QRect(0, 0, COST_WIDTH, height),
int(Qt.AlignmentFlag.AlignCenter),
str(self.cost),
)
# Nadirlik şeridi mana kutusunun hemen sağında
painter.fillRect(
COST_WIDTH, 0, 2, height, QColor(theme.RARITY_COLORS.get(self.rarity, theme.TEXT_DIM))
)
# Adet ve yüzde
font.setBold(self.count > 1)
painter.setFont(font)
right_edge = width - 6
align_right = int(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight)
def draw_badge(rect: QRect, text: str, color: str, strong: bool) -> None:
"""Sayıyı koyu bir kutunun içine yazar.
Kart sanatının parlak kısımlarında sadece gölge yetmiyordu, sayılar
kayboluyordu. Arkasına yarı opak bir zemin koyunca her kartta okunur
oluyor.
"""
painter.setOpacity(min(1.0, base_opacity + 0.15))
badge = QPainterPath()
badge.addRoundedRect(
float(rect.x() - 3), 3.0, float(rect.width() + 6), float(height - 6), 3.0, 3.0
)
painter.fillPath(badge, QColor(8, 10, 16, 215 if strong else 185))
painter.setOpacity(1.0)
painter.setPen(QPen(QColor(color)))
painter.drawText(rect, align_right, text)
if self.chance is not None:
draw_badge(
QRect(right_edge - 30, 0, 30, height),
f"%{self.chance:.0f}",
theme.TEXT if not self.faded else theme.TEXT_DIM,
False,
)
right_edge -= 38
if self.count > 1:
draw_badge(
QRect(right_edge - 16, 0, 16, height),
str(self.count),
theme.ACCENT,
True,
)
right_edge -= 26
# İsim, gölgeli çizilir ki sanatın üstünde kaybolmasın
name_rect = QRect(COST_WIDTH + 8, 0, max(right_edge - COST_WIDTH - 12, 20), height)
font.setBold(False)
painter.setFont(font)
metrics = painter.fontMetrics()
elided = metrics.elidedText(self.card_name, Qt.TextElideMode.ElideRight, name_rect.width())
painter.setPen(QPen(QColor(0, 0, 0, 190)))
painter.drawText(name_rect.translated(1, 1), int(Qt.AlignmentFlag.AlignVCenter), elided)
painter.setPen(QPen(QColor(theme.TEXT if not self.faded else theme.TEXT_DIM)))
painter.drawText(name_rect, int(Qt.AlignmentFlag.AlignVCenter), elided)
painter.end()
# --- önizleme -------------------------------------------------------
def enterEvent(self, event) -> None: # noqa: N802
window = self.window()
preview = getattr(window, "preview", None)
if preview is not None and self.card_id:
preview.request(self.card_id, self)
super().enterEvent(event)
def leaveEvent(self, event) -> None: # noqa: N802
window = self.window()
preview = getattr(window, "preview", None)
if preview is not None:
preview.cancel()
super().leaveEvent(event)
class CardPreview(QWidget):
"""Fareyle üzerine gelinen kartın tam görseli.
Kartın kendi render'ı metnini de içerdiği için ayrıca açıklama yazmaya
gerek kalmıyor. Görsel henüz inmediyse pencere açılmaz, indiği anda çıkar.
"""
def __init__(self, images, parent=None):
super().__init__(parent, Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint)
self.images = images
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
# Önizleme fare olaylarını yutarsa satır "fare çıktı" sanıp gizliyor,
# sonra fare tekrar satıra giriyor ve açılıp kapanma döngüsü oluşuyor.
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self._label = QLabel(self)
self._label.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self._label)
self._card_id = ""
self._anchor: QWidget | None = None
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self._show_now)
# Satırdan satıra geçerken önizleme kapanıp açılmasın diye gizleme
# kısa bir gecikmeyle yapılıyor; bu arada yeni istek gelirse iptal olur.
self._hide_timer = QTimer(self)
self._hide_timer.setSingleShot(True)
self._hide_timer.timeout.connect(self.hide)
def request(self, card_id: str, anchor: QWidget) -> None:
self._hide_timer.stop()
if card_id == self._card_id and self.isVisible():
self._anchor = anchor
return
self._card_id = card_id
self._anchor = anchor
self.images.get(card_id, RENDER)
if self.isVisible():
# Zaten açıksa gecikmeye gerek yok, doğrudan kartı değiştir.
self._show_now()
else:
self._timer.start(PREVIEW_DELAY_MS)
def cancel(self) -> None:
self._timer.stop()
self._hide_timer.start(HIDE_DELAY_MS)
def _show_now(self) -> None:
if not self._card_id or self._anchor is None:
return
path = self.images.get(self._card_id, RENDER)
if path is None:
# Görsel hâlâ iniyor, birazdan tekrar dene.
self._timer.start(400)
return
pixmap = QPixmap(str(path))
if pixmap.isNull():
return
self._label.setPixmap(pixmap)
self.resize(pixmap.size())
# Konum yalnızca pencerenin kendi koordinatlarından üretilir.
# Wayland'da uygulama pencerenin gerçek ekran konumunu bilmiyor;
# ekran kenarına göre kırpma yapmaya kalkarsak (eski "max(y, 0)")
# önizleme paletin dibine düşüyor. Pencereye göre hesaplayınca
# aradaki bilinmeyen kayma sadeleşiyor, ekran kenarını da bileşik
# yönetici kendisi düzeltiyor.
window = self._anchor.window()
origin = window.mapToGlobal(QPoint(0, 0))
anchor = self._anchor.mapToGlobal(QPoint(0, 0))
x = origin.x() - pixmap.width() - PREVIEW_GAP
y = anchor.y() + self._anchor.height() // 2 - pixmap.height() // 2
top = origin.y()
bottom = max(origin.y() + window.height() - pixmap.height(), top)
self.move(x, min(max(y, top), bottom))
if not self.isVisible():
self.show()
self.raise_()
class CardList(QWidget):
"""Satırları havuzda tutan liste. Her güncellemede widget yaratmak titretir."""
def __init__(self, images, parent=None):
super().__init__(parent)
self.images = images
self.opacity = 1.0
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setSpacing(2)
self._rows: list[CardRow] = []
self._empty = QLabel("")
self._empty.setObjectName("empty")
self._empty.setWordWrap(True)
self._empty.setVisible(False)
self._layout.addWidget(self._empty)
self._layout.addStretch(1)
def set_empty_text(self, text: str) -> None:
self._empty.setText(text)
def set_opacity(self, value: float) -> None:
self.opacity = value
for row in self._rows:
row.opacity = value
row.update()
def set_cards(self, entries: list[dict]) -> None:
self._empty.setVisible(not entries)
while len(self._rows) < len(entries):
row = CardRow(self.images, self)
row.opacity = self.opacity
self._rows.append(row)
self._layout.insertWidget(self._layout.count() - 1, row)
for index, row in enumerate(self._rows):
if index < len(entries):
entry = entries[index]
row.set_card(
entry.get("card_id", ""),
entry.get("name", "?"),
entry.get("cost", 0),
entry.get("count", 1),
entry.get("rarity", ""),
entry.get("chance"),
entry.get("faded", False),
)
row.setVisible(True)
else:
row.setVisible(False)
def repaint_rows(self) -> None:
"""Yeni görsel indiğinde satırları tazeler."""
for row in self._rows:
if row.isVisible():
row.update()