Files
deste-hearthstone-linux-tra…/ui/window.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

609 lines
24 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.
"""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,
)