From 8f54f4814ce379408e6f879b5e0d5425dc19a09f Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Thu, 28 Aug 2025 10:48:00 +0500 Subject: [PATCH] feat: added scale animation to game card hover and focus Signed-off-by: Boris Yumankulov --- portprotonqt/animations.py | 120 +++++++--- portprotonqt/game_card.py | 292 ++++++++++++------------- portprotonqt/input_manager.py | 74 ++++--- portprotonqt/themes/standart/styles.py | 128 +++++++---- 4 files changed, 371 insertions(+), 243 deletions(-) diff --git a/portprotonqt/animations.py b/portprotonqt/animations.py index 84a1828..c1643be 100644 --- a/portprotonqt/animations.py +++ b/portprotonqt/animations.py @@ -26,14 +26,23 @@ class GameCardAnimations: self.theme = theme if theme is not None else default_styles self.thickness_anim: QPropertyAnimation | None = None self.gradient_anim: QPropertyAnimation | None = None + self.scale_anim: QPropertyAnimation | None = None self.pulse_anim: QPropertyAnimation | None = None self._isPulseAnimationConnected = False 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.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): """Start pulse animation for border width when hovered or focused.""" if not (self.game_card._hovered or self.game_card._focused): @@ -57,6 +66,8 @@ class GameCardAnimations: if not self.thickness_anim: self.setup_animations() + animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient") + if self.thickness_anim: self.thickness_anim.stop() if self._isPulseAnimationConnected: @@ -69,23 +80,44 @@ class GameCardAnimations: self._isPulseAnimationConnected = True self.thickness_anim.start() - if self.gradient_anim: - self.gradient_anim.stop() - self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle")) - self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"]) - self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]) - self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"]) - self.gradient_anim.setLoopCount(-1) - self.gradient_anim.start() + if animation_type == "gradient": + if self.gradient_anim: + self.gradient_anim.stop() + self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle")) + self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"]) + self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]) + self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"]) + 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): """Handle mouse leave event animations.""" self.game_card._hovered = False self.game_card.hoverChanged.emit(self.game_card.name, False) if not self.game_card._focused: - if self.gradient_anim: - self.gradient_anim.stop() - self.gradient_anim = None + animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient") + if animation_type == "gradient": + 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: self.pulse_anim.stop() self.pulse_anim = None @@ -108,6 +140,8 @@ class GameCardAnimations: if not self.thickness_anim: self.setup_animations() + animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient") + if self.thickness_anim: self.thickness_anim.stop() if self._isPulseAnimationConnected: @@ -120,23 +154,44 @@ class GameCardAnimations: self._isPulseAnimationConnected = True self.thickness_anim.start() - if self.gradient_anim: - self.gradient_anim.stop() - self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle")) - self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"]) - self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]) - self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"]) - self.gradient_anim.setLoopCount(-1) - self.gradient_anim.start() + if animation_type == "gradient": + if self.gradient_anim: + self.gradient_anim.stop() + self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle")) + self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"]) + self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]) + self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"]) + 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): """Handle focus out event animations.""" self.game_card._focused = False self.game_card.focusChanged.emit(self.game_card.name, False) if not self.game_card._hovered: - if self.gradient_anim: - self.gradient_anim.stop() - self.gradient_anim = None + animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient") + if animation_type == "gradient": + 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: self.pulse_anim.stop() self.pulse_anim = None @@ -157,7 +212,8 @@ class GameCardAnimations: painter.setRenderHint(QPainter.RenderHint.Antialiasing) pen = QPen() 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() gradient = QConicalGradient(center, self.game_card._gradientAngle) for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]: @@ -166,11 +222,11 @@ class GameCardAnimations: else: pen.setColor(QColor(0, 0, 0, 0)) painter.setPen(pen) - radius = 18 + radius = 18 * self.game_card._scale bw = round(self.game_card._borderWidth / 2) rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw) if rect.isEmpty(): - return # Avoid drawing invalid rect + return painter.drawRoundedRect(rect, radius, radius) class DetailPageAnimations: @@ -284,15 +340,15 @@ class DetailPageAnimations: logger.debug("Original effect already deleted") cleanup_callback() 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"]: 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")]) end_pos = { - "slide_left": QPoint(-self.main_window.width(), 0), # Exit to left (opposite of entry) - "slide_right": QPoint(self.main_window.width(), 0), # Exit to right - "slide_up": QPoint(0, self.main_window.height()), # Exit downward - "slide_down": QPoint(0, -self.main_window.height()) # Exit upward + "slide_left": QPoint(-self.main_window.width(), 0), + "slide_right": QPoint(self.main_window.width(), 0), + "slide_up": QPoint(0, self.main_window.height()), + "slide_down": QPoint(0, -self.main_window.height()) }[animation_type] animation = QPropertyAnimation(detail_page, QByteArray(b"pos")) animation.setDuration(duration) @@ -325,4 +381,4 @@ class DetailPageAnimations: except Exception as e: logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True) self.animations.pop(detail_page, None) - cleanup_callback() # Fallback to cleanup if animation setup fails + cleanup_callback() diff --git a/portprotonqt/game_card.py b/portprotonqt/game_card.py index f83c52a..4e433c2 100644 --- a/portprotonqt/game_card.py +++ b/portprotonqt/game_card.py @@ -12,29 +12,27 @@ from portprotonqt.custom_widgets import ClickableLabel from portprotonqt.portproton_api import PortProtonAPI from portprotonqt.downloader import Downloader from portprotonqt.animations import GameCardAnimations -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 + 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): + 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 @@ -49,7 +47,9 @@ class GameCard(QFrame): self.game_source = game_source self.last_launch_ts = last_launch_ts 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.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.portproton_visible = (str(game_source).lower() == "portproton" and self.display_filter in ("all", "favorites")) - # Дополнительное пространство для анимации - extra_margin = 20 - self.setFixedSize(card_width + extra_margin, int(card_width * 1.6) + extra_margin) + 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() - # Тень - shadow = QGraphicsDropShadowEffect(self) - shadow.setBlurRadius(20) - shadow.setColor(QColor(0, 0, 0, 150)) - shadow.setOffset(0, 0) - self.setGraphicsEffect(shadow) + 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) - # Отступы - layout = QVBoxLayout(self) - layout.setContentsMargins(extra_margin // 2, extra_margin // 2, extra_margin // 2, extra_margin // 2) - layout.setSpacing(5) + 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) - # Контейнер обложки - coverWidget = QWidget() - coverWidget.setFixedSize(card_width, int(card_width * 1.2)) - coverLayout = QStackedLayout(coverWidget) + self.coverWidget = QWidget() + coverLayout = QStackedLayout(self.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) + load_pixmap_async(cover_path or "", self.base_card_width, int(self.base_card_width * 1.2), self.on_cover_loaded) - 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 = 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_() - # Определяем общие параметры для бейджей - 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) if tier_text: icon_filename = self.getProtonDBIconFilename(protondb_tier) @@ -143,67 +114,50 @@ class GameCard(QFrame): self.protondbLabel = ClickableLabel( tier_text, icon=icon, - parent=coverWidget, - icon_size=icon_size, - icon_space=icon_space, - font_scale_factor=font_scale_factor + parent=self.coverWidget, + font_scale_factor=0.06 ) self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier)) - self.protondbLabel.setFixedWidth(badge_width) self.protondbLabel.setCardWidth(card_width) else: - self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space) - self.protondbLabel.setFixedWidth(badge_width) + self.protondbLabel = ClickableLabel("", parent=self.coverWidget) self.protondbLabel.setVisible(False) - # Steam бейдж steam_icon = self.theme_manager.get_icon("steam") self.steamLabel = ClickableLabel( "Steam", icon=steam_icon, - parent=coverWidget, - icon_size=icon_size, - icon_space=icon_space, - font_scale_factor=font_scale_factor + parent=self.coverWidget, + font_scale_factor=0.06 ) self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) - self.steamLabel.setFixedWidth(badge_width) self.steamLabel.setCardWidth(card_width) self.steamLabel.setVisible(self.steam_visible) - # Epic Games Store бейдж egs_icon = self.theme_manager.get_icon("epic_games") self.egsLabel = ClickableLabel( "Epic Games", icon=egs_icon, - parent=coverWidget, - icon_size=icon_size, - icon_space=icon_space, - font_scale_factor=font_scale_factor, + parent=self.coverWidget, + font_scale_factor=0.06, change_cursor=False ) self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) - self.egsLabel.setFixedWidth(badge_width) self.egsLabel.setCardWidth(card_width) self.egsLabel.setVisible(self.egs_visible) - # PortProton бейдж portproton_icon = self.theme_manager.get_icon("portproton") self.portprotonLabel = ClickableLabel( "PortProton", icon=portproton_icon, - parent=coverWidget, - icon_size=icon_size, - icon_space=icon_space, - font_scale_factor=font_scale_factor + parent=self.coverWidget, + font_scale_factor=0.06 ) self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) - self.portprotonLabel.setFixedWidth(badge_width) self.portprotonLabel.setCardWidth(card_width) self.portprotonLabel.setVisible(self.portproton_visible) self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic) - # WeAntiCheatYet бейдж anticheat_text = self.getAntiCheatText(anticheat_status) if anticheat_text: icon_filename = self.getAntiCheatIconFilename(anticheat_status) @@ -211,40 +165,57 @@ class GameCard(QFrame): self.anticheatLabel = ClickableLabel( anticheat_text, icon=icon, - parent=coverWidget, - icon_size=icon_size, - icon_space=icon_space, - font_scale_factor=font_scale_factor + parent=self.coverWidget, + font_scale_factor=0.06 ) self.anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status)) - self.anticheatLabel.setFixedWidth(badge_width) self.anticheatLabel.setCardWidth(card_width) else: - self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space) - self.anticheatLabel.setFixedWidth(badge_width) + self.anticheatLabel = ClickableLabel("", parent=self.coverWidget) self.anticheatLabel.setVisible(False) - # Расположение бейджей - self._position_badges(card_width) 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) + self.layout_.addWidget(self.coverWidget) - # Название игры - nameLabel = QLabel(name) - nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) - nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE) - layout.addWidget(nameLabel) + 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) - def _position_badges(self, card_width): - """Позиционирует бейджи на основе ширины карточки.""" - right_margin = 8 - badge_spacing = int(card_width * 0.02) # 2% от ширины карточки - top_y = 10 + 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(card_width * 2/3) + badge_width = int(current_width * 2/3) badges = [ (self.steam_visible, self.steamLabel), @@ -256,80 +227,99 @@ class GameCard(QFrame): for is_visible, badge in badges: 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.move(badge_x, badge_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_card_size(self, new_width: int): - """Обновляет размер карточки, обложки и бейджей.""" - self.card_width = new_width - extra_margin = 20 - self.setFixedSize(new_width + extra_margin, int(new_width * 1.6) + extra_margin) + 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) - if self.coverLabel is None: - return + self.coverWidget.setFixedSize(scaled_width, int(scaled_width * 1.2)) + self.coverLabel.setFixedSize(scaled_width, int(scaled_width * 1.2)) - coverWidget = self.coverLabel.parentWidget() - if coverWidget is None: - return + self.update_cover_pixmap() - coverWidget.setFixedSize(new_width, int(new_width * 1.2)) - self.coverLabel.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.favoriteLabel.setFixedSize(*favorite_size) + self.favoriteLabel.move(int(8 * self._scale), int(8 * self._scale)) - label_ref = weakref.ref(self.coverLabel) - def on_cover_loaded(pixmap): - label = label_ref() - 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) + 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(new_width) # Пересчитываем размер шрифта + label.setCardWidth(scaled_width) - # Перепозиционируем бейджи - self._position_badges(new_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): - """Обновляет видимость бейджей на основе display_filter.""" self.display_filter = display_filter - self.steam_visible = (str(self.game_source).lower() == "steam" and display_filter in ("all", "favorites")) - self.egs_visible = (str(self.game_source).lower() == "epic" and display_filter in ("all", "favorites")) - self.portproton_visible = (str(self.game_source).lower() == "portproton" 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 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) - # Перепозиционируем бейджи - self._position_badges(self.card_width) + 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): - """Delegate context menu display to ContextMenuManager.""" if self.context_menu_manager: self.context_menu_manager.show_context_menu(self, pos) @@ -387,7 +377,6 @@ class GameCard(QFrame): return "" 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) base_url = "https://linux-gaming.ru/" if result.startswith("search?q="): @@ -447,8 +436,18 @@ class GameCard(QFrame): 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) @@ -487,6 +486,7 @@ class GameCard(QFrame): ) super().mousePressEvent(event) + def keyPressEvent(self, event): if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): self.select_callback( diff --git a/portprotonqt/input_manager.py b/portprotonqt/input_manager.py index 19b7de8..a041194 100644 --- a/portprotonqt/input_manager.py +++ b/portprotonqt/input_manager.py @@ -658,8 +658,9 @@ class InputManager(QObject): # Library tab navigation (index 0) if self._parent.stackedWidget.currentIndex() == 0 and code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y): 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: + logger.debug("No visible GameCards found") return 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 not isinstance(focused, GameCard) or focused not in game_cards: - game_cards[0].setFocus() + game_cards[0].setFocus(Qt.FocusReason.OtherFocusReason) if scroll_area: scroll_area.ensureWidgetVisible(game_cards[0], 50, 50) return - # Group cards by rows based on y-coordinate + # Group cards by rows based on normalized y-coordinate rows = {} + tolerance = 50 # Increased tolerance for scaling effects for card in game_cards: - y = card.pos().y() - if y not in rows: - rows[y] = [] - rows[y].append(card) - # Sort cards in each row by x-coordinate + y = card.pos().y() / card.getScale() # Normalize y-coordinate + found = False + for row_y in rows: + if abs(y - row_y) < tolerance: + 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: - 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 sorted_rows = sorted(rows.items(), key=lambda x: x[0]) - # Find current row and column - current_y = focused.pos().y() - current_row_idx = next(i for i, (y, _) in enumerate(sorted_rows) if y == current_y) + # Find current row with normalized y-coordinate + current_y = focused.pos().y() / focused.getScale() + 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_col_idx = current_row.index(focused) @@ -697,7 +719,7 @@ class InputManager(QObject): next_col_idx = current_col_idx - 1 if next_col_idx >= 0: next_card = current_row[next_col_idx] - next_card.setFocus() + next_card.setFocus(Qt.FocusReason.OtherFocusReason) if scroll_area: scroll_area.ensureWidgetVisible(next_card, 50, 50) else: @@ -706,14 +728,14 @@ class InputManager(QObject): prev_row = sorted_rows[current_row_idx - 1][1] next_card = prev_row[-1] if prev_row else None if next_card: - next_card.setFocus() + next_card.setFocus(Qt.FocusReason.OtherFocusReason) if scroll_area: scroll_area.ensureWidgetVisible(next_card, 50, 50) elif value > 0: # Right next_col_idx = current_col_idx + 1 if next_col_idx < len(current_row): next_card = current_row[next_col_idx] - next_card.setFocus() + next_card.setFocus(Qt.FocusReason.OtherFocusReason) if scroll_area: scroll_area.ensureWidgetVisible(next_card, 50, 50) else: @@ -722,7 +744,7 @@ class InputManager(QObject): next_row = sorted_rows[current_row_idx + 1][1] next_card = next_row[0] if next_row else None if next_card: - next_card.setFocus() + next_card.setFocus(Qt.FocusReason.OtherFocusReason) if scroll_area: scroll_area.ensureWidgetVisible(next_card, 50, 50) elif code == ecodes.ABS_HAT0Y and value != 0: # Up/Down @@ -730,30 +752,30 @@ class InputManager(QObject): next_row_idx = current_row_idx + 1 if next_row_idx < len(sorted_rows): next_row = sorted_rows[next_row_idx][1] - # Find card in same column or closest - target_x = focused.pos().x() + # Find card in same column or closest based on normalized x + target_x = focused.pos().x() / focused.getScale() next_card = min( next_row, - key=lambda c: abs(c.pos().x() - target_x), + key=lambda c: abs(c.pos().x() / c.getScale() - target_x), default=None ) if next_card: - next_card.setFocus() + next_card.setFocus(Qt.FocusReason.OtherFocusReason) if scroll_area: scroll_area.ensureWidgetVisible(next_card, 50, 50) elif value < 0: # Up next_row_idx = current_row_idx - 1 if next_row_idx >= 0: next_row = sorted_rows[next_row_idx][1] - # Find card in same column or closest - target_x = focused.pos().x() + # Find card in same column or closest based on normalized x + target_x = focused.pos().x() / focused.getScale() next_card = min( next_row, - key=lambda c: abs(c.pos().x() - target_x), + key=lambda c: abs(c.pos().x() / c.getScale() - target_x), default=None ) if next_card: - next_card.setFocus() + next_card.setFocus(Qt.FocusReason.OtherFocusReason) if scroll_area: scroll_area.ensureWidgetVisible(next_card, 50, 50) elif current_row_idx == 0: @@ -768,8 +790,8 @@ class InputManager(QObject): focusables = page.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively) focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus] if focusables: - focusables[0].setFocus() - return + focusables[0].setFocus(Qt.FocusReason.OtherFocusReason) + return elif focused: focused.focusNextChild() return diff --git a/portprotonqt/themes/standart/styles.py b/portprotonqt/themes/standart/styles.py index 6ebe7d3..9e0f901 100644 --- a/portprotonqt/themes/standart/styles.py +++ b/portprotonqt/themes/standart/styles.py @@ -29,69 +29,105 @@ color_h = "transparent" GAME_CARD_ANIMATION = { # Тип анимации при входе и выходе на детальную страницу # Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce" + # Определяет, как детальная страница появляется и исчезает "detail_page_animation_type": "fade", - # Ширина обводки карточки в состоянии покоя (без наведения или фокуса). - # Влияет на толщину рамки вокруг карточки, когда она не выделена. - # Значение в пикселях. + # Ширина обводки карточки в состоянии покоя (без наведения или фокуса) + # Влияет на толщину рамки вокруг карточки, когда она не выделена + # Значение в пикселях "default_border_width": 2, - # Ширина обводки при наведении курсора. - # Увеличивает толщину рамки, когда курсор находится над карточкой. - # Значение в пикселях. + # Ширина обводки при наведении курсора + # Увеличивает толщину рамки, когда курсор находится над карточкой + # Значение в пикселях "hover_border_width": 8, - # Ширина обводки при фокусе (например, при выборе с клавиатуры). - # Увеличивает толщину рамки, когда карточка в фокусе. - # Значение в пикселях. + # Ширина обводки при фокусе (например, при выборе с клавиатуры) + # Увеличивает толщину рамки, когда карточка в фокусе + # Значение в пикселях "focus_border_width": 12, - # Минимальная ширина обводки во время пульсирующей анимации. - # Определяет минимальную толщину рамки при пульсации (анимация "дыхания"). - # Значение в пикселях. + # Минимальная ширина обводки во время пульсирующей анимации + # Определяет минимальную толщину рамки при пульсации (анимация "дыхания") + # Значение в пикселях "pulse_min_border_width": 8, - # Максимальная ширина обводки во время пульсирующей анимации. - # Определяет максимальную толщину рамки при пульсации. - # Значение в пикселях. + # Максимальная ширина обводки во время пульсирующей анимации + # Определяет максимальную толщину рамки при пульсации + # Значение в пикселях "pulse_max_border_width": 10, - # Длительность анимации изменения толщины обводки (например, при наведении или фокусе). - # Влияет на скорость перехода от одной ширины обводки к другой. - # Значение в миллисекундах. + # Длительность анимации изменения толщины обводки (например, при наведении или фокусе) + # Влияет на скорость перехода от одной ширины обводки к другой + # Значение в миллисекундах "thickness_anim_duration": 300, - # Длительность одного цикла пульсирующей анимации. - # Определяет, как быстро рамка "пульсирует" между min и max значениями. - # Значение в миллисекундах. + # Длительность одного цикла пульсирующей анимации + # Определяет, как быстро рамка "пульсирует" между min и max значениями + # Значение в миллисекундах "pulse_anim_duration": 800, - # Длительность анимации вращения градиента. - # Влияет на скорость, с которой градиентная обводка вращается вокруг карточки. - # Значение в миллисекундах. + # Длительность анимации вращения градиента + # Влияет на скорость, с которой градиентная обводка вращается вокруг карточки + # Значение в миллисекундах "gradient_anim_duration": 3000, - # Начальный угол градиента (в градусах). - # Определяет начальную точку вращения градиента при старте анимации. + # Начальный угол градиента (в градусах) + # Определяет начальную точку вращения градиента при старте анимации "gradient_start_angle": 360, - # Конечный угол градиента (в градусах). - # Определяет конечную точку вращения градиента. - # Значение 0 означает полный поворот на 360 градусов. + # Конечный угол градиента (в градусах) + # Определяет конечную точку вращения градиента + # Значение 0 означает полный поворот на 360 градусов "gradient_end_angle": 0, - # Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе). - # Влияет на "чувство" анимации (например, плавное ускорение или замедление). - # Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad"). + # Тип анимации для карточки при наведении или фокусе + # Возможные значения: "gradient", "scale" + # 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_out": "InBack", - # Цвета градиента для анимированной обводки. - # Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex. - # Влияет на внешний вид обводки при наведении или фокусе. + # Тип кривой сглаживания для анимации увеличения масштаба (при наведении/фокусе) + # Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока") + # Возможные значения: строки, соответствующие QEasingCurve.Type + "scale_easing_curve": "OutBack", + + # Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса) + # Влияет на "чувство" возврата к исходному масштабу + "scale_easing_curve_out": "InBack", + + # Цвета градиента для анимированной обводки + # Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex + # Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient" "gradient_colors": [ {"position": 0, "color": "#00fff5"}, # Начальный цвет (циан) {"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый) @@ -100,29 +136,43 @@ GAME_CARD_ANIMATION = { ], # Длительность анимации fade при входе на детальную страницу + # Влияет на скорость появления страницы при fade-анимации + # Значение в миллисекундах "detail_page_fade_duration": 350, # Длительность анимации slide при входе на детальную страницу + # Влияет на скорость скольжения страницы при slide-анимации + # Значение в миллисекундах "detail_page_slide_duration": 500, # Длительность анимации bounce при входе на детальную страницу + # Влияет на скорость "прыжка" страницы при bounce-анимации + # Значение в миллисекундах "detail_page_bounce_duration": 400, # Длительность анимации fade при выходе из детальной страницы + # Влияет на скорость исчезновения страницы при fade-анимации + # Значение в миллисекундах "detail_page_fade_duration_exit": 350, # Длительность анимации slide при выходе из детальной страницы + # Влияет на скорость скольжения страницы при slide-анимации + # Значение в миллисекундах "detail_page_slide_duration_exit": 500, # Длительность анимации bounce при выходе из детальной страницы + # Влияет на скорость "сжатия" страницы при bounce-анимации + # Значение в миллисекундах "detail_page_bounce_duration_exit": 400, # Тип кривой сглаживания для анимации при входе на детальную страницу - # Применяется к slide и bounce анимациям + # Применяется к slide и bounce анимациям, влияет на "чувство" движения + # Возможные значения: строки, соответствующие QEasingCurve.Type "detail_page_easing_curve": "OutCubic", # Тип кривой сглаживания для анимации при выходе из детальной страницы - # Применяется к slide и bounce анимациям + # Применяется к slide и bounce анимациям, влияет на "чувство" движения + # Возможные значения: строки, соответствующие QEasingCurve.Type "detail_page_easing_curve_exit": "InCubic" }