feat: added scale animation to game card hover and focus

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
2025-08-28 10:48:00 +05:00
parent 37254b89f1
commit 8f54f4814c
4 changed files with 371 additions and 243 deletions

View File

@@ -26,14 +26,23 @@ class GameCardAnimations:
self.theme = theme if theme is not None else default_styles self.theme = theme if theme is not None else default_styles
self.thickness_anim: QPropertyAnimation | None = None self.thickness_anim: QPropertyAnimation | None = None
self.gradient_anim: QPropertyAnimation | None = None self.gradient_anim: QPropertyAnimation | None = None
self.scale_anim: QPropertyAnimation | None = None
self.pulse_anim: QPropertyAnimation | None = None self.pulse_anim: QPropertyAnimation | None = None
self._isPulseAnimationConnected = False self._isPulseAnimationConnected = False
def setup_animations(self): def setup_animations(self):
"""Initialize animation properties.""" """Initialize animation properties based on theme."""
self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth")) self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"]) self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if animation_type == "gradient":
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
elif animation_type == "scale":
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
def start_pulse_animation(self): def start_pulse_animation(self):
"""Start pulse animation for border width when hovered or focused.""" """Start pulse animation for border width when hovered or focused."""
if not (self.game_card._hovered or self.game_card._focused): if not (self.game_card._hovered or self.game_card._focused):
@@ -57,6 +66,8 @@ class GameCardAnimations:
if not self.thickness_anim: if not self.thickness_anim:
self.setup_animations() self.setup_animations()
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if self.thickness_anim: if self.thickness_anim:
self.thickness_anim.stop() self.thickness_anim.stop()
if self._isPulseAnimationConnected: if self._isPulseAnimationConnected:
@@ -69,23 +80,44 @@ class GameCardAnimations:
self._isPulseAnimationConnected = True self._isPulseAnimationConnected = True
self.thickness_anim.start() self.thickness_anim.start()
if self.gradient_anim: if animation_type == "gradient":
self.gradient_anim.stop() if self.gradient_anim:
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle")) self.gradient_anim.stop()
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"]) self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]) self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"]) self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
self.gradient_anim.setLoopCount(-1) self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
self.gradient_anim.start() self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start()
elif animation_type == "scale":
if self.scale_anim:
self.scale_anim.stop()
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_scale"])
self.scale_anim.start()
def handle_leave_event(self): def handle_leave_event(self):
"""Handle mouse leave event animations.""" """Handle mouse leave event animations."""
self.game_card._hovered = False self.game_card._hovered = False
self.game_card.hoverChanged.emit(self.game_card.name, False) self.game_card.hoverChanged.emit(self.game_card.name, False)
if not self.game_card._focused: if not self.game_card._focused:
if self.gradient_anim: animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
self.gradient_anim.stop() if animation_type == "gradient":
self.gradient_anim = None if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = None
elif animation_type == "scale":
if self.scale_anim:
self.scale_anim.stop()
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
self.scale_anim.start()
if self.pulse_anim: if self.pulse_anim:
self.pulse_anim.stop() self.pulse_anim.stop()
self.pulse_anim = None self.pulse_anim = None
@@ -108,6 +140,8 @@ class GameCardAnimations:
if not self.thickness_anim: if not self.thickness_anim:
self.setup_animations() self.setup_animations()
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if self.thickness_anim: if self.thickness_anim:
self.thickness_anim.stop() self.thickness_anim.stop()
if self._isPulseAnimationConnected: if self._isPulseAnimationConnected:
@@ -120,23 +154,44 @@ class GameCardAnimations:
self._isPulseAnimationConnected = True self._isPulseAnimationConnected = True
self.thickness_anim.start() self.thickness_anim.start()
if self.gradient_anim: if animation_type == "gradient":
self.gradient_anim.stop() if self.gradient_anim:
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle")) self.gradient_anim.stop()
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"]) self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]) self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"]) self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
self.gradient_anim.setLoopCount(-1) self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
self.gradient_anim.start() self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start()
elif animation_type == "scale":
if self.scale_anim:
self.scale_anim.stop()
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_scale"])
self.scale_anim.start()
def handle_focus_out_event(self): def handle_focus_out_event(self):
"""Handle focus out event animations.""" """Handle focus out event animations."""
self.game_card._focused = False self.game_card._focused = False
self.game_card.focusChanged.emit(self.game_card.name, False) self.game_card.focusChanged.emit(self.game_card.name, False)
if not self.game_card._hovered: if not self.game_card._hovered:
if self.gradient_anim: animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
self.gradient_anim.stop() if animation_type == "gradient":
self.gradient_anim = None if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = None
elif animation_type == "scale":
if self.scale_anim:
self.scale_anim.stop()
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
self.scale_anim.start()
if self.pulse_anim: if self.pulse_anim:
self.pulse_anim.stop() self.pulse_anim.stop()
self.pulse_anim = None self.pulse_anim = None
@@ -157,7 +212,8 @@ class GameCardAnimations:
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
pen = QPen() pen = QPen()
pen.setWidth(self.game_card._borderWidth) pen.setWidth(self.game_card._borderWidth)
if self.game_card._hovered or self.game_card._focused: animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if (self.game_card._hovered or self.game_card._focused) and animation_type == "gradient":
center = self.game_card.rect().center() center = self.game_card.rect().center()
gradient = QConicalGradient(center, self.game_card._gradientAngle) gradient = QConicalGradient(center, self.game_card._gradientAngle)
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]: for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
@@ -166,11 +222,11 @@ class GameCardAnimations:
else: else:
pen.setColor(QColor(0, 0, 0, 0)) pen.setColor(QColor(0, 0, 0, 0))
painter.setPen(pen) painter.setPen(pen)
radius = 18 radius = 18 * self.game_card._scale
bw = round(self.game_card._borderWidth / 2) bw = round(self.game_card._borderWidth / 2)
rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw) rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw)
if rect.isEmpty(): if rect.isEmpty():
return # Avoid drawing invalid rect return
painter.drawRoundedRect(rect, radius, radius) painter.drawRoundedRect(rect, radius, radius)
class DetailPageAnimations: class DetailPageAnimations:
@@ -284,15 +340,15 @@ class DetailPageAnimations:
logger.debug("Original effect already deleted") logger.debug("Original effect already deleted")
cleanup_callback() cleanup_callback()
animation.finished.connect(restore_and_cleanup) animation.finished.connect(restore_and_cleanup)
animation.finished.connect(opacity_effect.deleteLater) # Clean up effect animation.finished.connect(opacity_effect.deleteLater)
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]: elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500) duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500)
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")]) easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
end_pos = { end_pos = {
"slide_left": QPoint(-self.main_window.width(), 0), # Exit to left (opposite of entry) "slide_left": QPoint(-self.main_window.width(), 0),
"slide_right": QPoint(self.main_window.width(), 0), # Exit to right "slide_right": QPoint(self.main_window.width(), 0),
"slide_up": QPoint(0, self.main_window.height()), # Exit downward "slide_up": QPoint(0, self.main_window.height()),
"slide_down": QPoint(0, -self.main_window.height()) # Exit upward "slide_down": QPoint(0, -self.main_window.height())
}[animation_type] }[animation_type]
animation = QPropertyAnimation(detail_page, QByteArray(b"pos")) animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
animation.setDuration(duration) animation.setDuration(duration)
@@ -325,4 +381,4 @@ class DetailPageAnimations:
except Exception as e: except Exception as e:
logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True) logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True)
self.animations.pop(detail_page, None) self.animations.pop(detail_page, None)
cleanup_callback() # Fallback to cleanup if animation setup fails cleanup_callback()

