From 81e19c4cd46491da55b66a6e8ca884dd17ed123d Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Mon, 8 Jun 2026 20:47:46 +0500 Subject: [PATCH 1/3] feat: improve library filter controls Signed-off-by: Boris Yumankulov --- portprotonqt/animations/__init__.py | 2 + portprotonqt/animations/library_controls.py | 75 +++++++ portprotonqt/game_library_manager.py | 13 +- portprotonqt/input_manager.py | 65 +++++- portprotonqt/main_window.py | 197 +++++++++++------- portprotonqt/meson.build | 1 + portprotonqt/themes/classic-light/styles.py | 90 ++++++++ portprotonqt/themes/classic/styles.py | 90 ++++++++ portprotonqt/themes/standart-light/styles.py | 90 ++++++++ .../themes/standart/styles/detail_page.py | 81 +++++++ 10 files changed, 627 insertions(+), 77 deletions(-) create mode 100644 portprotonqt/animations/library_controls.py diff --git a/portprotonqt/animations/__init__.py b/portprotonqt/animations/__init__.py index f4862dde..8439740b 100644 --- a/portprotonqt/animations/__init__.py +++ b/portprotonqt/animations/__init__.py @@ -1,9 +1,11 @@ from portprotonqt.animations.detail_page import DetailPageAnimations from portprotonqt.animations.game_card import GameCardAnimations +from portprotonqt.animations.library_controls import LibraryControlsAnimation from portprotonqt.animations.virtual_keyboard import VirtualKeyboardAnimations __all__ = [ "DetailPageAnimations", "GameCardAnimations", + "LibraryControlsAnimation", "VirtualKeyboardAnimations", ] diff --git a/portprotonqt/animations/library_controls.py b/portprotonqt/animations/library_controls.py new file mode 100644 index 00000000..a727d142 --- /dev/null +++ b/portprotonqt/animations/library_controls.py @@ -0,0 +1,75 @@ +from PySide6.QtCore import ( + QAbstractAnimation, + QByteArray, + QEasingCurve, + QParallelAnimationGroup, + QPropertyAnimation, +) +from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget + + +class LibraryControlsAnimation: + def __init__(self, widget: QWidget, theme: object, fallback_duration: int): + self.widget = widget + self.theme = theme + self.fallback_duration = fallback_duration + self.opacity_effect = QGraphicsOpacityEffect(widget) + self.opacity_effect.setOpacity(0) + self.widget.setGraphicsEffect(self.opacity_effect) + self.group = QParallelAnimationGroup(widget) + + def setup_hidden(self) -> None: + self.widget.setVisible(False) + self.widget.setMaximumHeight(0) + + def toggle(self, opening: bool) -> None: + if self.group.state() == QAbstractAnimation.State.Running: + self.group.stop() + if opening: + self.widget.setVisible(True) + + target_height = self.widget.sizeHint().height() if opening else 0 + self.group = QParallelAnimationGroup(self.widget) + height_animation = self._create_widget_animation( + b"maximumHeight", + self.widget.maximumHeight(), + target_height, + ) + opacity_animation = self._create_opacity_animation(opening, height_animation.duration()) + self.group.addAnimation(height_animation) + self.group.addAnimation(opacity_animation) + if not opening: + self.group.finished.connect(self.widget.hide) + self.group.start() + + def _create_widget_animation( + self, + property_name: bytes, + start_value: object, + end_value: object, + ) -> QPropertyAnimation: + animation = QPropertyAnimation(self.widget, QByteArray(property_name)) + animation.setDuration(self._duration()) + animation.setStartValue(start_value) + animation.setEndValue(end_value) + animation.setEasingCurve(self._easing(end_value != 0)) + return animation + + def _create_opacity_animation(self, opening: bool, duration: int) -> QPropertyAnimation: + animation = QPropertyAnimation(self.opacity_effect, QByteArray(b"opacity")) + animation.setDuration(duration) + animation.setStartValue(self.opacity_effect.opacity()) + animation.setEndValue(1 if opening else 0) + animation.setEasingCurve(self._easing(opening)) + return animation + + def _duration(self) -> int: + animation_config = getattr(self.theme, "GAME_CARD_ANIMATION", {}) + return animation_config.get("scale_anim_duration", self.fallback_duration) + + def _easing(self, opening: bool) -> QEasingCurve: + animation_config = getattr(self.theme, "GAME_CARD_ANIMATION", {}) + key = "scale_easing_curve" if opening else "scale_easing_curve_out" + easing_name = animation_config.get(key, QEasingCurve.Type.InOutQuad.name) + easing_type = getattr(QEasingCurve.Type, easing_name, QEasingCurve.Type.InOutQuad) + return QEasingCurve(easing_type) diff --git a/portprotonqt/game_library_manager.py b/portprotonqt/game_library_manager.py index 7d807e70..a908321a 100644 --- a/portprotonqt/game_library_manager.py +++ b/portprotonqt/game_library_manager.py @@ -255,8 +255,15 @@ class GameLibraryManager: self._pending_update = False self._update_game_grid_immediate() - def update_game_grid(self, games_list: list[tuple] | None = None, is_filter: bool = False): + def update_game_grid( + self, + games_list: list[tuple] | None = None, + is_filter: bool = False, + focus_first_card: bool | None = None, + ): """Schedules a game grid update with debouncing.""" + if focus_first_card is not None: + self._focus_first_card_after_update = focus_first_card self.layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower() if self.sizeSlider is not None: self.sizeSlider.setVisible(self.layout_mode != "list") @@ -666,11 +673,11 @@ class GameLibraryManager: self.game_card_cache.clear() self.pending_images.clear() - def set_games(self, games: list[tuple]): + def set_games(self, games: list[tuple], focus_first_card: bool = True): """Sets the games list and updates the filtered games.""" self.games = games self.filtered_games = self.games - self._focus_first_card_after_update = bool(games) + self._focus_first_card_after_update = bool(games) and focus_first_card # Build search indices for fast searching self._build_search_indices(games) diff --git a/portprotonqt/input_manager.py b/portprotonqt/input_manager.py index 09d36178..43025ec5 100644 --- a/portprotonqt/input_manager.py +++ b/portprotonqt/input_manager.py @@ -3687,12 +3687,53 @@ class InputManager(QObject): def _get_library_toolbar_widgets(self) -> list[QWidget]: widgets = [] - for attr_name in ("quickLaunchButton", "addGameButton", "refreshButton", "searchEdit"): + for attr_name in ( + "quickLaunchButton", + "addGameButton", + "refreshButton", + "searchEdit", + "libraryControlsButton", + ): widget = getattr(self._parent, attr_name, None) if isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled(): widgets.append(widget) return widgets + def _get_library_filter_widgets(self) -> list[QWidget]: + controls_widget = getattr(self._parent, "libraryControlsWidget", None) + if not isinstance(controls_widget, QWidget) or not controls_widget.isVisible(): + return [] + widgets = [] + for attr_name in ("gamesSortCombo", "gamesDisplayCombo"): + widget = getattr(self._parent, attr_name, None) + if isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled(): + widgets.append(widget) + return widgets + + def _focus_library_filter_controls(self) -> bool: + controls_button = getattr(self._parent, "libraryControlsButton", None) + if not isinstance(controls_button, AutoSizeButton): + return False + if not controls_button.isChecked(): + controls_button.setChecked(True) + toggle_controls = getattr(self._parent, "_toggle_library_controls", None) + if callable(toggle_controls): + toggle_controls() + filter_widgets = self._get_library_filter_widgets() + if not filter_widgets: + return False + filter_widgets[0].setFocus(Qt.FocusReason.OtherFocusReason) + return True + + def _collapse_library_filter_controls(self) -> None: + controls_button = getattr(self._parent, "libraryControlsButton", None) + if not isinstance(controls_button, AutoSizeButton) or not controls_button.isChecked(): + return + controls_button.setChecked(False) + toggle_controls = getattr(self._parent, "_toggle_library_controls", None) + if callable(toggle_controls): + toggle_controls() + def _focus_first_library_card(self) -> bool: container = getattr(self._parent, "gamesListWidget", None) if container is None: @@ -3712,7 +3753,26 @@ class InputManager(QObject): if value == 0 or self._parent.stackedWidget.currentIndex() != 0: return False toolbar_widgets = self._get_library_toolbar_widgets() + filter_widgets = self._get_library_filter_widgets() focused = self._focused_widget() + if focused in filter_widgets: + if code == PAD_DPAD_X: + current_index = filter_widgets.index(cast(QWidget, focused)) + next_index = current_index + (1 if value > 0 else -1) + if 0 <= next_index < len(filter_widgets): + filter_widgets[next_index].setFocus(Qt.FocusReason.OtherFocusReason) + return True + if code != PAD_DPAD_Y: + return False + if value < 0: + controls_button = getattr(self._parent, "libraryControlsButton", None) + if isinstance(controls_button, QWidget): + controls_button.setFocus(Qt.FocusReason.OtherFocusReason) + return True + focused_card = self._focus_first_library_card() + if focused_card: + self._collapse_library_filter_controls() + return focused_card if focused not in toolbar_widgets: return False if code == PAD_DPAD_X: @@ -3724,6 +3784,9 @@ class InputManager(QObject): if code != PAD_DPAD_Y: return False if value > 0: + controls_button = getattr(self._parent, "libraryControlsButton", None) + if focused is controls_button and self._focus_library_filter_controls(): + return True return self._focus_first_library_card() tab_button = self._parent.tabButtons.get(0) if tab_button is not None and tab_button.isVisible(): diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index 16445c6f..7e619397 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -12,7 +12,7 @@ from portprotonqt.logger import get_logger from portprotonqt.icon_extractor import generate_thumbnail from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog, ExeSettingsDialog from portprotonqt.game_card import GameCard -from portprotonqt.animations import DetailPageAnimations +from portprotonqt.animations import DetailPageAnimations, LibraryControlsAnimation from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel, FlowLayout from portprotonqt.detail_pages import DetailPageManager from portprotonqt.portproton_api import PortProtonAPI, get_user_conf_setting, set_user_conf_setting @@ -769,7 +769,9 @@ class MainWindow(MainWindowControlHintsMixin, MainWindowSystemTabMixin, MainWind def on_games_loaded(self, games: list[tuple]): self._loading_games = False self.games = games - self.game_library_manager.set_games(games) + focus_first_card = not getattr(self, "_preserve_library_focus_after_load", False) + self._preserve_library_focus_after_load = False + self.game_library_manager.set_games(games, focus_first_card=focus_first_card) self._known_portproton_desktops = self._get_portproton_desktop_files() # Clear the refresh in progress flag @@ -1220,35 +1222,77 @@ class MainWindow(MainWindowControlHintsMixin, MainWindowSystemTabMixin, MainWind if index == 0 and self.isVisible() and not self.games: self.loadGames(force_load=True) - def createSearchWidget(self) -> tuple[QWidget, CustomLineEdit]: - self.container = QWidget() - self.container.setStyleSheet(self.theme.CONTAINER_STYLE) - layout = QHBoxLayout(self.container) - layout.setContentsMargins(0, 6, 0, 0) - layout.setSpacing(10) + def _set_combo_current_key(self, combo: QComboBox, keys: list[str], current: str) -> None: + try: + idx = keys.index(current) + except ValueError: + idx = 0 + combo.setCurrentIndex(idx) - # Quick Launch button + def _create_library_combo(self, labels: list[str], tooltip: str) -> QComboBox: + combo = QComboBox() + combo.view().window().setWindowFlags( + Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint + ) + combo.view().window().setAttribute( + Qt.WidgetAttribute.WA_TranslucentBackground + ) + combo.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + combo.addItems(labels) + combo.setToolTip(tooltip) + combo_style = getattr(self.theme, "LIBRARY_FILTER_COMBOBOX_STYLE", self.theme.COMBOBOX_STYLE) + combo.setStyleSheet(combo_style + self.theme.SCROLL_STYLE) + combo.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + return combo + + def _on_library_sort_changed(self, index: int) -> None: + if index < 0 or index >= len(self.sort_keys): + return + game_config.set_sort_method(self.sort_keys[index]) + if hasattr(self, "game_library_manager"): + self.game_library_manager.update_game_grid(focus_first_card=False) + + def _on_library_filter_changed(self, index: int) -> None: + if index < 0 or index >= len(self.filter_keys): + return + game_config.set_display_filter(self.filter_keys[index]) + self.searchEdit.clear() + self.games = [] + self._preserve_library_focus_after_load = True + self.loadGames(force_load=True) + + def _toggle_library_controls(self) -> None: + self.libraryControlsAnimation.toggle(self.libraryControlsButton.isChecked()) + + def _create_library_controls_widget(self) -> QHBoxLayout: + self.libraryControlsWidget = QWidget() + controls_layout = QHBoxLayout(self.libraryControlsWidget) + controls_layout.setContentsMargins(0, 0, 0, 0) + controls_layout.setSpacing(10) + return controls_layout + + def _add_library_action_buttons(self, buttons_layout: QHBoxLayout) -> None: self.quickLaunchButton = AutoSizeButton(_("Quick Launch"), icon=self.theme_manager.get_icon("play", as_path=True)) self.quickLaunchButton.setStyleSheet(self.theme.ADDGAME_BACK_BUTTON_STYLE) self.quickLaunchButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.quickLaunchButton.clicked.connect(self.quickLaunch) - layout.addWidget(self.quickLaunchButton) + buttons_layout.addWidget(self.quickLaunchButton) self.addGameButton = AutoSizeButton(_("Add a shortcut"), icon=self.theme_manager.get_icon("addgame", as_path=True)) self.addGameButton.setStyleSheet(self.theme.ADDGAME_BACK_BUTTON_STYLE) self.addGameButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.addGameButton.clicked.connect(self.openAddGameDialog) - layout.addWidget(self.addGameButton) + buttons_layout.addWidget(self.addGameButton) # Refresh button self.refreshButton = AutoSizeButton(_("Refresh Grid"), icon=self.theme_manager.get_icon("update", as_path=True)) self.refreshButton.setStyleSheet(self.theme.ADDGAME_BACK_BUTTON_STYLE) self.refreshButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.refreshButton.clicked.connect(self.refreshGames) - layout.addWidget(self.refreshButton) - - layout.addStretch() # Add stretch to push search to the right + buttons_layout.addWidget(self.refreshButton) + buttons_layout.addStretch() + def _add_library_search(self, buttons_layout: QHBoxLayout) -> None: self.searchEdit = CustomLineEdit(self, theme=self.theme) icon: QIcon = cast(QIcon, self.theme_manager.get_icon("search")) action_pos = cast(QLineEdit.ActionPosition, QLineEdit.ActionPosition.LeadingPosition) @@ -1263,8 +1307,71 @@ class MainWindow(MainWindowControlHintsMixin, MainWindowSystemTabMixin, MainWind self.searchDebounceTimer.setSingleShot(True) self.searchDebounceTimer.setInterval(150) # Reduced debounce time for better responsiveness self.searchDebounceTimer.timeout.connect(self.on_search_changed) + buttons_layout.addWidget(self.searchEdit) - layout.addWidget(self.searchEdit) + def _add_library_controls_button(self, buttons_layout: QHBoxLayout) -> None: + self.libraryControlsButton = AutoSizeButton( + icon=self.theme_manager.get_icon("menu", as_path=True) + ) + button_style = getattr( + self.theme, + "LIBRARY_CONTROLS_BUTTON_STYLE", + self.theme.ADDGAME_BACK_BUTTON_STYLE, + ) + self.libraryControlsButton.setStyleSheet(button_style) + self.libraryControlsButton.setToolTip(_("Library Settings")) + self.libraryControlsButton.setCheckable(True) + self.libraryControlsButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.libraryControlsButton.clicked.connect(self._toggle_library_controls) + buttons_layout.addWidget(self.libraryControlsButton) + + def _add_library_filter_controls(self, controls_layout: QHBoxLayout) -> None: + self.sort_keys = ["last_launch", "playtime", "alphabetical"] + self.sort_labels = [_("Last launch"), _("Time spent"), _("Alphabetical")] + self.gamesSortCombo = self._create_library_combo(self.sort_labels, _("Sort Method:")) + self._set_combo_current_key( + self.gamesSortCombo, + self.sort_keys, + game_config.get_sort_method(), + ) + self.gamesSortCombo.currentIndexChanged.connect(self._on_library_sort_changed) + controls_layout.addStretch() + controls_layout.addWidget(self.gamesSortCombo) + + self.filter_keys = ["all", "steam", "portproton", "favorites"] + self.filter_labels = [_("All"), "Steam", "PortProton", _("Favorites")] + self.gamesDisplayCombo = self._create_library_combo(self.filter_labels, _("Display Filter:")) + self._set_combo_current_key( + self.gamesDisplayCombo, + self.filter_keys, + game_config.get_display_filter(), + ) + self.gamesDisplayCombo.currentIndexChanged.connect(self._on_library_filter_changed) + controls_layout.addWidget(self.gamesDisplayCombo) + + def createSearchWidget(self) -> tuple[QWidget, CustomLineEdit]: + self.container = QWidget() + self.container.setStyleSheet(self.theme.CONTAINER_STYLE) + layout = QVBoxLayout(self.container) + layout.setContentsMargins(0, 6, 0, 0) + layout.setSpacing(10) + buttons_layout = QHBoxLayout() + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.setSpacing(10) + controls_layout = self._create_library_controls_widget() + + self._add_library_action_buttons(buttons_layout) + self._add_library_search(buttons_layout) + self.libraryControlsAnimation = LibraryControlsAnimation( + self.libraryControlsWidget, + self.theme, + self.searchDebounceTimer.interval(), + ) + self._add_library_controls_button(buttons_layout) + self._add_library_filter_controls(controls_layout) + self.libraryControlsAnimation.setup_hidden() + layout.addLayout(buttons_layout) + layout.addWidget(self.libraryControlsWidget) return self.container, self.searchEdit def refreshGames(self): @@ -2377,56 +2484,6 @@ class MainWindow(MainWindowControlHintsMixin, MainWindowSystemTabMixin, MainWind self.timeDetailCombo.setCurrentIndex(idx) genForm.addRow(self.timeDetailTitle, self.timeDetailCombo) - self.gamesSortCombo = QComboBox() - self.gamesSortCombo.view().window().setWindowFlags( - Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint - ) - self.gamesSortCombo.view().window().setAttribute( - Qt.WidgetAttribute.WA_TranslucentBackground - ) - self.gamesSortCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.sort_keys = ["last_launch", "playtime", "alphabetical"] - self.sort_labels = [_("Last launch"), _("Time spent"), _("Alphabetical")] - self.gamesSortCombo.addItems(self.sort_labels) - self.gamesSortCombo.setStyleSheet(self.theme.COMBOBOX_STYLE + self.theme.SCROLL_STYLE) - self.gamesSortCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus) - self.gamesSortTitle = QLabel(_("Sort Method:")) - self.gamesSortTitle.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) - self.gamesSortTitle.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE) - self.gamesSortTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) - current = game_config.get_sort_method() - try: - idx = self.sort_keys.index(current) - except ValueError: - idx = 0 - self.gamesSortCombo.setCurrentIndex(idx) - genForm.addRow(self.gamesSortTitle, self.gamesSortCombo) - - self.filter_keys = ["all", "steam", "portproton", "favorites"] - self.filter_labels = [_("All"), "Steam", "PortProton", _("Favorites")] - self.gamesDisplayCombo = QComboBox() - self.gamesDisplayCombo.view().window().setWindowFlags( - Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint - ) - self.gamesDisplayCombo.view().window().setAttribute( - Qt.WidgetAttribute.WA_TranslucentBackground - ) - self.gamesDisplayCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.gamesDisplayCombo.addItems(self.filter_labels) - self.gamesDisplayCombo.setStyleSheet(self.theme.COMBOBOX_STYLE + self.theme.SCROLL_STYLE) - self.gamesDisplayCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus) - self.gamesDisplayTitle = QLabel(_("Display Filter:")) - self.gamesDisplayTitle.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) - self.gamesDisplayTitle.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE) - self.gamesDisplayTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) - current = game_config.get_display_filter() - try: - idx = self.filter_keys.index(current) - except ValueError: - idx = 0 - self.gamesDisplayCombo.setCurrentIndex(idx) - genForm.addRow(self.gamesDisplayTitle, self.gamesDisplayCombo) - self.badge_view_keys = ["detailed", "compact", "hidden"] self.badge_view_labels = [_("Detailed"), _("Compact"), _("Hidden")] self.badgeViewCombo = QComboBox() @@ -2904,16 +2961,9 @@ class MainWindow(MainWindowControlHintsMixin, MainWindowSystemTabMixin, MainWind time_key = self.time_keys[time_idx] ui_config.set_time_detail_level(time_key) - sort_idx = self.gamesSortCombo.currentIndex() - sort_key = self.sort_keys[sort_idx] - game_config.set_sort_method(sort_key) - - filter_idx = self.gamesDisplayCombo.currentIndex() - filter_key = self.filter_keys[filter_idx] economy_mode = self.economyModeCheckBox.isChecked() ui_config.set_economy_mode(economy_mode) economy_mode_changed = previous_economy_mode != economy_mode - game_config.set_display_filter(filter_key) badge_view_idx = self.badgeViewCombo.currentIndex() badge_view_mode = self.badge_view_keys[badge_view_idx] if economy_mode: @@ -2989,8 +3039,9 @@ class MainWindow(MainWindowControlHintsMixin, MainWindowSystemTabMixin, MainWind if self.game_library_manager.gamesListLayout is not None: self.game_library_manager.clear_layout(self.game_library_manager.gamesListLayout) else: + display_filter = game_config.get_display_filter() for card in self.game_library_manager.game_card_cache.values(): - card.update_badge_visibility(filter_key) + card.update_badge_visibility(display_filter) card.update_badge_view_mode(badge_view_mode) if ( diff --git a/portprotonqt/meson.build b/portprotonqt/meson.build index a8b563c7..8a1b5b75 100644 --- a/portprotonqt/meson.build +++ b/portprotonqt/meson.build @@ -38,6 +38,7 @@ install_data( ['animations/__init__.py', 'animations/detail_page.py', 'animations/game_card.py', + 'animations/library_controls.py', 'animations/virtual_keyboard.py', ], install_dir: pythondir / meson.project_name() / 'animations', diff --git a/portprotonqt/themes/classic-light/styles.py b/portprotonqt/themes/classic-light/styles.py index 58b42677..d74aa53c 100644 --- a/portprotonqt/themes/classic-light/styles.py +++ b/portprotonqt/themes/classic-light/styles.py @@ -271,6 +271,96 @@ ADDGAME_BACK_BUTTON_STYLE = f""" }} """ +LIBRARY_CONTROLS_BUTTON_STYLE = f""" + QPushButton {{ + background: qradialgradient( + cx:0.5, cy:0.5, radius:0.8, + stop:0 #E9ECEF, + stop:1 {color_j} + ); + border: {border_b} {color_f}; + border-radius: {border_radius_a}; + color: {color_l}; + font-size: {font_size_a}; + font-family: '{font_family}'; + padding: 5px 10px; + min-width: 24px; + min-height: 25px; + }} + QPushButton:hover, + QPushButton:pressed, + QPushButton:focus, + QPushButton:checked {{ + background: qradialgradient( + cx:0.5, cy:0.5, radius:0.8, + stop:0 #E9ECEF, + stop:1 {color_j} + ); + border: {border_b} {color_a}; + }} +""" + +LIBRARY_FILTER_COMBOBOX_STYLE = f""" + QComboBox {{ + background: {color_b}; + border: {border_b} {color_f}; + border-radius: {border_radius_a}; + padding-left: 12px; + height: 30px; + color: {color_l}; + font-family: '{font_family}'; + font-size: {font_size_a}; + min-width: 120px; + combobox-popup: 0; + }} + QComboBox:hover, + QComboBox:focus, + QComboBox:on {{ + background: {color_b}; + border: {border_b} {color_a}; + }} + QComboBox::drop-down {{ + subcontrol-origin: padding; + subcontrol-position: center right; + border-left: {border_b} rgba(255, 255, 255, 0.05); + padding: 12px; + height: 12px; + width: 12px; + }} + QComboBox::down-arrow {{ + image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)}); + padding: 12px; + height: 10px; + width: 10px; + }} + QComboBox::down-arrow:on {{ + image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)}); + padding: 12px; + height: 10px; + width: 10px; + }} + QComboBox QAbstractItemView {{ + outline: none; + background: {color_c}; + border: {border_b} {color_a}; + border-radius: {border_radius_a}; + color: {color_l}; + selection-background-color: {color_a}; + selection-color: {color_l}; + }} + QComboBox QAbstractItemView::item {{ + background: {color_c}; + color: {color_l}; + padding: 6px 10px; + min-height: 24px; + }} + QComboBox QAbstractItemView::item:hover, + QComboBox QAbstractItemView::item:selected {{ + background: {color_a}; + color: {color_l}; + }} +""" + # SEARCH FIELD STYLE SEARCH_EDIT_STYLE = f""" QLineEdit {{ diff --git a/portprotonqt/themes/classic/styles.py b/portprotonqt/themes/classic/styles.py index a5dd56cf..4e617338 100644 --- a/portprotonqt/themes/classic/styles.py +++ b/portprotonqt/themes/classic/styles.py @@ -201,6 +201,96 @@ ADDGAME_BACK_BUTTON_STYLE = f""" }} """ +LIBRARY_CONTROLS_BUTTON_STYLE = f""" + QPushButton {{ + background: qradialgradient( + cx:0.5, cy:0.5, radius:0.8, + stop:0 #404554, + stop:1 #30323d + ); + border: {border_a}; + border-radius: {border_radius_a}; + color: {color_f}; + font-size: {font_size_a}; + font-family: '{font_family}'; + padding: 5px 10px; + min-width: 24px; + min-height: 25px; + }} + QPushButton:hover, + QPushButton:pressed, + QPushButton:focus, + QPushButton:checked {{ + background: qradialgradient( + cx:0.5, cy:0.5, radius:0.8, + stop:0 #404554, + stop:1 #30323d + ); + border: {border_b} {color_a}; + }} +""" + +LIBRARY_FILTER_COMBOBOX_STYLE = f""" + QComboBox {{ + background: {color_b}; + border: {border_a}; + border-radius: {border_radius_a}; + padding-left: 12px; + height: 30px; + color: {color_f}; + font-family: '{font_family}'; + font-size: {font_size_a}; + min-width: 120px; + combobox-popup: 0; + }} + QComboBox:hover, + QComboBox:focus, + QComboBox:on {{ + background: {color_b}; + border: {border_b} {color_a}; + }} + QComboBox::drop-down {{ + subcontrol-origin: padding; + subcontrol-position: center right; + border-left: {border_b} rgba(255, 255, 255, 0.05); + padding: 12px; + height: 12px; + width: 12px; + }} + QComboBox::down-arrow {{ + image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)}); + padding: 12px; + height: 10px; + width: 10px; + }} + QComboBox::down-arrow:on {{ + image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)}); + padding: 12px; + height: 10px; + width: 10px; + }} + QComboBox QAbstractItemView {{ + outline: none; + background: {color_c}; + border: {border_b} {color_a}; + border-radius: {border_radius_a}; + color: {color_f}; + selection-background-color: {color_a}; + selection-color: {color_f}; + }} + QComboBox QAbstractItemView::item {{ + background: {color_c}; + color: {color_f}; + padding: 6px 10px; + min-height: 24px; + }} + QComboBox QAbstractItemView::item:hover, + QComboBox QAbstractItemView::item:selected {{ + background: {color_a}; + color: {color_f}; + }} +""" + # SEARCH FIELD STYLE SEARCH_EDIT_STYLE = f""" QLineEdit {{ diff --git a/portprotonqt/themes/standart-light/styles.py b/portprotonqt/themes/standart-light/styles.py index 8d4c2acc..99dd4d0a 100644 --- a/portprotonqt/themes/standart-light/styles.py +++ b/portprotonqt/themes/standart-light/styles.py @@ -788,6 +788,96 @@ ADDGAME_BACK_BUTTON_STYLE = f""" }} """ +LIBRARY_CONTROLS_BUTTON_STYLE = f""" + QPushButton {{ + background: qradialgradient( + cx:0.5, cy:0.5, radius:0.8, + stop:0 #E9ECEF, + stop:1 {color_j} + ); + border: {border_b} {color_f}; + border-radius: {border_radius_a}; + color: {color_l}; + font-size: {font_size_a}; + font-family: '{font_family}'; + padding: 5px 10px; + min-width: 24px; + min-height: 25px; + }} + QPushButton:hover, + QPushButton:pressed, + QPushButton:focus, + QPushButton:checked {{ + background: qradialgradient( + cx:0.5, cy:0.5, radius:0.8, + stop:0 #E9ECEF, + stop:1 {color_j} + ); + border: {border_b} {color_a}; + }} +""" + +LIBRARY_FILTER_COMBOBOX_STYLE = f""" + QComboBox {{ + background: {color_b}; + border: {border_b} {color_f}; + border-radius: {border_radius_a}; + padding-left: 12px; + height: 34px; + color: {color_l}; + font-family: '{font_family}'; + font-size: {font_size_a}; + min-width: 120px; + combobox-popup: 0; + }} + QComboBox:hover, + QComboBox:focus, + QComboBox:on {{ + background: {color_b}; + border: {border_b} {color_a}; + }} + QComboBox::drop-down {{ + subcontrol-origin: padding; + subcontrol-position: center right; + border-left: {border_b} rgba(255, 255, 255, 0.05); + padding: 12px; + height: 12px; + width: 12px; + }} + QComboBox::down-arrow {{ + image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)}); + padding: 12px; + height: 12px; + width: 12px; + }} + QComboBox::down-arrow:on {{ + image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)}); + padding: 12px; + height: 12px; + width: 12px; + }} + QComboBox QAbstractItemView {{ + outline: none; + background: {color_c}; + border: {border_b} {color_a}; + border-radius: {border_radius_a}; + color: {color_l}; + selection-background-color: {color_a}; + selection-color: {color_l}; + }} + QComboBox QAbstractItemView::item {{ + background: {color_c}; + color: {color_l}; + padding: 6px 10px; + min-height: 24px; + }} + QComboBox QAbstractItemView::item:hover, + QComboBox QAbstractItemView::item:selected {{ + background: {color_a}; + color: {color_l}; + }} +""" + # SEARCH FIELD STYLE SEARCH_EDIT_STYLE = f""" QLineEdit {{ diff --git a/portprotonqt/themes/standart/styles/detail_page.py b/portprotonqt/themes/standart/styles/detail_page.py index ec57334b..6e469a1d 100644 --- a/portprotonqt/themes/standart/styles/detail_page.py +++ b/portprotonqt/themes/standart/styles/detail_page.py @@ -26,6 +26,87 @@ ADDGAME_BACK_BUTTON_STYLE = f""" }} """ +LIBRARY_CONTROLS_BUTTON_STYLE = f""" + QPushButton {{ + background: rgba(20, 20, 20, 0.40); + border: {border_b} rgba(255, 255, 255, 0.5); + border-radius: {border_radius_a}; + color: {color_f}; + font-size: {font_size_a}; + font-family: '{font_family}'; + padding: 8px 12px; + min-width: 24px; + }} + QPushButton:hover, + QPushButton:pressed, + QPushButton:focus, + QPushButton:checked {{ + background: rgba(20, 20, 20, 0.40); + border: {border_b} {color_a}; + }} +""" + +LIBRARY_FILTER_COMBOBOX_STYLE = f""" + QComboBox {{ + background: rgba(20, 20, 20, 0.40); + border: {border_b} rgba(255, 255, 255, 0.5); + border-radius: {border_radius_a}; + padding-left: 12px; + height: 34px; + color: {color_f}; + font-family: '{font_family}'; + font-size: {font_size_a}; + min-width: 120px; + combobox-popup: 0; + }} + QComboBox:hover, + QComboBox:focus, + QComboBox:on {{ + background: rgba(20, 20, 20, 0.40); + border: {border_b} {color_a}; + }} + QComboBox::drop-down {{ + subcontrol-origin: padding; + subcontrol-position: center right; + border-left: {border_b} rgba(255, 255, 255, 0.05); + padding: 12px; + height: 12px; + width: 12px; + }} + QComboBox::down-arrow {{ + image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)}); + padding: 12px; + height: 12px; + width: 12px; + }} + QComboBox::down-arrow:on {{ + image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)}); + padding: 12px; + height: 12px; + width: 12px; + }} + QComboBox QAbstractItemView {{ + outline: none; + background: {color_c}; + border: {border_b} {color_a}; + border-radius: {border_radius_a}; + color: {color_f}; + selection-background-color: {color_a}; + selection-color: {color_f}; + }} + QComboBox QAbstractItemView::item {{ + background: {color_c}; + color: {color_f}; + padding: 6px 10px; + min-height: 24px; + }} + QComboBox QAbstractItemView::item:hover, + QComboBox QAbstractItemView::item:selected {{ + background: {color_a}; + color: {color_f}; + }} +""" + # MAIN FRAME FOR GAME DETAILS DETAIL_CONTENT_FRAME_STYLE = f""" QFrame {{ -- 2.54.0 From 2c43f0d23dc148606d0fee4adb7b4922fba826e3 Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Mon, 8 Jun 2026 21:21:47 +0500 Subject: [PATCH 2/3] feat: make search compat Signed-off-by: Boris Yumankulov --- portprotonqt/animations/__init__.py | 3 +- portprotonqt/animations/library_controls.py | 42 +++++++++++++++ portprotonqt/main_window.py | 57 +++++++++++++++++++-- 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/portprotonqt/animations/__init__.py b/portprotonqt/animations/__init__.py index 8439740b..1fa21817 100644 --- a/portprotonqt/animations/__init__.py +++ b/portprotonqt/animations/__init__.py @@ -1,10 +1,11 @@ from portprotonqt.animations.detail_page import DetailPageAnimations from portprotonqt.animations.game_card import GameCardAnimations -from portprotonqt.animations.library_controls import LibraryControlsAnimation +from portprotonqt.animations.library_controls import ExpandingSearchAnimation, LibraryControlsAnimation from portprotonqt.animations.virtual_keyboard import VirtualKeyboardAnimations __all__ = [ "DetailPageAnimations", + "ExpandingSearchAnimation", "GameCardAnimations", "LibraryControlsAnimation", "VirtualKeyboardAnimations", diff --git a/portprotonqt/animations/library_controls.py b/portprotonqt/animations/library_controls.py index a727d142..708276af 100644 --- a/portprotonqt/animations/library_controls.py +++ b/portprotonqt/animations/library_controls.py @@ -8,6 +8,48 @@ from PySide6.QtCore import ( from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget +class ExpandingSearchAnimation: + def __init__(self, widget: QWidget, theme: object, fallback_duration: int): + self.widget = widget + self.theme = theme + self.fallback_duration = fallback_duration + self.animation = QPropertyAnimation(widget, QByteArray(b"maximumWidth")) + + def setup(self, collapsed_width: int, expanded_width: int) -> None: + self.collapsed_width = collapsed_width + self.expanded_width = expanded_width + self.widget.setMaximumWidth(collapsed_width) + + def expand(self) -> None: + self._start(self.expanded_width) + + def collapse(self) -> None: + if getattr(self.widget, "text", lambda: "")(): + return + self._start(self.collapsed_width) + + def _start(self, target_width: int) -> None: + if self.animation.state() == QAbstractAnimation.State.Running: + self.animation.stop() + self.animation = QPropertyAnimation(self.widget, QByteArray(b"maximumWidth")) + self.animation.setDuration(self._duration()) + self.animation.setStartValue(self.widget.maximumWidth()) + self.animation.setEndValue(target_width) + self.animation.setEasingCurve(self._easing(target_width == self.expanded_width)) + self.animation.start() + + def _duration(self) -> int: + animation_config = getattr(self.theme, "GAME_CARD_ANIMATION", {}) + return animation_config.get("scale_anim_duration", self.fallback_duration) + + def _easing(self, opening: bool) -> QEasingCurve: + animation_config = getattr(self.theme, "GAME_CARD_ANIMATION", {}) + key = "scale_easing_curve" if opening else "scale_easing_curve_out" + easing_name = animation_config.get(key, QEasingCurve.Type.InOutQuad.name) + easing_type = getattr(QEasingCurve.Type, easing_name, QEasingCurve.Type.InOutQuad) + return QEasingCurve(easing_type) + + class LibraryControlsAnimation: def __init__(self, widget: QWidget, theme: object, fallback_duration: int): self.widget = widget diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index 7e619397..7881e587 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -12,7 +12,7 @@ from portprotonqt.logger import get_logger from portprotonqt.icon_extractor import generate_thumbnail from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog, ExeSettingsDialog from portprotonqt.game_card import GameCard -from portprotonqt.animations import DetailPageAnimations, LibraryControlsAnimation +from portprotonqt.animations import DetailPageAnimations, ExpandingSearchAnimation, LibraryControlsAnimation from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel, FlowLayout from portprotonqt.detail_pages import DetailPageManager from portprotonqt.portproton_api import PortProtonAPI, get_user_conf_setting, set_user_conf_setting @@ -75,7 +75,7 @@ from portprotonqt.tabs.workers import MainWindowWorkersMixin from portprotonqt.settings_manager import get_available_prefix_options, get_available_wine_options from PySide6.QtWidgets import (QLineEdit, QMainWindow, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, - QDialog, QFormLayout, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller, QSlider, QFrame) + QDialog, QFormLayout, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller, QSlider, QFrame, QToolButton) from PySide6.QtCore import Qt, QAbstractAnimation, QEvent, QUrl, Signal, QTimer, Slot, QProcess, QProcessEnvironment, QFileSystemWatcher, QStandardPaths from PySide6.QtGui import QIcon, QColor, QDesktopServices, QHideEvent, QShowEvent from typing import cast @@ -1296,7 +1296,7 @@ class MainWindow(MainWindowControlHintsMixin, MainWindowSystemTabMixin, MainWind self.searchEdit = CustomLineEdit(self, theme=self.theme) icon: QIcon = cast(QIcon, self.theme_manager.get_icon("search")) action_pos = cast(QLineEdit.ActionPosition, QLineEdit.ActionPosition.LeadingPosition) - self.searchEdit.addAction(icon, action_pos) + self.searchIconAction = self.searchEdit.addAction(icon, action_pos) self.searchEdit.setMaximumWidth(200) self.searchEdit.setPlaceholderText(_("Search ...")) self.searchEdit.setClearButtonEnabled(True) @@ -1307,6 +1307,15 @@ class MainWindow(MainWindowControlHintsMixin, MainWindowSystemTabMixin, MainWind self.searchDebounceTimer.setSingleShot(True) self.searchDebounceTimer.setInterval(150) # Reduced debounce time for better responsiveness self.searchDebounceTimer.timeout.connect(self.on_search_changed) + self.searchEdit.focusInEvent = self._wrap_search_focus_event( + self.searchEdit.focusInEvent, + True, + ) + self.searchEdit.focusOutEvent = self._wrap_search_focus_event( + self.searchEdit.focusOutEvent, + False, + ) + self.searchEdit.resizeEvent = self._wrap_search_resize_event(self.searchEdit.resizeEvent) buttons_layout.addWidget(self.searchEdit) def _add_library_controls_button(self, buttons_layout: QHBoxLayout) -> None: @@ -1325,6 +1334,47 @@ class MainWindow(MainWindowControlHintsMixin, MainWindowSystemTabMixin, MainWind self.libraryControlsButton.clicked.connect(self._toggle_library_controls) buttons_layout.addWidget(self.libraryControlsButton) + def _setup_library_search_animation(self) -> None: + self.searchAnimation = ExpandingSearchAnimation( + self.searchEdit, + self.theme, + self.searchDebounceTimer.interval(), + ) + collapsed_width = self.libraryControlsButton.sizeHint().width() + expanded_width = self.searchEdit.maximumWidth() + self.searchAnimation.setup(collapsed_width, expanded_width) + QTimer.singleShot(0, self._center_collapsed_search_icon) + + def _wrap_search_focus_event(self, original_event: Callable, expand: bool) -> Callable: + def handle_focus_event(event): + original_event(event) + if not hasattr(self, "searchAnimation"): + return + if expand: + self.searchAnimation.expand() + else: + self.searchAnimation.collapse() + QTimer.singleShot(0, self._center_collapsed_search_icon) + return handle_focus_event + + def _wrap_search_resize_event(self, original_event: Callable) -> Callable: + def handle_resize_event(event): + original_event(event) + self._center_collapsed_search_icon() + return handle_resize_event + + def _center_collapsed_search_icon(self) -> None: + animation = getattr(self, "searchAnimation", None) + if animation is None or self.searchEdit.maximumWidth() != animation.collapsed_width: + return + for button in self.searchEdit.findChildren(QToolButton): + if button.defaultAction() is self.searchIconAction: + size = button.sizeHint() + x = (self.searchEdit.width() - size.width()) // 2 + y = (self.searchEdit.height() - size.height()) // 2 + button.setGeometry(x, y, size.width(), size.height()) + return + def _add_library_filter_controls(self, controls_layout: QHBoxLayout) -> None: self.sort_keys = ["last_launch", "playtime", "alphabetical"] self.sort_labels = [_("Last launch"), _("Time spent"), _("Alphabetical")] @@ -1368,6 +1418,7 @@ class MainWindow(MainWindowControlHintsMixin, MainWindowSystemTabMixin, MainWind self.searchDebounceTimer.interval(), ) self._add_library_controls_button(buttons_layout) + self._setup_library_search_animation() self._add_library_filter_controls(controls_layout) self.libraryControlsAnimation.setup_hidden() layout.addLayout(buttons_layout) -- 2.54.0 From 4334b02acabe4bd2e0c4dd3503729a6315aef2f6 Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Mon, 8 Jun 2026 21:39:49 +0500 Subject: [PATCH 3/3] fix: collapse filters on card hover Signed-off-by: Boris Yumankulov --- portprotonqt/game_library_manager.py | 12 ++++++++++++ portprotonqt/input_manager.py | 14 +------------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/portprotonqt/game_library_manager.py b/portprotonqt/game_library_manager.py index a908321a..00dd7226 100644 --- a/portprotonqt/game_library_manager.py +++ b/portprotonqt/game_library_manager.py @@ -1,6 +1,7 @@ from typing import Protocol from portprotonqt.game_card import GameCard from portprotonqt.search_utils import SearchOptimizer, ThreadedSearch +from PySide6.QtWidgets import QAbstractButton from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller, QStackedWidget from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QRegion @@ -197,6 +198,7 @@ class GameLibraryManager: card = self.game_card_cache[card_key] if is_focused: + self._collapse_library_filter_controls() if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card: try: self.main_window.current_hovered_card._hovered = False @@ -215,6 +217,15 @@ class GameLibraryManager: if self.main_window.current_focused_card == card: self.main_window.current_focused_card = None + def _collapse_library_filter_controls(self) -> None: + controls_button = getattr(self.main_window, "libraryControlsButton", None) + if not isinstance(controls_button, QAbstractButton) or not controls_button.isChecked(): + return + controls_button.setChecked(False) + toggle_controls = getattr(self.main_window, "_toggle_library_controls", None) + if callable(toggle_controls): + toggle_controls() + def _on_card_hovered(self, game_name: str, is_hovered: bool): """Handles card hover events.""" card_key = None @@ -229,6 +240,7 @@ class GameLibraryManager: card = self.game_card_cache[card_key] if is_hovered: + self._collapse_library_filter_controls() if self.main_window.current_focused_card and self.main_window.current_focused_card != card: try: if self.main_window.current_focused_card: diff --git a/portprotonqt/input_manager.py b/portprotonqt/input_manager.py index 43025ec5..6e2aeaf9 100644 --- a/portprotonqt/input_manager.py +++ b/portprotonqt/input_manager.py @@ -3725,15 +3725,6 @@ class InputManager(QObject): filter_widgets[0].setFocus(Qt.FocusReason.OtherFocusReason) return True - def _collapse_library_filter_controls(self) -> None: - controls_button = getattr(self._parent, "libraryControlsButton", None) - if not isinstance(controls_button, AutoSizeButton) or not controls_button.isChecked(): - return - controls_button.setChecked(False) - toggle_controls = getattr(self._parent, "_toggle_library_controls", None) - if callable(toggle_controls): - toggle_controls() - def _focus_first_library_card(self) -> bool: container = getattr(self._parent, "gamesListWidget", None) if container is None: @@ -3769,10 +3760,7 @@ class InputManager(QObject): if isinstance(controls_button, QWidget): controls_button.setFocus(Qt.FocusReason.OtherFocusReason) return True - focused_card = self._focus_first_library_card() - if focused_card: - self._collapse_library_filter_controls() - return focused_card + return self._focus_first_library_card() if focused not in toolbar_widgets: return False if code == PAD_DPAD_X: -- 2.54.0