mirror of
https://github.com/yusufipk/deste-hearthstone-linux-tracker.git
synced 2026-09-12 03:06:11 +00:00
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
This commit is contained in:
+406
@@ -0,0 +1,406 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user