View File

@@ -12,29 +12,27 @@ from portprotonqt.custom_widgets import ClickableLabel
from portprotonqt.portproton_api import PortProtonAPI from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from portprotonqt.animations import GameCardAnimations from portprotonqt.animations import GameCardAnimations
import weakref
from typing import cast from typing import cast
class GameCard(QFrame): class GameCard(QFrame):
borderWidthChanged = Signal() borderWidthChanged = Signal()
gradientAngleChanged = Signal() gradientAngleChanged = Signal()
# Signals for context menu actions scaleChanged = Signal()
editShortcutRequested = Signal(str, str, str) # name, exec_line, cover_path editShortcutRequested = Signal(str, str, str)
deleteGameRequested = Signal(str, str) # name, exec_line deleteGameRequested = Signal(str, str)
addToMenuRequested = Signal(str, str) # name, exec_line addToMenuRequested = Signal(str, str)
removeFromMenuRequested = Signal(str) # name removeFromMenuRequested = Signal(str)
addToDesktopRequested = Signal(str, str) # name, exec_line addToDesktopRequested = Signal(str, str)
removeFromDesktopRequested = Signal(str) # name removeFromDesktopRequested = Signal(str)
addToSteamRequested = Signal(str, str, str) # name, exec_line, cover_path addToSteamRequested = Signal(str, str, str)
removeFromSteamRequested = Signal(str, str) # name, exec_line removeFromSteamRequested = Signal(str, str)
openGameFolderRequested = Signal(str, str) # name, exec_line openGameFolderRequested = Signal(str, str)
hoverChanged = Signal(str, bool) hoverChanged = Signal(str, bool)
focusChanged = Signal(str, bool) focusChanged = Signal(str, bool)
def __init__(self, name, description, cover_path, appid, controller_support, 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, game_source, 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): select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None):
super().__init__(parent) super().__init__(parent)
self.name = name self.name = name
self.description = description self.description = description
@@ -49,7 +47,9 @@ class GameCard(QFrame):
self.game_source = game_source self.game_source = game_source
self.last_launch_ts = last_launch_ts self.last_launch_ts = last_launch_ts
self.playtime_seconds = playtime_seconds self.playtime_seconds = playtime_seconds
self.card_width = card_width self.base_card_width = card_width
self.base_pixmap = None
self.base_font_size = None
self.select_callback = select_callback self.select_callback = select_callback
self.context_menu_manager = context_menu_manager self.context_menu_manager = context_menu_manager
@@ -67,75 +67,46 @@ class GameCard(QFrame):
self.egs_visible = (str(game_source).lower() == "epic" 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.portproton_visible = (str(game_source).lower() == "portproton" and self.display_filter in ("all", "favorites"))
# Дополнительное пространство для анимации self.base_extra_margin = 20
extra_margin = 20
self.setFixedSize(card_width + extra_margin, int(card_width * 1.6) + extra_margin)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE) self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
# Параметры анимации обводки
self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"] self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"]
self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"] self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]
self._scale = self.theme.GAME_CARD_ANIMATION["default_scale"]
self._hovered = False self._hovered = False
self._focused = False self._focused = False
# Анимации
self.animations = GameCardAnimations(self, self.theme) self.animations = GameCardAnimations(self, self.theme)
self.animations.setup_animations() self.animations.setup_animations()
# Тень self.shadow = QGraphicsDropShadowEffect(self)
shadow = QGraphicsDropShadowEffect(self) self.shadow.setBlurRadius(20)
shadow.setBlurRadius(20) self.shadow.setColor(QColor(0, 0, 0, 150))
shadow.setColor(QColor(0, 0, 0, 150)) self.shadow.setOffset(0, 0)
shadow.setOffset(0, 0) self.setGraphicsEffect(self.shadow)
self.setGraphicsEffect(shadow)
# Отступы self.layout_ = QVBoxLayout(self)
layout = QVBoxLayout(self) self.layout_.setSpacing(5)
layout.setContentsMargins(extra_margin // 2, extra_margin // 2, extra_margin // 2, extra_margin // 2) self.layout_.setContentsMargins(self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2)
layout.setSpacing(5)
# Контейнер обложки self.coverWidget = QWidget()
coverWidget = QWidget() coverLayout = QStackedLayout(self.coverWidget)
coverWidget.setFixedSize(card_width, int(card_width * 1.2))
coverLayout = QStackedLayout(coverWidget)
coverLayout.setContentsMargins(0, 0, 0, 0) coverLayout.setContentsMargins(0, 0, 0, 0)
coverLayout.setStackingMode(QStackedLayout.StackingMode.StackAll) coverLayout.setStackingMode(QStackedLayout.StackingMode.StackAll)
# Обложка
self.coverLabel = QLabel() self.coverLabel = QLabel()
self.coverLabel.setFixedSize(card_width, int(card_width * 1.2))
self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE) self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE)
coverLayout.addWidget(self.coverLabel) coverLayout.addWidget(self.coverLabel)
# создаём слабую ссылку на label load_pixmap_async(cover_path or "", self.base_card_width, int(self.base_card_width * 1.2), self.on_cover_loaded)
label_ref = weakref.ref(self.coverLabel)
def on_cover_loaded(pixmap): self.favoriteLabel = ClickableLabel(self.coverWidget)
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.favoriteLabel.clicked.connect(self.toggle_favorite)
self.is_favorite = self.name in read_favorites() self.is_favorite = self.name in read_favorites()
self.update_favorite_icon() self.update_favorite_icon()
self.favoriteLabel.raise_() self.favoriteLabel.raise_()
# Определяем общие параметры для бейджей
badge_width = int(card_width * 2/3)
icon_size = int(card_width * 0.06) # 6% от ширины карточки
icon_space = int(card_width * 0.012) # 1.2% от ширины карточки
font_scale_factor = 0.06 # Шрифт будет 6% от card_width
# ProtonDB бейдж
tier_text = self.getProtonDBText(protondb_tier) tier_text = self.getProtonDBText(protondb_tier)
if tier_text: if tier_text:
icon_filename = self.getProtonDBIconFilename(protondb_tier) icon_filename = self.getProtonDBIconFilename(protondb_tier)
@@ -143,67 +114,50 @@ class GameCard(QFrame):
self.protondbLabel = ClickableLabel( self.protondbLabel = ClickableLabel(
tier_text, tier_text,
icon=icon, icon=icon,
parent=coverWidget, parent=self.coverWidget,
icon_size=icon_size, font_scale_factor=0.06
icon_space=icon_space,
font_scale_factor=font_scale_factor
) )
self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier)) self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier))
self.protondbLabel.setFixedWidth(badge_width)
self.protondbLabel.setCardWidth(card_width) self.protondbLabel.setCardWidth(card_width)
else: else:
self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space) self.protondbLabel = ClickableLabel("", parent=self.coverWidget)
self.protondbLabel.setFixedWidth(badge_width)
self.protondbLabel.setVisible(False) self.protondbLabel.setVisible(False)
# Steam бейдж
steam_icon = self.theme_manager.get_icon("steam") steam_icon = self.theme_manager.get_icon("steam")
self.steamLabel = ClickableLabel( self.steamLabel = ClickableLabel(
"Steam", "Steam",
icon=steam_icon, icon=steam_icon,
parent=coverWidget, parent=self.coverWidget,
icon_size=icon_size, font_scale_factor=0.06
icon_space=icon_space,
font_scale_factor=font_scale_factor
) )
self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
self.steamLabel.setFixedWidth(badge_width)
self.steamLabel.setCardWidth(card_width) self.steamLabel.setCardWidth(card_width)
self.steamLabel.setVisible(self.steam_visible) self.steamLabel.setVisible(self.steam_visible)
# Epic Games Store бейдж
egs_icon = self.theme_manager.get_icon("epic_games") egs_icon = self.theme_manager.get_icon("epic_games")
self.egsLabel = ClickableLabel( self.egsLabel = ClickableLabel(
"Epic Games", "Epic Games",
icon=egs_icon, icon=egs_icon,
parent=coverWidget, parent=self.coverWidget,
icon_size=icon_size, font_scale_factor=0.06,
icon_space=icon_space,
font_scale_factor=font_scale_factor,
change_cursor=False change_cursor=False
) )
self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
self.egsLabel.setFixedWidth(badge_width)
self.egsLabel.setCardWidth(card_width) self.egsLabel.setCardWidth(card_width)
self.egsLabel.setVisible(self.egs_visible) self.egsLabel.setVisible(self.egs_visible)
# PortProton бейдж
portproton_icon = self.theme_manager.get_icon("portproton") portproton_icon = self.theme_manager.get_icon("portproton")
self.portprotonLabel = ClickableLabel( self.portprotonLabel = ClickableLabel(
"PortProton", "PortProton",
icon=portproton_icon, icon=portproton_icon,
parent=coverWidget, parent=self.coverWidget,
icon_size=icon_size, font_scale_factor=0.06
icon_space=icon_space,
font_scale_factor=font_scale_factor
) )
self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
self.portprotonLabel.setFixedWidth(badge_width)
self.portprotonLabel.setCardWidth(card_width) self.portprotonLabel.setCardWidth(card_width)
self.portprotonLabel.setVisible(self.portproton_visible) self.portprotonLabel.setVisible(self.portproton_visible)
self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic) self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic)
# WeAntiCheatYet бейдж
anticheat_text = self.getAntiCheatText(anticheat_status) anticheat_text = self.getAntiCheatText(anticheat_status)
if anticheat_text: if anticheat_text:
icon_filename = self.getAntiCheatIconFilename(anticheat_status) icon_filename = self.getAntiCheatIconFilename(anticheat_status)
@@ -211,40 +165,57 @@ class GameCard(QFrame):
self.anticheatLabel = ClickableLabel( self.anticheatLabel = ClickableLabel(
anticheat_text, anticheat_text,
icon=icon, icon=icon,
parent=coverWidget, parent=self.coverWidget,
icon_size=icon_size, font_scale_factor=0.06
icon_space=icon_space,
font_scale_factor=font_scale_factor
) )
self.anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status)) self.anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status))
self.anticheatLabel.setFixedWidth(badge_width)
self.anticheatLabel.setCardWidth(card_width) self.anticheatLabel.setCardWidth(card_width)
else: else:
self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space) self.anticheatLabel = ClickableLabel("", parent=self.coverWidget)
self.anticheatLabel.setFixedWidth(badge_width)
self.anticheatLabel.setVisible(False) self.anticheatLabel.setVisible(False)
# Расположение бейджей
self._position_badges(card_width)
self.protondbLabel.clicked.connect(self.open_protondb_report) self.protondbLabel.clicked.connect(self.open_protondb_report)
self.steamLabel.clicked.connect(self.open_steam_page) self.steamLabel.clicked.connect(self.open_steam_page)
self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page) self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page)
layout.addWidget(coverWidget) self.layout_.addWidget(self.coverWidget)
# Название игры self.nameLabel = QLabel(name)
nameLabel = QLabel(name) self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) self.nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE) self.layout_.addWidget(self.nameLabel)
layout.addWidget(nameLabel)
def _position_badges(self, card_width): font_size = self.nameLabel.font().pointSizeF()
"""Позиционирует бейджи на основе ширины карточки.""" self.base_font_size = font_size if font_size > 0 else 10.0
right_margin = 8
badge_spacing = int(card_width * 0.02) # 2% от ширины карточки self.update_scale()
top_y = 10
# 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_y_positions = []
badge_width = int(card_width * 2/3) badge_width = int(current_width * 2/3)
badges = [ badges = [
(self.steam_visible, self.steamLabel), (self.steam_visible, self.steamLabel),
@@ -256,80 +227,99 @@ class GameCard(QFrame):
for is_visible, badge in badges: for is_visible, badge in badges:
if is_visible: if is_visible:
badge_x = card_width - badge_width - right_margin badge_x = current_width - badge_width - right_margin
badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
badge.move(badge_x, badge_y) badge.move(int(badge_x), int(badge_y))
badge_y_positions.append(badge_y + badge.height()) badge_y_positions.append(badge_y + badge.height())
# Поднимаем бейджи в правильном порядке (от нижнего к верхнему)
self.anticheatLabel.raise_() self.anticheatLabel.raise_()
self.protondbLabel.raise_() self.protondbLabel.raise_()
self.portprotonLabel.raise_() self.portprotonLabel.raise_()
self.egsLabel.raise_() self.egsLabel.raise_()
self.steamLabel.raise_() self.steamLabel.raise_()
def update_card_size(self, new_width: int): def update_scale(self):
"""Обновляет размер карточки, обложки и бейджей.""" scaled_width = int(self.base_card_width * self._scale)
self.card_width = new_width scaled_height = int(self.base_card_width * 1.6 * self._scale)
extra_margin = 20 scaled_extra = int(self.base_extra_margin * self._scale)
self.setFixedSize(new_width + extra_margin, int(new_width * 1.6) + extra_margin) 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)
if self.coverLabel is None: self.coverWidget.setFixedSize(scaled_width, int(scaled_width * 1.2))
return self.coverLabel.setFixedSize(scaled_width, int(scaled_width * 1.2))
coverWidget = self.coverLabel.parentWidget() self.update_cover_pixmap()
if coverWidget is None:
return
coverWidget.setFixedSize(new_width, int(new_width * 1.2)) favorite_size = (int(self.theme.favoriteLabelSize[0] * self._scale), int(self.theme.favoriteLabelSize[1] * self._scale))
self.coverLabel.setFixedSize(new_width, int(new_width * 1.2)) self.favoriteLabel.setFixedSize(*favorite_size)
self.favoriteLabel.move(int(8 * self._scale), int(8 * self._scale))
label_ref = weakref.ref(self.coverLabel) badge_width = int(scaled_width * 2/3)
def on_cover_loaded(pixmap): icon_size = int(scaled_width * 0.06)
label = label_ref() icon_space = int(scaled_width * 0.012)
if label:
scaled_pixmap = pixmap.scaled(new_width, int(new_width * 1.2), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
rounded_pixmap = round_corners(scaled_pixmap, 15)
label.setPixmap(rounded_pixmap)
load_pixmap_async(self.cover_path or "", new_width, int(new_width * 1.2), on_cover_loaded)
# Обновляем размеры и шрифты бейджей
badge_width = int(new_width * 2/3)
icon_size = int(new_width * 0.06)
icon_space = int(new_width * 0.012)
for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]: for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]:
if label is not None: if label is not None:
label.setFixedWidth(badge_width) label.setFixedWidth(badge_width)
label.setIconSize(icon_size, icon_space) label.setIconSize(icon_size, icon_space)
label.setCardWidth(new_width) # Пересчитываем размер шрифта label.setCardWidth(scaled_width)
# Перепозиционируем бейджи self._position_badges(scaled_width)
self._position_badges(new_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() 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): def update_badge_visibility(self, display_filter: str):
"""Обновляет видимость бейджей на основе display_filter."""
self.display_filter = display_filter self.display_filter = display_filter
self.steam_visible = (str(self.game_source).lower() == "steam" and display_filter in ("all", "favorites")) 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 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 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)) protondb_visible = bool(self.getProtonDBText(self.protondb_tier))
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status))
# Обновляем видимость бейджей
self.steamLabel.setVisible(self.steam_visible) self.steamLabel.setVisible(self.steam_visible)
self.egsLabel.setVisible(self.egs_visible) self.egsLabel.setVisible(self.egs_visible)
self.portprotonLabel.setVisible(self.portproton_visible) self.portprotonLabel.setVisible(self.portproton_visible)
self.protondbLabel.setVisible(protondb_visible) self.protondbLabel.setVisible(protondb_visible)
self.anticheatLabel.setVisible(anticheat_visible) self.anticheatLabel.setVisible(anticheat_visible)
# Перепозиционируем бейджи scaled_width = int(self.base_card_width * self._scale)
self._position_badges(self.card_width) 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): def _show_context_menu(self, pos):
"""Delegate context menu display to ContextMenuManager."""
if self.context_menu_manager: if self.context_menu_manager:
self.context_menu_manager.show_context_menu(self, pos) self.context_menu_manager.show_context_menu(self, pos)
@@ -387,7 +377,6 @@ class GameCard(QFrame):
return "" return ""
def open_portproton_forum_topic(self): def open_portproton_forum_topic(self):
"""Open the PortProton forum topic or search page for this game."""
result = self.portproton_api.get_forum_topic_slug(self.name) result = self.portproton_api.get_forum_topic_slug(self.name)
base_url = "https://linux-gaming.ru/" base_url = "https://linux-gaming.ru/"
if result.startswith("search?q="): if result.startswith("search?q="):
@@ -447,8 +436,18 @@ class GameCard(QFrame):
self.gradientAngleChanged.emit() self.gradientAngleChanged.emit()
self.update() 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)) borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged))
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged)) 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): def paintEvent(self, event):
super().paintEvent(event) super().paintEvent(event)
@@ -487,6 +486,7 @@ class GameCard(QFrame):
) )
super().mousePressEvent(event) super().mousePressEvent(event)
def keyPressEvent(self, event): def keyPressEvent(self, event):
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
self.select_callback( self.select_callback(

View File

@@ -658,8 +658,9 @@ class InputManager(QObject):
# Library tab navigation (index 0) # Library tab navigation (index 0)
if self._parent.stackedWidget.currentIndex() == 0 and code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y): if self._parent.stackedWidget.currentIndex() == 0 and code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y):
focused = QApplication.focusWidget() focused = QApplication.focusWidget()
game_cards = self._parent.gamesListWidget.findChildren(GameCard) game_cards = [card for card in self._parent.gamesListWidget.findChildren(GameCard) if card.isVisible()]
if not game_cards: if not game_cards:
logger.debug("No visible GameCards found")
return return
scroll_area = self._parent.gamesListWidget.parentWidget() scroll_area = self._parent.gamesListWidget.parentWidget()
@@ -668,27 +669,48 @@ class InputManager(QObject):
# If no focused widget or not a GameCard, focus the first card # If no focused widget or not a GameCard, focus the first card
if not isinstance(focused, GameCard) or focused not in game_cards: if not isinstance(focused, GameCard) or focused not in game_cards:
game_cards[0].setFocus() game_cards[0].setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(game_cards[0], 50, 50) scroll_area.ensureWidgetVisible(game_cards[0], 50, 50)
return return
# Group cards by rows based on y-coordinate # Group cards by rows based on normalized y-coordinate
rows = {} rows = {}
tolerance = 50 # Increased tolerance for scaling effects
for card in game_cards: for card in game_cards:
y = card.pos().y() y = card.pos().y() / card.getScale() # Normalize y-coordinate
if y not in rows: found = False
rows[y] = [] for row_y in rows:
rows[y].append(card) if abs(y - row_y) < tolerance:
# Sort cards in each row by x-coordinate rows[row_y].append(card)
found = True
break
if not found:
rows[y] = [card]
# Sort cards in each row by normalized x-coordinate
for y in rows: for y in rows:
rows[y].sort(key=lambda c: c.pos().x()) rows[y].sort(key=lambda c: c.pos().x() / c.getScale())
# Sort rows by y-coordinate # Sort rows by y-coordinate
sorted_rows = sorted(rows.items(), key=lambda x: x[0]) sorted_rows = sorted(rows.items(), key=lambda x: x[0])
# Find current row and column # Find current row with normalized y-coordinate
current_y = focused.pos().y() current_y = focused.pos().y() / focused.getScale()
current_row_idx = next(i for i, (y, _) in enumerate(sorted_rows) if y == current_y) current_row_idx = None
min_diff = float('inf')
for i, (y, _) in enumerate(sorted_rows):
diff = abs(y - current_y)
if diff < tolerance and diff < min_diff:
current_row_idx = i
min_diff = diff
if current_row_idx is None:
logger.warning("No row found for current_y: %s, falling back to closest row", current_y)
if sorted_rows:
current_row_idx = min(range(len(sorted_rows)), key=lambda i: abs(sorted_rows[i][0] - current_y))
else:
logger.error("No rows available")
return
current_row = sorted_rows[current_row_idx][1] current_row = sorted_rows[current_row_idx][1]
current_col_idx = current_row.index(focused) current_col_idx = current_row.index(focused)
@@ -697,7 +719,7 @@ class InputManager(QObject):
next_col_idx = current_col_idx - 1 next_col_idx = current_col_idx - 1
if next_col_idx >= 0: if next_col_idx >= 0:
next_card = current_row[next_col_idx] next_card = current_row[next_col_idx]
next_card.setFocus() next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
else: else:
@@ -706,14 +728,14 @@ class InputManager(QObject):
prev_row = sorted_rows[current_row_idx - 1][1] prev_row = sorted_rows[current_row_idx - 1][1]
next_card = prev_row[-1] if prev_row else None next_card = prev_row[-1] if prev_row else None
if next_card: if next_card:
next_card.setFocus() next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif value > 0: # Right elif value > 0: # Right
next_col_idx = current_col_idx + 1 next_col_idx = current_col_idx + 1
if next_col_idx < len(current_row): if next_col_idx < len(current_row):
next_card = current_row[next_col_idx] next_card = current_row[next_col_idx]
next_card.setFocus() next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
else: else:
@@ -722,7 +744,7 @@ class InputManager(QObject):
next_row = sorted_rows[current_row_idx + 1][1] next_row = sorted_rows[current_row_idx + 1][1]
next_card = next_row[0] if next_row else None next_card = next_row[0] if next_row else None
if next_card: if next_card:
next_card.setFocus() next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif code == ecodes.ABS_HAT0Y and value != 0: # Up/Down elif code == ecodes.ABS_HAT0Y and value != 0: # Up/Down
@@ -730,30 +752,30 @@ class InputManager(QObject):
next_row_idx = current_row_idx + 1 next_row_idx = current_row_idx + 1
if next_row_idx < len(sorted_rows): if next_row_idx < len(sorted_rows):
next_row = sorted_rows[next_row_idx][1] next_row = sorted_rows[next_row_idx][1]
# Find card in same column or closest # Find card in same column or closest based on normalized x
target_x = focused.pos().x() target_x = focused.pos().x() / focused.getScale()
next_card = min( next_card = min(
next_row, next_row,
key=lambda c: abs(c.pos().x() - target_x), key=lambda c: abs(c.pos().x() / c.getScale() - target_x),
default=None default=None
) )
if next_card: if next_card:
next_card.setFocus() next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif value < 0: # Up elif value < 0: # Up
next_row_idx = current_row_idx - 1 next_row_idx = current_row_idx - 1
if next_row_idx >= 0: if next_row_idx >= 0:
next_row = sorted_rows[next_row_idx][1] next_row = sorted_rows[next_row_idx][1]
# Find card in same column or closest # Find card in same column or closest based on normalized x
target_x = focused.pos().x() target_x = focused.pos().x() / focused.getScale()
next_card = min( next_card = min(
next_row, next_row,
key=lambda c: abs(c.pos().x() - target_x), key=lambda c: abs(c.pos().x() / c.getScale() - target_x),
default=None default=None
) )
if next_card: if next_card:
next_card.setFocus() next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif current_row_idx == 0: elif current_row_idx == 0:
@@ -768,8 +790,8 @@ class InputManager(QObject):
focusables = page.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively) focusables = page.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus] focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
if focusables: if focusables:
focusables[0].setFocus() focusables[0].setFocus(Qt.FocusReason.OtherFocusReason)
return return
elif focused: elif focused:
focused.focusNextChild() focused.focusNextChild()
return return

