from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush, QDesktopServices from PySide6.QtCore import QEasingCurve, Signal, Property, Qt, QPropertyAnimation, QByteArray, QUrl from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel from collections.abc import Callable import portprotonqt.themes.standart.styles as default_styles from portprotonqt.image_utils import load_pixmap_async, round_corners from portprotonqt.localization import _ from portprotonqt.config_utils import read_favorites, save_favorites from portprotonqt.theme_manager import ThemeManager from portprotonqt.config_utils import read_theme_from_config from portprotonqt.custom_widgets import ClickableLabel import weakref from typing import cast class GameCard(QFrame): borderWidthChanged = Signal() gradientAngleChanged = Signal() # Signals for context menu actions editShortcutRequested = Signal(str, str, str) # name, exec_line, cover_path deleteGameRequested = Signal(str, str) # name, exec_line addToMenuRequested = Signal(str, str) # name, exec_line removeFromMenuRequested = Signal(str) # name addToDesktopRequested = Signal(str, str) # name, exec_line removeFromDesktopRequested = Signal(str) # name addToSteamRequested = Signal(str, str, str) # name, exec_line, cover_path removeFromSteamRequested = Signal(str, str) # name, exec_line openGameFolderRequested = Signal(str, str) # name, exec_line def __init__(self, name, description, cover_path, appid, controller_support, exec_line, last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, steam_game, select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None): super().__init__(parent) self.name = name self.description = description self.cover_path = cover_path self.appid = appid self.controller_support = controller_support self.exec_line = exec_line self.last_launch = last_launch self.formatted_playtime = formatted_playtime self.protondb_tier = protondb_tier self.anticheat_status = anticheat_status self.steam_game = steam_game self.last_launch_ts = last_launch_ts self.playtime_seconds = playtime_seconds self.select_callback = select_callback self.context_menu_manager = context_menu_manager self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self._show_context_menu) self.theme_manager = ThemeManager() self.theme = theme if theme is not None else default_styles self.current_theme_name = read_theme_from_config() # Дополнительное пространство для анимации extra_margin = 20 self.setFixedSize(card_width + extra_margin, int(card_width * 1.6) + extra_margin) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE) # Параметры анимации обводки self._borderWidth = 2 self._gradientAngle = 0.0 self._hovered = False self._focused = False # Анимации self.thickness_anim = QPropertyAnimation(self, QByteArray(b"borderWidth")) self.thickness_anim.setDuration(300) self.gradient_anim = None self.pulse_anim = None # Флаг для отслеживания подключения слота startPulseAnimation self._isPulseAnimationConnected = False # Тень shadow = QGraphicsDropShadowEffect(self) shadow.setBlurRadius(20) shadow.setColor(QColor(0, 0, 0, 150)) shadow.setOffset(0, 0) self.setGraphicsEffect(shadow) # Отступы layout = QVBoxLayout(self) layout.setContentsMargins(extra_margin // 2, extra_margin // 2, extra_margin // 2, extra_margin // 2) layout.setSpacing(5) # Контейнер обложки coverWidget = QWidget() coverWidget.setFixedSize(card_width, int(card_width * 1.2)) coverLayout = QStackedLayout(coverWidget) coverLayout.setContentsMargins(0, 0, 0, 0) coverLayout.setStackingMode(QStackedLayout.StackingMode.StackAll) # Обложка self.coverLabel = QLabel() self.coverLabel.setFixedSize(card_width, int(card_width * 1.2)) self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE) coverLayout.addWidget(self.coverLabel) # создаём слабую ссылку на label label_ref = weakref.ref(self.coverLabel) def on_cover_loaded(pixmap): label = label_ref() if label is None: return label.setPixmap(round_corners(pixmap, 15)) # асинхронная загрузка обложки (пустая строка даст placeholder внутри load_pixmap_async) load_pixmap_async(cover_path or "", card_width, int(card_width * 1.2), on_cover_loaded) # Значок избранного (звёздочка) в левом верхнем углу обложки self.favoriteLabel = ClickableLabel(coverWidget) self.favoriteLabel.setFixedSize(*self.theme.favoriteLabelSize) self.favoriteLabel.move(8, 8) self.favoriteLabel.clicked.connect(self.toggle_favorite) self.is_favorite = self.name in read_favorites() self.update_favorite_icon() self.favoriteLabel.raise_() # ProtonDB бейдж tier_text = self.getProtonDBText(protondb_tier) if tier_text: icon_filename = self.getProtonDBIconFilename(protondb_tier) icon = self.theme_manager.get_icon(icon_filename, self.current_theme_name) self.protondbLabel = ClickableLabel( tier_text, icon=icon, parent=coverWidget, icon_size=16, icon_space=3, ) self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier)) self.protondbLabel.setFixedWidth(int(card_width * 2/3)) protondb_visible = True else: self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=16, icon_space=3) self.protondbLabel.setFixedWidth(int(card_width * 2/3)) self.protondbLabel.setVisible(False) protondb_visible = False # Steam бейдж steam_icon = self.theme_manager.get_icon("steam") self.steamLabel = ClickableLabel( "Steam", icon=steam_icon, parent=coverWidget, icon_size=16, icon_space=5, ) self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.steamLabel.setFixedWidth(int(card_width * 2/3)) steam_visible = (str(steam_game).lower() == "true") self.steamLabel.setVisible(steam_visible) # Epic Games Store бейдж egs_icon = self.theme_manager.get_icon("steam") self.egsLabel = ClickableLabel( "Epic Games", icon=egs_icon, parent=coverWidget, icon_size=16, icon_space=5, change_cursor=False ) self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.egsLabel.setFixedWidth(int(card_width * 2/3)) egs_visible = (str(steam_game).lower() == "epic") self.egsLabel.setVisible(egs_visible) # PortProton badge portproton_icon = self.theme_manager.get_icon("ppqt-tray") self.portprotonLabel = ClickableLabel( "PortProton", icon=portproton_icon, parent=coverWidget, icon_size=16, icon_space=5, change_cursor=False ) self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.portprotonLabel.setFixedWidth(int(card_width * 2/3)) portproton_visible = (str(steam_game).lower() == "false") self.portprotonLabel.setVisible(portproton_visible) # WeAntiCheatYet бейдж anticheat_text = self.getAntiCheatText(anticheat_status) if anticheat_text: icon_filename = self.getAntiCheatIconFilename(anticheat_status) icon = self.theme_manager.get_icon(icon_filename, self.current_theme_name) self.anticheatLabel = ClickableLabel( anticheat_text, icon=icon, parent=coverWidget, icon_size=16, icon_space=3, ) self.anticheatLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.anticheatLabel.setFixedWidth(int(card_width * 2/3)) anticheat_visible = True else: self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=16, icon_space=3) self.anticheatLabel.setFixedWidth(int(card_width * 2/3)) self.anticheatLabel.setVisible(False) anticheat_visible = False # Расположение бейджей right_margin = 8 badge_spacing = 5 top_y = 10 badge_y_positions = [] badge_width = int(card_width * 2/3) if steam_visible: steam_x = card_width - badge_width - right_margin self.steamLabel.move(steam_x, top_y) badge_y_positions.append(top_y + self.steamLabel.height()) if egs_visible: egs_x = card_width - badge_width - right_margin egs_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y self.egsLabel.move(egs_x, egs_y) badge_y_positions.append(egs_y + self.egsLabel.height()) if portproton_visible: portproton_x = card_width - badge_width - right_margin portproton_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y self.portprotonLabel.move(portproton_x, portproton_y) badge_y_positions.append(portproton_y + self.portprotonLabel.height()) if protondb_visible: protondb_x = card_width - badge_width - right_margin protondb_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y self.protondbLabel.move(protondb_x, protondb_y) badge_y_positions.append(protondb_y + self.protondbLabel.height()) if anticheat_visible: anticheat_x = card_width - badge_width - right_margin anticheat_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y self.anticheatLabel.move(anticheat_x, anticheat_y) self.anticheatLabel.raise_() self.protondbLabel.raise_() self.portprotonLabel.raise_() self.egsLabel.raise_() self.steamLabel.raise_() self.protondbLabel.clicked.connect(self.open_protondb_report) self.steamLabel.clicked.connect(self.open_steam_page) self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page) layout.addWidget(coverWidget) # Название игры nameLabel = QLabel(name) nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE) layout.addWidget(nameLabel) def _show_context_menu(self, pos): """Delegate context menu display to ContextMenuManager.""" if self.context_menu_manager: self.context_menu_manager.show_context_menu(self, pos) @staticmethod def getAntiCheatText(status: str) -> str: if not status: return "" translations = { "supported": _("Supported"), "running": _("Running"), "planned": _("Planned"), "broken": _("Broken"), "denied": _("Denied") } return translations.get(status.lower(), "") @staticmethod def getAntiCheatIconFilename(status: str) -> str: status = status.lower() if status in ("supported", "running"): return "platinum-gold" elif status in ("denied", "planned", "broken"): return "broken" return "" @staticmethod def getProtonDBText(tier: str) -> str: if not tier: return "" translations = { "platinum": _("Platinum"), "gold": _("Gold"), "silver": _("Silver"), "bronze": _("Bronze"), "borked": _("Broken"), "pending": _("Pending") } return translations.get(tier.lower(), "") @staticmethod def getProtonDBIconFilename(tier: str) -> str: tier = tier.lower() if tier in ("platinum", "gold"): return "platinum-gold" elif tier in ("silver", "bronze"): return "silver-bronze" elif tier in ("borked", "pending"): return "broken" return "" def open_protondb_report(self): url = QUrl(f"https://www.protondb.com/app/{self.appid}") QDesktopServices.openUrl(url) def open_steam_page(self): url = QUrl(f"https://steamcommunity.com/app/{self.appid}") QDesktopServices.openUrl(url) def open_weanticheatyet_page(self): formatted_name = self.name.lower().replace(" ", "-") url = QUrl(f"https://areweanticheatyet.com/game/{formatted_name}") QDesktopServices.openUrl(url) def update_favorite_icon(self): if self.is_favorite: self.favoriteLabel.setText("★") else: self.favoriteLabel.setText("☆") self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE) def toggle_favorite(self): favorites = read_favorites() if self.is_favorite: if self.name in favorites: favorites.remove(self.name) self.is_favorite = False else: if self.name not in favorites: favorites.append(self.name) self.is_favorite = True save_favorites(favorites) self.update_favorite_icon() def getBorderWidth(self) -> int: return self._borderWidth def setBorderWidth(self, value: int): if self._borderWidth != value: self._borderWidth = value self.borderWidthChanged.emit() self.update() def getGradientAngle(self) -> float: return self._gradientAngle def setGradientAngle(self, value: float): if self._gradientAngle != value: self._gradientAngle = value self.gradientAngleChanged.emit() self.update() borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged)) gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged)) def paintEvent(self, event): super().paintEvent(event) painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) pen = QPen() pen.setWidth(self._borderWidth) if self._hovered or self._focused: center = self.rect().center() gradient = QConicalGradient(center, self._gradientAngle) gradient.setColorAt(0, QColor("#00fff5")) gradient.setColorAt(0.33, QColor("#FF5733")) gradient.setColorAt(0.66, QColor("#9B59B6")) gradient.setColorAt(1, QColor("#00fff5")) pen.setBrush(QBrush(gradient)) else: pen.setColor(QColor(0, 0, 0, 0)) painter.setPen(pen) radius = 18 bw = round(self._borderWidth / 2) rect = self.rect().adjusted(bw, bw, -bw, -bw) painter.drawRoundedRect(rect, radius, radius) def startPulseAnimation(self): if not (self._hovered or self._focused): return if self.pulse_anim: self.pulse_anim.stop() self.pulse_anim = QPropertyAnimation(self, QByteArray(b"borderWidth")) self.pulse_anim.setDuration(800) self.pulse_anim.setLoopCount(0) self.pulse_anim.setKeyValueAt(0, 8) self.pulse_anim.setKeyValueAt(0.5, 10) self.pulse_anim.setKeyValueAt(1, 8) self.pulse_anim.start() def enterEvent(self, event): self._hovered = True self.thickness_anim.stop() if self._isPulseAnimationConnected: self.thickness_anim.finished.disconnect(self.startPulseAnimation) self._isPulseAnimationConnected = False self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.OutBack)) self.thickness_anim.setStartValue(self._borderWidth) self.thickness_anim.setEndValue(8) self.thickness_anim.finished.connect(self.startPulseAnimation) self._isPulseAnimationConnected = True self.thickness_anim.start() if self.gradient_anim: self.gradient_anim.stop() self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle")) self.gradient_anim.setDuration(3000) self.gradient_anim.setStartValue(360) self.gradient_anim.setEndValue(0) self.gradient_anim.setLoopCount(-1) self.gradient_anim.start() super().enterEvent(event) def leaveEvent(self, event): self._hovered = False if not self._focused: # Сохраняем анимацию, если есть фокус if self.gradient_anim: self.gradient_anim.stop() self.gradient_anim = None self.thickness_anim.stop() if self._isPulseAnimationConnected: self.thickness_anim.finished.disconnect(self.startPulseAnimation) self._isPulseAnimationConnected = False if self.pulse_anim: self.pulse_anim.stop() self.pulse_anim = None self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.InBack)) self.thickness_anim.setStartValue(self._borderWidth) self.thickness_anim.setEndValue(2) self.thickness_anim.start() super().leaveEvent(event) def focusInEvent(self, event): self._focused = True self.thickness_anim.stop() if self._isPulseAnimationConnected: self.thickness_anim.finished.disconnect(self.startPulseAnimation) self._isPulseAnimationConnected = False self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.OutBack)) self.thickness_anim.setStartValue(self._borderWidth) self.thickness_anim.setEndValue(12) self.thickness_anim.finished.connect(self.startPulseAnimation) self._isPulseAnimationConnected = True self.thickness_anim.start() if self.gradient_anim: self.gradient_anim.stop() self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle")) self.gradient_anim.setDuration(3000) self.gradient_anim.setStartValue(360) self.gradient_anim.setEndValue(0) self.gradient_anim.setLoopCount(-1) self.gradient_anim.start() super().focusInEvent(event) def focusOutEvent(self, event): self._focused = False if not self._hovered: # Сохраняем анимацию, если есть наведение if self.gradient_anim: self.gradient_anim.stop() self.gradient_anim = None self.thickness_anim.stop() if self._isPulseAnimationConnected: self.thickness_anim.finished.disconnect(self.startPulseAnimation) self._isPulseAnimationConnected = False if self.pulse_anim: self.pulse_anim.stop() self.pulse_anim = None self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.InBack)) self.thickness_anim.setStartValue(self._borderWidth) self.thickness_anim.setEndValue(2) self.thickness_anim.start() super().focusOutEvent(event) def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self.select_callback( self.name, self.description, self.cover_path, self.appid, self.controller_support, self.exec_line, self.last_launch, self.formatted_playtime, self.protondb_tier, self.steam_game, self.anticheat_status ) super().mousePressEvent(event) def keyPressEvent(self, event): if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): self.select_callback( self.name, self.description, self.cover_path, self.appid, self.controller_support, self.exec_line, self.last_launch, self.formatted_playtime, self.protondb_tier, self.steam_game, self.anticheat_status ) else: super().keyPressEvent(event)