Files
deste-hearthstone-linux-tra…/ui/widgets.py
T
yusufipk c2a5a2d4d2 Hearthstone deck tracker for Linux, first public version
Reads the game's own Power.log: no screen capture, no image
recognition, no memory reading.

- deck tracking (remaining cards, draw chance, played cards fade in place)
- opponent panel (revealed cards, hand and deck counts)
- decks read from the game's offline cache, matched to the match
- card art in rows, full card on hover
- overlay and window modes, adjustable transparency, tray icon
- English and Turkish interface
- consistency tests over a real log corpus, cross-checked against Zone.log
2026-07-27 22:31:13 +07:00

407 lines
15 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, 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
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()