"""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} {mark}' ) 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, )