mirror of
https://github.com/yusufipk/deste-hearthstone-linux-tracker.git
synced 2026-09-11 18:56:12 +00:00
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.
458 lines
17 KiB
Python
458 lines
17 KiB
Python
"""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()
|