mirror of
https://github.com/yusufipk/deste-hearthstone-linux-tracker.git
synced 2026-09-11 10:46:12 +00:00
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
407 lines
15 KiB
Python
407 lines
15 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, 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()
|