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:
yusufipk
2026-07-27 22:31:13 +07:00
commit c2a5a2d4d2
41 changed files with 4521 additions and 0 deletions
View File
+87
View File
@@ -0,0 +1,87 @@
"""Arayüz metinleri: Türkçe ve İngilizce.
Qt Linguist yerine düz bir sözlük: metin sayısı az, derleme adımı istemiyoruz
ve dil menüden anında değişebiliyor. Anahtar bulunamazsa anahtarın kendisi
dönüyor, uygulama metin yüzünden çökmesin.
"""
from __future__ import annotations
import os
LANGUAGES = ("tr", "en")
LANGUAGE_NAMES = {"tr": "Türkçe", "en": "English"}
STRINGS: dict[str, dict[str, str]] = {
"waiting_match": {"tr": "Maç bekleniyor", "en": "Waiting for a match"},
"deck_empty": {
"tr": "Maça girdiğinde destendeki kartlar burada görünecek.",
"en": "Your deck will show up here once a match starts.",
},
"opponent_empty": {
"tr": "Rakip henüz kart göstermedi.",
"en": "The opponent has not revealed a card yet.",
},
"opponent_title": {"tr": "RAKİP", "en": "OPPONENT"},
"opponent_counts": {
"tr": "el {hand} deste {deck}",
"en": "hand {hand} deck {deck}",
},
"deck_auto": {"tr": "Otomatik", "en": "Automatic"},
"deck_tooltip": {"tr": "Takip edilen deste", "en": "Tracked deck"},
"menu_to_window": {"tr": "Pencere moduna geç", "en": "Switch to window mode"},
"menu_to_overlay": {"tr": "Overlay moduna geç", "en": "Switch to overlay mode"},
"menu_opacity": {"tr": "Saydamlık", "en": "Opacity"},
"opacity_full": {"tr": "Tam", "en": "Full"},
"opacity_step": {"tr": "%{value}", "en": "{value}%"},
"menu_draw_chance": {"tr": "Çekme yüzdesi", "en": "Draw chance"},
"menu_language": {"tr": "Dil", "en": "Language"},
"menu_reload_decks": {"tr": "Desteleri yeniden yükle", "en": "Reload decks"},
"menu_hide": {"tr": "Gizle (tepsiye)", "en": "Hide to tray"},
"menu_quit": {"tr": "Çıkış", "en": "Quit"},
"tray_toggle": {"tr": "Göster / Gizle", "en": "Show / Hide"},
"tray_mode": {"tr": "Overlay / Pencere", "en": "Overlay / Window"},
"result_won": {"tr": "Kazandın", "en": "You won"},
"result_lost": {"tr": "Kaybettin", "en": "You lost"},
"result_tied": {"tr": "Berabere", "en": "Tie"},
"error_no_install": {
"tr": "Hearthstone kurulumu bulunamadı.",
"en": "Hearthstone installation not found.",
},
"error_no_install_hint": {
"tr": "Yolu ~/.config/deste/config.json içinde game_dir olarak belirtin.",
"en": "Set the path as game_dir in ~/.config/deste/config.json.",
},
"error_log_config": {
"tr": "Log yapılandırması eksik: ",
"en": "Log configuration is incomplete: ",
},
"error_log_config_hint": {
"tr": "Oyunun logları eksik yazılıyor olabilir.",
"en": "The game may not be writing complete logs.",
},
}
_current = "en"
def detect(preference: str = "auto") -> str:
"""Ayardaki dili çözer. "auto" ise ortamın diline bakar."""
if preference in LANGUAGES:
return preference
locale = os.environ.get("LC_ALL") or os.environ.get("LANG") or ""
return "tr" if locale.lower().startswith("tr") else "en"
def set_language(code: str) -> None:
global _current
_current = code if code in LANGUAGES else detect(code)
def current() -> str:
return _current
def t(key: str, **kwargs) -> str:
text = STRINGS.get(key, {}).get(_current, key)
return text.format(**kwargs) if kwargs else text
+77
View File
@@ -0,0 +1,77 @@
"""Uygulama ikonu.
Önemli ayrıntı: ikonu QIcon.fromTheme ile kurarsak Qt, sistem tepsisine
(StatusNotifierItem) ikonun kendisini değil *adını* gönderiyor. Plasma o adı
kendi ikon önbelleğinden çözmeye çalışıyor ve yeni kurulmuş bir ikonu
bulamayınca tepside boş kare çıkıyor.
Bu yüzden ikon her zaman dosyadan kuruluyor ve önceden üretilmiş PNG'ler
eklenerek gerçek piksel verisi taşınıyor. Böylece tepsi ikonu, tema önbelleği
ne durumda olursa olsun görünüyor.
"""
from __future__ import annotations
from pathlib import Path
from PyQt6.QtCore import QRectF, QSize, Qt
from PyQt6.QtGui import QColor, QIcon, QPainter, QPixmap
from . import theme
ASSETS = Path(__file__).resolve().parent.parent / "assets"
SVG_PATH = ASSETS / "deste.svg"
PNG_DIR = ASSETS / "icons"
PNG_SIZES = (16, 22, 24, 32, 48, 64, 96, 128, 256)
_cached: QIcon | None = None
def _fallback_pixmap(size: int) -> QPixmap:
pixmap = QPixmap(size, size)
pixmap.fill(QColor(0, 0, 0, 0))
scale = size / 64.0
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.scale(scale, scale)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(theme.BACKGROUND))
painter.drawRoundedRect(QRectF(2, 2, 60, 60), 13, 13)
painter.setBrush(QColor(theme.MANA_BLUE))
painter.drawRoundedRect(QRectF(16, 14, 24, 34), 4, 4)
painter.setBrush(QColor(theme.ACCENT))
painter.drawRoundedRect(QRectF(25, 13, 25, 36), 4, 4)
painter.end()
return pixmap
def app_icon() -> QIcon:
global _cached
if _cached is not None:
return _cached
icon = QIcon()
added = False
for size in PNG_SIZES:
png = PNG_DIR / f"deste-{size}.png"
if png.exists():
pixmap = QPixmap(str(png))
if not pixmap.isNull():
icon.addPixmap(pixmap)
added = True
if not added and SVG_PATH.exists():
source = QIcon(str(SVG_PATH))
for size in PNG_SIZES:
pixmap = source.pixmap(QSize(size, size))
if not pixmap.isNull():
icon.addPixmap(pixmap)
added = True
if not added:
for size in PNG_SIZES:
icon.addPixmap(_fallback_pixmap(size))
_cached = icon
return _cached
+133
View File
@@ -0,0 +1,133 @@
"""Görsel tema. Koyu, yarı saydam, oyunun üstünde okunaklı."""
from __future__ import annotations
BACKGROUND = "#0d0f16"
SURFACE = "#171a25"
SURFACE_ALT = "#222738"
ROW_BASE = "#12151f"
TEXT = "#eceef5"
TEXT_DIM = "#8b90a3"
ACCENT = "#e0a63c"
BORDER = "#2b3145"
MANA_BLUE = "#1f4e8c"
WIN = "#5fbf6a"
LOSS = "#d0605a"
RARITY_COLORS = {
"COMMON": "#c9cedb",
"FREE": "#c9cedb",
"RARE": "#3f7fd8",
"EPIC": "#a951d8",
"LEGENDARY": "#e0a63c",
}
CLASS_COLORS = {
"DEATHKNIGHT": "#4a6fa5",
"DEMONHUNTER": "#5c9c48",
"DRUID": "#8b5a2b",
"HUNTER": "#3f8f4f",
"MAGE": "#4aa3c7",
"PALADIN": "#d4b84a",
"PRIEST": "#c9cedb",
"ROGUE": "#7d8494",
"SHAMAN": "#3355aa",
"WARLOCK": "#8a5fbf",
"WARRIOR": "#a5453c",
"NEUTRAL": "#8b90a3",
}
CLASS_NAMES = {
"DEATHKNIGHT": "Death Knight",
"DEMONHUNTER": "Demon Hunter",
"DRUID": "Druid",
"HUNTER": "Hunter",
"MAGE": "Mage",
"PALADIN": "Paladin",
"PRIEST": "Priest",
"ROGUE": "Rogue",
"SHAMAN": "Shaman",
"WARLOCK": "Warlock",
"WARRIOR": "Warrior",
"NEUTRAL": "Neutral",
}
def rgb(hex_color: str) -> tuple[int, int, int]:
hex_color = hex_color.lstrip("#")
return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
STYLESHEET = f"""
QWidget {{
color: {TEXT};
font-size: 12px;
}}
QWidget#topbar {{
background: rgba(28, 33, 49, 170);
border-bottom: 1px solid {BORDER};
border-top-left-radius: 9px;
border-top-right-radius: 9px;
}}
QLabel#header {{
font-size: 13px;
font-weight: 600;
}}
QLabel#subheader {{
color: {TEXT_DIM};
font-size: 11px;
}}
QLabel#section {{
color: {TEXT_DIM};
font-size: 10px;
font-weight: 600;
letter-spacing: 1px;
}}
QLabel#dim {{
color: {TEXT_DIM};
}}
QLabel#count {{
color: {ACCENT};
font-weight: 600;
}}
QLabel#empty {{
color: {TEXT_DIM};
padding: 10px 4px;
}}
QComboBox, QPushButton, QToolButton {{
background: rgba(35, 40, 58, 200);
border: 1px solid {BORDER};
border-radius: 4px;
padding: 3px 8px;
}}
QComboBox:hover, QPushButton:hover, QToolButton:hover {{
background: {SURFACE_ALT};
border-color: {ACCENT};
}}
QComboBox::drop-down {{ border: none; width: 16px; }}
QToolButton#menu {{ padding: 2px 6px; font-size: 15px; }}
QToolButton#menu::menu-indicator {{ image: none; width: 0; }}
QComboBox QAbstractItemView {{
background: {SURFACE};
border: 1px solid {BORDER};
selection-background-color: {SURFACE_ALT};
outline: none;
}}
QMenu {{
background: {SURFACE};
border: 1px solid {BORDER};
padding: 4px;
}}
QMenu::item {{ padding: 5px 22px 5px 12px; border-radius: 3px; }}
QMenu::item:selected {{ background: {SURFACE_ALT}; }}
QMenu::separator {{ height: 1px; background: {BORDER}; margin: 4px 6px; }}
QScrollArea, QScrollArea > QWidget, QScrollArea > QWidget > QWidget {{
border: none;
background: transparent;
}}
QScrollBar:vertical {{ background: transparent; width: 5px; margin: 0; }}
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 2px; min-height: 20px; }}
QScrollBar::handle:vertical:hover {{ background: {TEXT_DIM}; }}
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
QScrollBar::add-page, QScrollBar::sub-page {{ background: transparent; }}
"""
+406
View File
@@ -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()
+608
View File
@@ -0,0 +1,608 @@
"""Tracker penceresi.
İki mod:
overlay : çerçevesiz, üstte duran, sürüklenebilir, yarı saydam panel
window : sıradan pencere (başlık çubuğu, Alt+Tab)
Wayland'da uygulama kendini "her zaman üstte" yapamaz, bunu pencere yöneticisi
belirler; install.sh bunun için bir KWin kuralı yazıyor.
"""
from __future__ import annotations
from collections import Counter
from PyQt6.QtCore import QEvent, QPoint, Qt, QTimer
from PyQt6.QtGui import QAction, QActionGroup, QColor, QIcon, QPainter, QPainterPath
from PyQt6.QtWidgets import (
QHBoxLayout,
QLabel,
QLayout,
QMenu,
QScrollArea,
QSizeGrip,
QSizePolicy,
QSystemTrayIcon,
QToolButton,
QVBoxLayout,
QWidget,
)
from core import config
from core.state import Game
from data.images import TILE, ImageCache
from . import i18n, theme
from .i18n import LANGUAGE_NAMES, LANGUAGES, t
from .icon import app_icon
from .widgets import CardList, CardPreview, ElidedLabel
POLL_INTERVAL_MS = 400
MIN_WIDTH = 210
MIN_HEIGHT = 260
RESULT_MARK = {"WON": ("", theme.WIN), "LOST": ("", theme.LOSS), "TIED": ("=", theme.TEXT_DIM)}
RESULT_TOOLTIP = {"WON": "result_won", "LOST": "result_lost", "TIED": "result_tied"}
OPACITY_STEPS = [1.00, 0.90, 0.75, 0.60, 0.45]
class ClassDot(QLabel):
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(8, 8)
self.set_class("NEUTRAL")
def set_class(self, player_class: str) -> None:
color = theme.CLASS_COLORS.get(player_class, theme.TEXT_DIM)
self.setStyleSheet(f"background: {color}; border-radius: 4px;")
class TrackerWindow(QWidget):
def __init__(self, watcher, library, cards, settings: dict):
super().__init__()
self.watcher = watcher
self.library = library
self.cards = cards
self.settings = settings
self.mode = settings.get("window_mode", "overlay")
self.images = ImageCache()
# Önizleme pencereye bağlı: Wayland'da konumlandırılabilmesi için
# bir ebeveyne bağlı popup olması gerekiyor, başıboş pencere taşınamaz.
self.preview = CardPreview(self.images, self)
self._last_signature = None
# Boyut ve konum değişikliklerini hemen değil, kısa bir gecikmeyle
# kaydediyoruz: sürükleme sırasında her pikselde dosyaya yazmayalım.
self._geometry_timer = QTimer(self)
self._geometry_timer.setSingleShot(True)
self._geometry_timer.timeout.connect(self._save_geometry)
self._last_image_generation = -1
self.setWindowTitle("deste")
self.setWindowIcon(app_icon())
self.setStyleSheet(theme.STYLESHEET)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self._build()
self._build_tray()
self._apply_mode(self.mode, first=True)
self._apply_opacity()
self.timer = QTimer(self)
self.timer.timeout.connect(self._tick)
self.timer.start(POLL_INTERVAL_MS)
self._refresh(force=True)
# --- kurulum --------------------------------------------------------
def _build(self) -> None:
outer = QVBoxLayout(self)
outer.setContentsMargins(1, 1, 1, 1)
outer.setSpacing(0)
# Yerleşimin minimum boyutu pencereye dayatılmasın: maç sonucu gibi
# yeni bir yazı geldiğinde pencere kendiliğinden genişliyordu ve
# kullanıcı her maçta boyutu elle düzeltmek zorunda kalıyordu.
outer.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
self.setMinimumSize(MIN_WIDTH, MIN_HEIGHT)
# Üst çubuk: başlık ve deste seçimi ayrı bir zeminde dursun ki panel
# düz bir liste yığını gibi görünmesin.
topbar = QWidget()
topbar.setObjectName("topbar")
outer.addWidget(topbar)
top_layout = QVBoxLayout(topbar)
top_layout.setContentsMargins(10, 8, 8, 8)
top_layout.setSpacing(6)
body = QWidget()
outer.addWidget(body, 1)
layout = QVBoxLayout(body)
layout.setContentsMargins(9, 8, 9, 9)
layout.setSpacing(6)
# Başlık: sınıf noktaları, tur, menü
header = QHBoxLayout()
header.setSpacing(6)
self.my_dot = ClassDot()
header.addWidget(self.my_dot)
self.header_label = ElidedLabel(t("waiting_match"))
self.header_label.setObjectName("header")
header.addWidget(self.header_label, 1)
# Sabit genişlik: "T12" ile "T12 ✓" arasındaki fark pencereyi büyütmesin.
self.turn_label = QLabel("")
self.turn_label.setObjectName("dim")
self.turn_label.setFixedWidth(48)
self.turn_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
header.addWidget(self.turn_label)
self.menu_button = QToolButton()
self.menu_button.setObjectName("menu")
self.menu_button.setText("")
self.menu = self._build_menu()
self.menu_button.clicked.connect(
lambda: self._popup(self.menu_button, self.menu, align_right=True)
)
header.addWidget(self.menu_button)
top_layout.addLayout(header)
# Deste satırı
deck_row = QHBoxLayout()
deck_row.setSpacing(6)
self.deck_button = QToolButton()
self.deck_button.setToolTip(t("deck_tooltip"))
self.deck_button.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
self.deck_button.clicked.connect(
lambda: self._popup(self.deck_button, self.deck_menu, align_right=False)
)
deck_row.addWidget(self.deck_button, 1)
self.deck_count_label = QLabel("")
self.deck_count_label.setObjectName("count")
deck_row.addWidget(self.deck_count_label)
top_layout.addLayout(deck_row)
self._rebuild_deck_menu()
# Kendi destem
self.my_list = CardList(self.images)
self.my_list.set_empty_text(t("deck_empty"))
layout.addWidget(self._scrollable(self.my_list), 3)
# Rakip
opponent_header = QHBoxLayout()
opponent_header.setSpacing(6)
self.opponent_dot = ClassDot()
opponent_header.addWidget(self.opponent_dot)
self.opponent_title = QLabel(t("opponent_title"))
self.opponent_title.setObjectName("section")
opponent_header.addWidget(self.opponent_title, 1)
self.opponent_counts = QLabel("")
self.opponent_counts.setObjectName("dim")
opponent_header.addWidget(self.opponent_counts)
layout.addLayout(opponent_header)
self.opponent_list = CardList(self.images)
self.opponent_list.set_empty_text(t("opponent_empty"))
layout.addWidget(self._scrollable(self.opponent_list), 2)
# Overlay modunda pencere çerçevesi yok, boyutlandırmak için köşede
# bir tutamak gerekiyor. Wayland'da startSystemResize üzerinden çalışır.
grip_row = QHBoxLayout()
grip_row.setContentsMargins(0, 0, 0, 0)
grip_row.addStretch(1)
self.size_grip = QSizeGrip(self)
self.size_grip.setFixedSize(14, 14)
self.size_grip.installEventFilter(self)
grip_row.addWidget(self.size_grip, 0, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignBottom)
layout.addLayout(grip_row)
@staticmethod
def _scrollable(widget: QWidget) -> QScrollArea:
area = QScrollArea()
area.setWidgetResizable(True)
area.setWidget(widget)
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
area.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
area.viewport().setAutoFillBackground(False)
return area
def _build_menu(self) -> QMenu:
menu = QMenu(self)
self.mode_action = QAction(
t("menu_to_window") if self.mode == "overlay" else t("menu_to_overlay"), self
)
self.mode_action.triggered.connect(self._toggle_mode)
menu.addAction(self.mode_action)
opacity_menu = menu.addMenu(t("menu_opacity"))
group = QActionGroup(self)
group.setExclusive(True)
current = float(self.settings.get("opacity", 0.90))
for value in OPACITY_STEPS:
label = (
t("opacity_full")
if value >= 1.0
else t("opacity_step", value=round(value * 100))
)
action = QAction(label, self, checkable=True)
action.setChecked(abs(value - current) < 0.01)
action.triggered.connect(lambda _checked, v=value: self._set_opacity(v))
group.addAction(action)
opacity_menu.addAction(action)
self.chance_action = QAction(t("menu_draw_chance"), self, checkable=True)
self.chance_action.setChecked(bool(self.settings.get("show_draw_chance", True)))
self.chance_action.triggered.connect(self._toggle_chance)
menu.addAction(self.chance_action)
language_menu = menu.addMenu(t("menu_language"))
language_group = QActionGroup(self)
language_group.setExclusive(True)
for code in LANGUAGES:
action = QAction(LANGUAGE_NAMES[code], self, checkable=True)
action.setChecked(code == i18n.current())
action.triggered.connect(lambda _checked, c=code: self._set_language(c))
language_group.addAction(action)
language_menu.addAction(action)
menu.addSeparator()
reload_action = QAction(t("menu_reload_decks"), self)
reload_action.triggered.connect(self._reload_decks)
menu.addAction(reload_action)
menu.addSeparator()
hide_action = QAction(t("menu_hide"), self)
hide_action.triggered.connect(self.hide)
menu.addAction(hide_action)
quit_action = QAction(t("menu_quit"), self)
quit_action.triggered.connect(self._quit)
menu.addAction(quit_action)
return menu
def _build_tray(self) -> None:
self.tray = QSystemTrayIcon(app_icon(), self)
self.tray.setToolTip("deste")
self.tray_menu = QMenu()
self._fill_tray_menu()
self.tray.setContextMenu(self.tray_menu)
self.tray.activated.connect(self._on_tray_activated)
self.tray.show()
def _fill_tray_menu(self) -> None:
self.tray_menu.clear()
show_action = QAction(t("tray_toggle"), self)
show_action.triggered.connect(self._toggle_visible)
self.tray_menu.addAction(show_action)
mode_action = QAction(t("tray_mode"), self)
mode_action.triggered.connect(self._toggle_mode)
self.tray_menu.addAction(mode_action)
self.tray_menu.addSeparator()
quit_action = QAction(t("menu_quit"), self)
quit_action.triggered.connect(self._quit)
self.tray_menu.addAction(quit_action)
def _set_language(self, code: str) -> None:
"""Dili anında değiştirir: menüler ve sabit metinler yeniden kurulur."""
if code == i18n.current():
return
i18n.set_language(code)
self.settings["language"] = code
config.save(self.settings)
self.menu = self._build_menu()
self._fill_tray_menu()
self.deck_button.setToolTip(t("deck_tooltip"))
self.my_list.set_empty_text(t("deck_empty"))
self.opponent_list.set_empty_text(t("opponent_empty"))
self.opponent_title.setText(t("opponent_title"))
self._rebuild_deck_menu()
self._refresh(force=True)
def _on_tray_activated(self, reason) -> None:
if reason == QSystemTrayIcon.ActivationReason.Trigger:
self._toggle_visible()
def _toggle_visible(self) -> None:
if self.isVisible():
self.hide()
else:
self.show()
self.raise_()
def _quit(self) -> None:
self._save_geometry()
self.images.shutdown()
self.tray.hide()
from PyQt6.QtWidgets import QApplication
QApplication.quit()
def _rebuild_deck_menu(self) -> None:
menu = QMenu(self)
group = QActionGroup(self)
group.setExclusive(True)
selected = self.settings.get("selected_deck", "")
auto_label = t("deck_auto")
for name in [auto_label] + [d.name for d in self.library.decks]:
action = QAction(name, self, checkable=True)
action.setChecked((name == auto_label and not selected) or name == selected)
action.triggered.connect(lambda _checked, n=name: self._select_deck(n))
group.addAction(action)
menu.addAction(action)
self.deck_menu = menu
self._set_deck_text(selected or auto_label)
def _set_deck_text(self, name: str) -> None:
# Ok işareti metnin parçası: menü düğmeye setMenu ile bağlı olmadığı
# için Qt'nin kendi göstergesi çizilmiyor.
self.deck_button.setText(f"{name}")
def _set_turn_text(self, game: Game | None) -> None:
"""Tur sayısı ve maç sonucu.
Sonuç "WON" yazısıyla değil renkli bir işaretle gösteriliyor: etiket
sabit genişlikte kalsın, yazı geldiğinde panel büyümesin diye.
"""
if game is None:
self.turn_label.setText("")
self.turn_label.setToolTip("")
return
mark, color = RESULT_MARK.get(game.result, ("", ""))
if mark:
self.turn_label.setText(
f'T{game.game_turn} <span style="color:{color}">{mark}</span>'
)
self.turn_label.setToolTip(t(RESULT_TOOLTIP[game.result]))
else:
self.turn_label.setText(f"T{game.game_turn}")
self.turn_label.setToolTip("")
def _popup(self, button: QToolButton, menu: QMenu, align_right: bool) -> None:
"""Menüyü düğmenin altında açar.
Qt'nin kendi yerleştirmesi menüyü ekrana sığdırmaya çalışırken
pencerenin gerçek konumunu bildiğini varsayıyor. Wayland'da bilmiyor,
o yüzden menü ekranın dibine düşüyordu. Konumu düğmeden türetince
menü her zaman düğmenin altında açılıyor.
"""
width = menu.sizeHint().width()
x = button.width() - width if align_right else 0
menu.popup(button.mapToGlobal(QPoint(x, button.height() + 3)))
def _reload_decks(self) -> None:
self.library.load()
self._rebuild_deck_menu()
self._refresh(force=True)
def _select_deck(self, name: str) -> None:
self.settings["selected_deck"] = "" if name == t("deck_auto") else name
config.save(self.settings)
self._set_deck_text(name)
self._refresh(force=True)
def _set_opacity(self, value: float) -> None:
self.settings["opacity"] = value
config.save(self.settings)
self._apply_opacity()
def _apply_opacity(self) -> None:
value = float(self.settings.get("opacity", 0.90))
self.my_list.set_opacity(value)
self.opponent_list.set_opacity(value)
self.update()
def _toggle_chance(self) -> None:
self.settings["show_draw_chance"] = self.chance_action.isChecked()
config.save(self.settings)
self._refresh(force=True)
# --- zemin ----------------------------------------------------------
def paintEvent(self, event) -> None: # noqa: N802
"""Yarı saydam, yuvarlatılmış zemin. Oyun arkadan görünsün diye."""
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
alpha = int(255 * float(self.settings.get("opacity", 0.90)))
path = QPainterPath()
radius = 10.0 if self.mode == "overlay" else 6.0
path.addRoundedRect(0.0, 0.0, float(self.width()), float(self.height()), radius, radius)
painter.fillPath(path, QColor(*theme.rgb(theme.BACKGROUND), alpha))
pen_color = QColor(*theme.rgb(theme.BORDER), min(alpha + 40, 255))
painter.setPen(pen_color)
painter.drawPath(path)
painter.end()
# --- mod yönetimi ---------------------------------------------------
def _toggle_mode(self) -> None:
self._save_geometry()
self._apply_mode("window" if self.mode == "overlay" else "overlay")
def _apply_mode(self, mode: str, first: bool = False) -> None:
self.mode = mode
self.settings["window_mode"] = mode
config.save(self.settings)
if mode == "overlay":
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint
| Qt.WindowType.Tool
| Qt.WindowType.WindowStaysOnTopHint
)
self.mode_action.setText(t("menu_to_window"))
else:
self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.WindowStaysOnTopHint)
self.mode_action.setText(t("menu_to_overlay"))
self.size_grip.setVisible(mode == "overlay")
self._restore_geometry()
self.show()
def _save_geometry(self) -> None:
# Yalnızca boyut saklanır. Wayland'da pencerenin konumunu bileşik
# yönetici belirliyor ve uygulamaya söylemiyor; buradan okunan konum
# gerçeği yansıtmıyor, kaydedip geri yüklersek pencere ekran dışına
# düşmüş gibi davranıyor ve menüler yanlış yere açılıyor.
geometry = self.settings.setdefault("geometry", {})
rect = self.geometry()
geometry[self.mode] = [rect.width(), rect.height()]
config.save(self.settings)
def _restore_geometry(self) -> None:
saved = self.settings.get("geometry", {}).get(self.mode)
if saved and len(saved) == 4: # eski biçim: x, y, w, h
saved = saved[2:]
if saved and len(saved) == 2:
self.resize(max(saved[0], MIN_WIDTH), max(saved[1], MIN_HEIGHT))
else:
self.resize(310, 640)
def mousePressEvent(self, event) -> None: # noqa: N802
# Taşımayı pencere yöneticisi yapar. Kendi move() çağrımız Wayland'da
# pencereyi kıpırdatmıyor, sadece Qt'nin konum bilgisini bozuyordu.
if self.mode == "overlay" and event.button() == Qt.MouseButton.LeftButton:
handle = self.windowHandle()
if handle is not None:
handle.startSystemMove()
def eventFilter(self, obj, event): # noqa: N802
# Boyut tutamağı bırakıldığında yeni boyutu kaydet. Pencere yöneticisi
# ya da tiling script'i boyutu değiştirdiğinde kaydetmiyoruz, yoksa
# bizim tercihimiz onların dayattığı boyutla eziliyor.
if obj is self.size_grip and event.type() == QEvent.Type.MouseButtonRelease:
self._geometry_timer.start(200)
return super().eventFilter(obj, event)
def closeEvent(self, event) -> None: # noqa: N802
# Kapatma tepsiye gizler, uygulama arka planda kalır.
self._save_geometry()
if self.tray.isVisible():
event.ignore()
self.hide()
else:
super().closeEvent(event)
# --- güncelleme -----------------------------------------------------
def _tick(self) -> None:
changed = self.watcher.poll()
if changed:
self._refresh()
if self.images.generation != self._last_image_generation:
self._last_image_generation = self.images.generation
self.my_list.repaint_rows()
self.opponent_list.repaint_rows()
def _selected_deck(self, game: Game | None):
name = self.settings.get("selected_deck", "")
if name:
for deck in self.library.decks:
if deck.name == name:
return deck
if game is None:
return None
return self.library.match(game)
def _entry(self, card_id: str, count: int, chance=None, faded: bool = False) -> dict:
return {
"card_id": card_id,
"name": self.cards.name(card_id),
"cost": self.cards.cost(card_id),
"count": count,
"rarity": self.cards.rarity(card_id),
"chance": chance,
"faded": faded,
}
def _refresh(self, force: bool = False) -> None:
game = self.watcher.game
signature = self._signature(game)
if not force and signature == self._last_signature:
return
self._last_signature = signature
if game is None or game.local_player_id is None:
self.header_label.set_full_text(t("waiting_match"))
self._set_turn_text(None)
self.my_dot.set_class("NEUTRAL")
self.opponent_dot.set_class("NEUTRAL")
self.my_list.set_cards([])
self.opponent_list.set_cards([])
self.deck_count_label.setText("")
self.opponent_counts.setText("")
return
my_class = self.cards.card_class(game.hero_card_id(game.local_player_id))
opponent_class = self.cards.card_class(game.hero_card_id(game.opponent_player_id))
self.my_dot.set_class(my_class or "NEUTRAL")
self.opponent_dot.set_class(opponent_class or "NEUTRAL")
self.header_label.set_full_text(
f"{theme.CLASS_NAMES.get(my_class, my_class or '?')}"
f" vs {theme.CLASS_NAMES.get(opponent_class, opponent_class or '?')}"
)
self._set_turn_text(game)
deck = self._selected_deck(game)
if deck is not None:
if not self.settings.get("selected_deck"):
self._set_deck_text(deck.name)
remaining = game.remaining_deck(deck.cards)
total = sum(remaining.values())
self.deck_count_label.setText(str(total))
show_chance = bool(self.settings.get("show_draw_chance", True))
# Destede kalmayan kartlar listeden düşmez, solgun gösterilir:
# "bu kartı çektim/oynadım" bilgisi listeden silinince kayboluyor.
all_cards = Counter(deck.cards)
all_cards.update(game.cards_shuffled_in)
entries = []
for card_id in sorted(
all_cards, key=lambda c: (self.cards.cost(c), self.cards.name(c))
):
count = remaining.get(card_id, 0)
entries.append(
self._entry(
card_id,
count,
(100.0 * count / total) if (show_chance and total and count) else None,
faded=count == 0,
)
)
self.my_list.set_cards(entries)
self.my_list.set_opacity(float(self.settings.get("opacity", 0.90)))
self.images.prefetch([e["card_id"] for e in entries], TILE)
else:
self.deck_count_label.setText(str(game.my_deck_count))
self.my_list.set_cards([])
self.opponent_counts.setText(
t(
"opponent_counts",
hand=game.opponent_hand_count,
deck=game.opponent_deck_count,
)
)
seen: dict[str, int] = {}
for event in game.opponent_events:
if event.card_id:
seen[event.card_id] = seen.get(event.card_id, 0) + 1
opponent_entries = [
self._entry(card_id, count)
for card_id, count in sorted(
seen.items(), key=lambda kv: (self.cards.cost(kv[0]), self.cards.name(kv[0]))
)
]
self.opponent_list.set_cards(opponent_entries)
self.opponent_list.set_opacity(float(self.settings.get("opacity", 0.90)))
self.images.prefetch([e["card_id"] for e in opponent_entries], TILE)
@staticmethod
def _signature(game: Game | None):
if game is None:
return None
return (
id(game),
game.turn,
game.result,
len(game.my_events),
len(game.opponent_events),
game.my_deck_count,
game.opponent_hand_count,
game.opponent_deck_count,
)