View File

@@ -29,69 +29,105 @@ color_h = "transparent"
GAME_CARD_ANIMATION = { GAME_CARD_ANIMATION = {
# Тип анимации при входе и выходе на детальную страницу # Тип анимации при входе и выходе на детальную страницу
# Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce" # Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
# Определяет, как детальная страница появляется и исчезает
"detail_page_animation_type": "fade", "detail_page_animation_type": "fade",
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса). # Ширина обводки карточки в состоянии покоя (без наведения или фокуса)
# Влияет на толщину рамки вокруг карточки, когда она не выделена. # Влияет на толщину рамки вокруг карточки, когда она не выделена
# Значение в пикселях. # Значение в пикселях
"default_border_width": 2, "default_border_width": 2,
# Ширина обводки при наведении курсора. # Ширина обводки при наведении курсора
# Увеличивает толщину рамки, когда курсор находится над карточкой. # Увеличивает толщину рамки, когда курсор находится над карточкой
# Значение в пикселях. # Значение в пикселях
"hover_border_width": 8, "hover_border_width": 8,
# Ширина обводки при фокусе (например, при выборе с клавиатуры). # Ширина обводки при фокусе (например, при выборе с клавиатуры)
# Увеличивает толщину рамки, когда карточка в фокусе. # Увеличивает толщину рамки, когда карточка в фокусе
# Значение в пикселях. # Значение в пикселях
"focus_border_width": 12, "focus_border_width": 12,
# Минимальная ширина обводки во время пульсирующей анимации. # Минимальная ширина обводки во время пульсирующей анимации
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания"). # Определяет минимальную толщину рамки при пульсации (анимация "дыхания")
# Значение в пикселях. # Значение в пикселях
"pulse_min_border_width": 8, "pulse_min_border_width": 8,
# Максимальная ширина обводки во время пульсирующей анимации. # Максимальная ширина обводки во время пульсирующей анимации
# Определяет максимальную толщину рамки при пульсации. # Определяет максимальную толщину рамки при пульсации
# Значение в пикселях. # Значение в пикселях
"pulse_max_border_width": 10, "pulse_max_border_width": 10,
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе). # Длительность анимации изменения толщины обводки (например, при наведении или фокусе)
# Влияет на скорость перехода от одной ширины обводки к другой. # Влияет на скорость перехода от одной ширины обводки к другой
# Значение в миллисекундах. # Значение в миллисекундах
"thickness_anim_duration": 300, "thickness_anim_duration": 300,
# Длительность одного цикла пульсирующей анимации. # Длительность одного цикла пульсирующей анимации
# Определяет, как быстро рамка "пульсирует" между min и max значениями. # Определяет, как быстро рамка "пульсирует" между min и max значениями
# Значение в миллисекундах. # Значение в миллисекундах
"pulse_anim_duration": 800, "pulse_anim_duration": 800,
# Длительность анимации вращения градиента. # Длительность анимации вращения градиента
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки. # Влияет на скорость, с которой градиентная обводка вращается вокруг карточки
# Значение в миллисекундах. # Значение в миллисекундах
"gradient_anim_duration": 3000, "gradient_anim_duration": 3000,
# Начальный угол градиента (в градусах). # Начальный угол градиента (в градусах)
# Определяет начальную точку вращения градиента при старте анимации. # Определяет начальную точку вращения градиента при старте анимации
"gradient_start_angle": 360, "gradient_start_angle": 360,
# Конечный угол градиента (в градусах). # Конечный угол градиента (в градусах)
# Определяет конечную точку вращения градиента. # Определяет конечную точку вращения градиента
# Значение 0 означает полный поворот на 360 градусов. # Значение 0 означает полный поворот на 360 градусов
"gradient_end_angle": 0, "gradient_end_angle": 0,
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе). # Тип анимации для карточки при наведении или фокусе
# Влияет на "чувство" анимации (например, плавное ускорение или замедление). # Возможные значения: "gradient", "scale"
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad"). # scale крайне нестабилен и требует доработки (используйте на свой страх и риск)
# "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки
"card_animation_type": "gradient",
# Масштаб карточки в состоянии покоя
# Определяет базовый размер карточки (1.0 = 100% от исходного размера)
# Значение в долях (например, 1.0 для нормального размера)
"default_scale": 1.0,
# Масштаб карточки при наведении курсора
# Увеличивает размер карточки при наведении
# Значение в долях (например, 1.1 = 110% от исходного размера)
"hover_scale": 1.1,
# Масштаб карточки при фокусе (например, при выборе с клавиатуры)
# Увеличивает размер карточки при фокусе
# Значение в долях (например, 1.05 = 105% от исходного размера)
"focus_scale": 1.05,
# Длительность анимации масштабирования
# Влияет на скорость изменения размера карточки при наведении или фокусе
# Значение в миллисекундах
"scale_anim_duration": 200,
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе)
# Влияет на "чувство" анимации (например, плавное ускорение или замедление)
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad")
"thickness_easing_curve": "OutBack", "thickness_easing_curve": "OutBack",
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса). # Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса)
# Влияет на "чувство" возврата к исходной ширине обводки. # Влияет на "чувство" возврата к исходной ширине обводки
"thickness_easing_curve_out": "InBack", "thickness_easing_curve_out": "InBack",
# Цвета градиента для анимированной обводки. # Тип кривой сглаживания для анимации увеличения масштаба (при наведении/фокусе)
# Список словарей, где каждый словарь задает позицию (0.01.0) и цвет в формате hex. # Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока")
# Влияет на внешний вид обводки при наведении или фокусе. # Возможные значения: строки, соответствующие QEasingCurve.Type
"scale_easing_curve": "OutBack",
# Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса)
# Влияет на "чувство" возврата к исходному масштабу
"scale_easing_curve_out": "InBack",
# Цвета градиента для анимированной обводки
# Список словарей, где каждый словарь задает позицию (0.01.0) и цвет в формате hex
# Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient"
"gradient_colors": [ "gradient_colors": [
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан) {"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый) {"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
@@ -100,29 +136,43 @@ GAME_CARD_ANIMATION = {
], ],
# Длительность анимации fade при входе на детальную страницу # Длительность анимации fade при входе на детальную страницу
# Влияет на скорость появления страницы при fade-анимации
# Значение в миллисекундах
"detail_page_fade_duration": 350, "detail_page_fade_duration": 350,
# Длительность анимации slide при входе на детальную страницу # Длительность анимации slide при входе на детальную страницу
# Влияет на скорость скольжения страницы при slide-анимации
# Значение в миллисекундах
"detail_page_slide_duration": 500, "detail_page_slide_duration": 500,
# Длительность анимации bounce при входе на детальную страницу # Длительность анимации bounce при входе на детальную страницу
# Влияет на скорость "прыжка" страницы при bounce-анимации
# Значение в миллисекундах
"detail_page_bounce_duration": 400, "detail_page_bounce_duration": 400,
# Длительность анимации fade при выходе из детальной страницы # Длительность анимации fade при выходе из детальной страницы
# Влияет на скорость исчезновения страницы при fade-анимации
# Значение в миллисекундах
"detail_page_fade_duration_exit": 350, "detail_page_fade_duration_exit": 350,
# Длительность анимации slide при выходе из детальной страницы # Длительность анимации slide при выходе из детальной страницы
# Влияет на скорость скольжения страницы при slide-анимации
# Значение в миллисекундах
"detail_page_slide_duration_exit": 500, "detail_page_slide_duration_exit": 500,
# Длительность анимации bounce при выходе из детальной страницы # Длительность анимации bounce при выходе из детальной страницы
# Влияет на скорость "сжатия" страницы при bounce-анимации
# Значение в миллисекундах
"detail_page_bounce_duration_exit": 400, "detail_page_bounce_duration_exit": 400,
# Тип кривой сглаживания для анимации при входе на детальную страницу # Тип кривой сглаживания для анимации при входе на детальную страницу
# Применяется к slide и bounce анимациям # Применяется к slide и bounce анимациям, влияет на "чувство" движения
# Возможные значения: строки, соответствующие QEasingCurve.Type
"detail_page_easing_curve": "OutCubic", "detail_page_easing_curve": "OutCubic",
# Тип кривой сглаживания для анимации при выходе из детальной страницы # Тип кривой сглаживания для анимации при выходе из детальной страницы
# Применяется к slide и bounce анимациям # Применяется к slide и bounce анимациям, влияет на "чувство" движения
# Возможные значения: строки, соответствующие QEasingCurve.Type
"detail_page_easing_curve_exit": "InCubic" "detail_page_easing_curve_exit": "InCubic"
} }