mirror of
https://github.com/yusufipk/deste-hearthstone-linux-tracker.git
synced 2026-09-11 10:46:12 +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:
+87
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user