507 lines
20 KiB
Python
507 lines
20 KiB
Python
from PySide6.QtGui import QPainter, QColor, QDesktopServices
|
|
from PySide6.QtCore import Signal, Property, Qt, 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, read_display_filter
|
|
from portprotonqt.theme_manager import ThemeManager
|
|
from portprotonqt.config_utils import read_theme_from_config
|
|
from portprotonqt.custom_widgets import ClickableLabel
|
|
from portprotonqt.portproton_api import PortProtonAPI
|
|
from portprotonqt.downloader import Downloader
|
|
from portprotonqt.animations import GameCardAnimations
|
|
from typing import cast
|
|
|
|
class GameCard(QFrame):
|
|
borderWidthChanged = Signal()
|
|
gradientAngleChanged = Signal()
|
|
scaleChanged = Signal()
|
|
editShortcutRequested = Signal(str, str, str)
|
|
deleteGameRequested = Signal(str, str)
|
|
addToMenuRequested = Signal(str, str)
|
|
removeFromMenuRequested = Signal(str)
|
|
addToDesktopRequested = Signal(str, str)
|
|
removeFromDesktopRequested = Signal(str)
|
|
addToSteamRequested = Signal(str, str, str)
|
|
removeFromSteamRequested = Signal(str, str)
|
|
openGameFolderRequested = Signal(str, str)
|
|
hoverChanged = Signal(str, bool)
|
|
focusChanged = Signal(str, bool)
|
|
|
|
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, game_source,
|
|
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.game_source = game_source
|
|
self.last_launch_ts = last_launch_ts
|
|
self.playtime_seconds = playtime_seconds
|
|
self.base_card_width = card_width
|
|
self.base_pixmap = None
|
|
self.base_font_size = None
|
|
|
|
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.display_filter = read_display_filter()
|
|
self.current_theme_name = read_theme_from_config()
|
|
self.downloader = Downloader(max_workers=4)
|
|
self.portproton_api = PortProtonAPI(self.downloader)
|
|
|
|
self.steam_visible = (str(game_source).lower() == "steam" and self.display_filter in ("all", "favorites"))
|
|
self.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
|
|
self.portproton_visible = (str(game_source).lower() == "portproton" and self.display_filter in ("all", "favorites"))
|
|
|
|
self.base_extra_margin = 20
|
|
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
|
|
|
|
self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"]
|
|
self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]
|
|
self._scale = self.theme.GAME_CARD_ANIMATION["default_scale"]
|
|
self._hovered = False
|
|
self._focused = False
|
|
|
|
self.animations = GameCardAnimations(self, self.theme)
|
|
self.animations.setup_animations()
|
|
|
|
self.shadow = QGraphicsDropShadowEffect(self)
|
|
self.shadow.setBlurRadius(20)
|
|
self.shadow.setColor(QColor(0, 0, 0, 150))
|
|
self.shadow.setOffset(0, 0)
|
|
self.setGraphicsEffect(self.shadow)
|
|
|
|
self.layout_ = QVBoxLayout(self)
|
|
self.layout_.setSpacing(5)
|
|
self.layout_.setContentsMargins(self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2)
|
|
|
|
self.coverWidget = QWidget()
|
|
coverLayout = QStackedLayout(self.coverWidget)
|
|
coverLayout.setContentsMargins(0, 0, 0, 0)
|
|
coverLayout.setStackingMode(QStackedLayout.StackingMode.StackAll)
|
|
|
|
self.coverLabel = QLabel()
|
|
self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE)
|
|
coverLayout.addWidget(self.coverLabel)
|
|
|
|
load_pixmap_async(cover_path or "", self.base_card_width, int(self.base_card_width * 1.2), self.on_cover_loaded)
|
|
|
|
self.favoriteLabel = ClickableLabel(self.coverWidget)
|
|
self.favoriteLabel.clicked.connect(self.toggle_favorite)
|
|
self.is_favorite = self.name in read_favorites()
|
|
self.update_favorite_icon()
|
|
self.favoriteLabel.raise_()
|
|
|
|
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=self.coverWidget,
|
|
font_scale_factor=0.06
|
|
)
|
|
self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier))
|
|
self.protondbLabel.setCardWidth(card_width)
|
|
else:
|
|
self.protondbLabel = ClickableLabel("", parent=self.coverWidget)
|
|
self.protondbLabel.setVisible(False)
|
|
|
|
steam_icon = self.theme_manager.get_icon("steam")
|
|
self.steamLabel = ClickableLabel(
|
|
"Steam",
|
|
icon=steam_icon,
|
|
parent=self.coverWidget,
|
|
font_scale_factor=0.06
|
|
)
|
|
self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
|
self.steamLabel.setCardWidth(card_width)
|
|
self.steamLabel.setVisible(self.steam_visible)
|
|
|
|
egs_icon = self.theme_manager.get_icon("epic_games")
|
|
self.egsLabel = ClickableLabel(
|
|
"Epic Games",
|
|
icon=egs_icon,
|
|
parent=self.coverWidget,
|
|
font_scale_factor=0.06,
|
|
change_cursor=False
|
|
)
|
|
self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
|
self.egsLabel.setCardWidth(card_width)
|
|
self.egsLabel.setVisible(self.egs_visible)
|
|
|
|
portproton_icon = self.theme_manager.get_icon("portproton")
|
|
self.portprotonLabel = ClickableLabel(
|
|
"PortProton",
|
|
icon=portproton_icon,
|
|
parent=self.coverWidget,
|
|
font_scale_factor=0.06
|
|
)
|
|
self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
|
self.portprotonLabel.setCardWidth(card_width)
|
|
self.portprotonLabel.setVisible(self.portproton_visible)
|
|
self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic)
|
|
|
|
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=self.coverWidget,
|
|
font_scale_factor=0.06
|
|
)
|
|
self.anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status))
|
|
self.anticheatLabel.setCardWidth(card_width)
|
|
else:
|
|
self.anticheatLabel = ClickableLabel("", parent=self.coverWidget)
|
|
self.anticheatLabel.setVisible(False)
|
|
|
|
self.protondbLabel.clicked.connect(self.open_protondb_report)
|
|
self.steamLabel.clicked.connect(self.open_steam_page)
|
|
self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page)
|
|
|
|
self.layout_.addWidget(self.coverWidget)
|
|
|
|
self.nameLabel = QLabel(name)
|
|
self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
|
|
self.layout_.addWidget(self.nameLabel)
|
|
|
|
font_size = self.nameLabel.font().pointSizeF()
|
|
self.base_font_size = font_size if font_size > 0 else 10.0
|
|
|
|
self.update_scale()
|
|
|
|
# Force initial layout update to ensure correct geometry
|
|
self.updateGeometry()
|
|
parent = self.parentWidget()
|
|
if parent:
|
|
layout = parent.layout()
|
|
if layout:
|
|
layout.invalidate()
|
|
parent.updateGeometry()
|
|
|
|
def on_cover_loaded(self, pixmap):
|
|
self.base_pixmap = pixmap
|
|
self.update_cover_pixmap()
|
|
|
|
def update_cover_pixmap(self):
|
|
if self.base_pixmap:
|
|
scaled_width = int(self.base_card_width * self._scale)
|
|
scaled_pixmap = self.base_pixmap.scaled(scaled_width, int(scaled_width * 1.2), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
|
|
rounded_pixmap = round_corners(scaled_pixmap, int(15 * self._scale))
|
|
self.coverLabel.setPixmap(rounded_pixmap)
|
|
|
|
def _position_badges(self, current_width):
|
|
right_margin = int(8 * self._scale)
|
|
badge_spacing = int(current_width * 0.02)
|
|
top_y = int(10 * self._scale)
|
|
badge_y_positions = []
|
|
badge_width = int(current_width * 2/3)
|
|
|
|
badges = [
|
|
(self.steam_visible, self.steamLabel),
|
|
(self.egs_visible, self.egsLabel),
|
|
(self.portproton_visible, self.portprotonLabel),
|
|
(bool(self.getProtonDBText(self.protondb_tier)), self.protondbLabel),
|
|
(bool(self.getAntiCheatText(self.anticheat_status)), self.anticheatLabel),
|
|
]
|
|
|
|
for is_visible, badge in badges:
|
|
if is_visible:
|
|
badge_x = current_width - badge_width - right_margin
|
|
badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
|
badge.move(int(badge_x), int(badge_y))
|
|
badge_y_positions.append(badge_y + badge.height())
|
|
|
|
self.anticheatLabel.raise_()
|
|
self.protondbLabel.raise_()
|
|
self.portprotonLabel.raise_()
|
|
self.egsLabel.raise_()
|
|
self.steamLabel.raise_()
|
|
|
|
def update_scale(self):
|
|
scaled_width = int(self.base_card_width * self._scale)
|
|
scaled_height = int(self.base_card_width * 1.6 * self._scale)
|
|
scaled_extra = int(self.base_extra_margin * self._scale)
|
|
self.setFixedSize(scaled_width + scaled_extra, scaled_height + scaled_extra)
|
|
self.layout_.setContentsMargins(scaled_extra // 2, scaled_extra // 2, scaled_extra // 2, scaled_extra // 2)
|
|
|
|
self.coverWidget.setFixedSize(scaled_width, int(scaled_width * 1.2))
|
|
self.coverLabel.setFixedSize(scaled_width, int(scaled_width * 1.2))
|
|
|
|
self.update_cover_pixmap()
|
|
|
|
favorite_size = (int(self.theme.favoriteLabelSize[0] * self._scale), int(self.theme.favoriteLabelSize[1] * self._scale))
|
|
self.favoriteLabel.setFixedSize(*favorite_size)
|
|
self.favoriteLabel.move(int(8 * self._scale), int(8 * self._scale))
|
|
|
|
badge_width = int(scaled_width * 2/3)
|
|
icon_size = int(scaled_width * 0.06)
|
|
icon_space = int(scaled_width * 0.012)
|
|
for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]:
|
|
if label is not None:
|
|
label.setFixedWidth(badge_width)
|
|
label.setIconSize(icon_size, icon_space)
|
|
label.setCardWidth(scaled_width)
|
|
|
|
self._position_badges(scaled_width)
|
|
|
|
if self.base_font_size is not None:
|
|
font = self.nameLabel.font()
|
|
new_font_size = self.base_font_size * self._scale
|
|
if new_font_size > 0:
|
|
font.setPointSizeF(new_font_size)
|
|
self.nameLabel.setFont(font)
|
|
|
|
self.shadow.setBlurRadius(int(20 * self._scale))
|
|
|
|
self.updateGeometry()
|
|
self.update()
|
|
|
|
# Ensure parent layout is updated safely
|
|
parent = self.parentWidget()
|
|
if parent:
|
|
layout = parent.layout()
|
|
if layout:
|
|
layout.invalidate()
|
|
layout.activate()
|
|
layout.update()
|
|
parent.updateGeometry()
|
|
|
|
def update_card_size(self, new_width: int):
|
|
self.base_card_width = new_width
|
|
load_pixmap_async(self.cover_path or "", new_width, int(new_width * 1.2), self.on_cover_loaded)
|
|
self.update_scale()
|
|
|
|
def update_badge_visibility(self, display_filter: str):
|
|
self.display_filter = display_filter
|
|
self.steam_visible = (str(self.game_source).lower() == "steam" and self.display_filter in ("all", "favorites"))
|
|
self.egs_visible = (str(self.game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
|
|
self.portproton_visible = (str(self.game_source).lower() == "portproton" and self.display_filter in ("all", "favorites"))
|
|
protondb_visible = bool(self.getProtonDBText(self.protondb_tier))
|
|
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status))
|
|
|
|
self.steamLabel.setVisible(self.steam_visible)
|
|
self.egsLabel.setVisible(self.egs_visible)
|
|
self.portprotonLabel.setVisible(self.portproton_visible)
|
|
self.protondbLabel.setVisible(protondb_visible)
|
|
self.anticheatLabel.setVisible(anticheat_visible)
|
|
|
|
scaled_width = int(self.base_card_width * self._scale)
|
|
self._position_badges(scaled_width)
|
|
|
|
# Update layout after visibility changes
|
|
self.updateGeometry()
|
|
parent = self.parentWidget()
|
|
if parent:
|
|
layout = parent.layout()
|
|
if layout:
|
|
layout.invalidate()
|
|
layout.update()
|
|
parent.updateGeometry()
|
|
|
|
def _show_context_menu(self, pos):
|
|
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"):
|
|
return "ac_supported"
|
|
elif status in ("running"):
|
|
return "ac_running"
|
|
elif status in ("planned"):
|
|
return "ac_planned"
|
|
elif status in ("denied"):
|
|
return "ac_denied"
|
|
elif status in ("broken"):
|
|
return "ac_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_portproton_forum_topic(self):
|
|
result = self.portproton_api.get_forum_topic_slug(self.name)
|
|
base_url = "https://linux-gaming.ru/"
|
|
if result.startswith("search?q="):
|
|
url = QUrl(f"{base_url}{result}")
|
|
else:
|
|
url = QUrl(f"{base_url}t/{result}")
|
|
QDesktopServices.openUrl(url)
|
|
|
|
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()
|
|
|
|
def getScale(self) -> float:
|
|
return self._scale
|
|
|
|
def setScale(self, value: float):
|
|
if self._scale != value:
|
|
self._scale = value
|
|
self.update_scale()
|
|
self.scaleChanged.emit()
|
|
|
|
borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged))
|
|
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged))
|
|
scale = Property(float, getScale, setScale, None, "", notify=cast(Callable[[], None], scaleChanged))
|
|
|
|
def paintEvent(self, event):
|
|
super().paintEvent(event)
|
|
self.animations.paint_border(QPainter(self))
|
|
|
|
def enterEvent(self, event):
|
|
self.animations.handle_enter_event()
|
|
super().enterEvent(event)
|
|
|
|
def leaveEvent(self, event):
|
|
self.animations.handle_leave_event()
|
|
super().leaveEvent(event)
|
|
|
|
def focusInEvent(self, event):
|
|
self.animations.handle_focus_in_event()
|
|
super().focusInEvent(event)
|
|
|
|
def focusOutEvent(self, event):
|
|
self.animations.handle_focus_out_event()
|
|
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.game_source,
|
|
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.game_source,
|
|
self.anticheat_status
|
|
)
|
|
else:
|
|
super().keyPressEvent(event)
|