PortProtonQt/portprotonqt/game_card.py
2025-06-03 14:26:25 +05:00

480 lines
20 KiB
Python

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:
# QLabel уже удалён — ничего не делаем
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)) # Устанавливаем ширину в 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)) # Устанавливаем ширину в 2/3 ширины карточки
steam_visible = (str(steam_game).lower() == "true")
self.steamLabel.setVisible(steam_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)) # Устанавливаем ширину в 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 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.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)