From 792e52d9818e1169145f9b950710a19458d67bb1 Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Thu, 16 Oct 2025 10:39:24 +0500 Subject: [PATCH] feat(dialogs): added controller hints Signed-off-by: Boris Yumankulov --- portprotonqt/dialogs.py | 193 ++++++++++++++++++++++++++++++-- portprotonqt/input_manager.py | 202 ++++++++++++++++++++++++++++------ portprotonqt/main_window.py | 4 + 3 files changed, 356 insertions(+), 43 deletions(-) diff --git a/portprotonqt/dialogs.py b/portprotonqt/dialogs.py index e44b927..424535f 100644 --- a/portprotonqt/dialogs.py +++ b/portprotonqt/dialogs.py @@ -91,6 +91,130 @@ def generate_thumbnail(inputfile, outfile, size=128, force_resize=True): logger.error(f"Ошибка при сохранении миниатюры: {e}") return False +def create_dialog_hints_widget(theme, main_window, input_manager, context='default'): + """ + Common function to create hints widget for all dialogs. + Uses main_window for get_button_icon/get_nav_icon, input_manager for gamepad detection. + """ + theme_manager = ThemeManager() + current_theme_name = read_theme_from_config() + + hintsWidget = QWidget() + hintsWidget.setStyleSheet(theme.STATUS_BAR_STYLE) + hintsLayout = QHBoxLayout(hintsWidget) + hintsLayout.setContentsMargins(10, 0, 10, 0) + hintsLayout.setSpacing(20) + + dialog_actions = [] + + # Context-specific actions (gamepad only, no keyboard) + if context == 'file_explorer': + dialog_actions = [ + ("confirm", _("Open")), # A / Cross + ("add_game", _("Select Dir")), # X / Triangle + ("prev_dir", _("Prev Dir")), # Y / Square + ("back", _("Cancel")), # B / Circle + ("context_menu", _("Menu")), # Start / Options + ] + elif context == 'winetricks': + dialog_actions = [ + ("confirm", _("Toggle")), # A / Cross + ("add_game", _("Install")), # X / Triangle + ("prev_dir", _("Force Install")), # Y / Square + ("back", _("Cancel")), # B / Circle + ("prev_tab", _("Prev Tab")), # LB / L1 + ("next_tab", _("Next Tab")), # RB / R1 + ] + + hints_labels = [] # Store for updates (returned for class storage) + + def make_hint(icon_name, text, action=None): + container = QWidget() + hlayout = QHBoxLayout(container) + hlayout.setContentsMargins(0, 5, 0, 0) + hlayout.setSpacing(6) + + icon_label = QLabel() + icon_label.setFixedSize(26, 26) + icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + pixmap = QPixmap() + icon_path = theme_manager.get_theme_image(icon_name, current_theme_name) + if icon_path: + pixmap.load(str(icon_path)) + if not pixmap.isNull(): + icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) + + hlayout.addWidget(icon_label) + + text_label = QLabel(text) + text_label.setStyleSheet(theme.LAST_LAUNCH_VALUE_STYLE) + text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) + hlayout.addWidget(text_label) + + # Initially hidden; show only if gamepad connected + container.setVisible(False) + hints_labels.append((container, icon_label, action)) + + hintsLayout.addWidget(container) + + # Add gamepad hints only + for action, text in dialog_actions: + make_hint("placeholder", text, action) + + hintsLayout.addStretch() + + # Return widget and labels for class storage + return hintsWidget, hints_labels + +def update_dialog_hints(hints_labels, main_window, input_manager, theme_manager, current_theme_name): + """ + Common function to update hints for any dialog. + """ + if not input_manager or not main_window: + # Hide all if no input_manager or main_window + for container, _, _ in hints_labels: + container.setVisible(False) + return + + is_gamepad = input_manager.gamepad is not None + if not is_gamepad: + # Hide all hints if no gamepad + for container, _, _ in hints_labels: + container.setVisible(False) + return + + gtype = input_manager.gamepad_type + gamepad_actions = ['confirm', 'back', 'context_menu', 'add_game', 'prev_dir', 'prev_tab', 'next_tab'] + + for container, icon_label, action in hints_labels: + if action and action in gamepad_actions: + container.setVisible(True) + # Update icon using main_window methods + if action in ['confirm', 'back', 'context_menu', 'add_game', 'prev_dir']: + icon_name = main_window.get_button_icon(action, gtype) + else: # only prev_tab/next_tab (treat as nav) + direction = 'left' if action == 'prev_tab' else 'right' + icon_name = main_window.get_nav_icon(direction, gtype) + icon_path = theme_manager.get_theme_image(icon_name, current_theme_name) + pixmap = QPixmap() + if icon_path: + pixmap.load(str(icon_path)) + if not pixmap.isNull(): + icon_label.setPixmap(pixmap.scaled( + 26, 26, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + )) + else: + # Fallback to placeholder + placeholder = theme_manager.get_theme_image("placeholder", current_theme_name) + if placeholder: + pixmap.load(str(placeholder)) + icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) + else: + container.setVisible(False) + class FileSelectedSignal(QObject): file_selected = Signal(str) # Сигнал с путем к выбранному файлу @@ -185,6 +309,7 @@ class FileExplorer(QDialog): self.initial_path = initial_path # Store initial path if provided self.thumbnail_cache = {} # Cache for loaded thumbnails self.pending_thumbnails = set() # Track files pending thumbnail loading + self.main_window = None # Add reference to MainWindow self.setup_ui() # Window settings @@ -198,6 +323,7 @@ class FileExplorer(QDialog): while parent: if hasattr(parent, 'input_manager'): self.input_manager = cast("MainWindow", parent).input_manager + self.main_window = parent if hasattr(parent, 'context_menu_manager'): self.context_menu_manager = cast("MainWindow", parent).context_menu_manager parent = parent.parent() @@ -214,6 +340,17 @@ class FileExplorer(QDialog): self.current_path = os.path.expanduser("~") # Fallback to home if initial path is invalid self.update_file_list() + # Create hints widget using common function + self.current_theme_name = read_theme_from_config() + self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='file_explorer') + self.main_layout.addWidget(self.hints_widget) + + # Connect signals + if self.input_manager: + self.input_manager.button_event.connect(lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)) + self.input_manager.dpad_moved.connect(lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)) + update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name) + class ThumbnailLoader(QRunnable): """Class for asynchronous thumbnail loading in a separate thread.""" class Signals(QObject): @@ -1037,8 +1174,6 @@ Icon={icon_path} return desktop_entry, desktop_path class WinetricksDialog(QDialog): - """Dialog for managing Winetricks components in a prefix.""" - def __init__(self, parent=None, theme=None, prefix_path: str | None = None, wine_use: str | None = None): super().__init__(parent) self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config()) @@ -1071,6 +1206,36 @@ class WinetricksDialog(QDialog): self.setup_ui() self.load_lists() + # Find input_manager and main_window + self.input_manager = None + self.main_window = None + parent = self.parent() + while parent: + if hasattr(parent, 'input_manager'): + self.input_manager = cast("MainWindow", parent).input_manager + self.main_window = parent + parent = parent.parent() + + self.current_theme_name = read_theme_from_config() + + # Enable Winetricks-specific mode + if self.input_manager: + self.input_manager.enable_winetricks_mode(self) + + # Create hints widget using common function + self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='winetricks') + self.main_layout.addWidget(self.hints_widget) + + # Connect signals (use self.theme_manager) + if self.input_manager: + self.input_manager.button_event.connect( + lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name) + ) + self.input_manager.dpad_moved.connect( + lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name) + ) + update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name) + def update_winetricks(self): """Update the winetricks script.""" if not self.downloader.has_internet(): @@ -1143,15 +1308,15 @@ class WinetricksDialog(QDialog): def setup_ui(self): """Set up the user interface with tabs and tables.""" - main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(10, 10, 10, 10) - main_layout.setSpacing(10) + self.main_layout = QVBoxLayout(self) + self.main_layout.setContentsMargins(10, 10, 10, 10) + self.main_layout.setSpacing(10) # Log output self.log_output = QTextEdit() self.log_output.setReadOnly(True) self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE) - main_layout.addWidget(self.log_output) + self.main_layout.addWidget(self.log_output) # Tab widget self.tab_widget = QTabWidget() @@ -1258,7 +1423,7 @@ class WinetricksDialog(QDialog): "settings": self.settings_container } - main_layout.addWidget(self.tab_widget) + self.main_layout.addWidget(self.tab_widget) # Buttons button_layout = QHBoxLayout() @@ -1272,7 +1437,7 @@ class WinetricksDialog(QDialog): button_layout.addWidget(self.cancel_button) button_layout.addWidget(self.force_button) button_layout.addWidget(self.install_button) - main_layout.addLayout(button_layout) + self.main_layout.addLayout(button_layout) self.cancel_button.clicked.connect(self.reject) self.force_button.clicked.connect(lambda: self.install_selected(force=True)) @@ -1497,3 +1662,15 @@ class WinetricksDialog(QDialog): """Добавляет в лог.""" self.log_output.append(message) self.log_output.moveCursor(QTextCursor.MoveOperation.End) + + def closeEvent(self, event): + """Disable mode on close.""" + if self.input_manager: + self.input_manager.disable_winetricks_mode() + super().closeEvent(event) + + def reject(self): + """Disable mode on reject.""" + if self.input_manager: + self.input_manager.disable_winetricks_mode() + super().reject() diff --git a/portprotonqt/input_manager.py b/portprotonqt/input_manager.py index 6120219..496054e 100644 --- a/portprotonqt/input_manager.py +++ b/portprotonqt/input_manager.py @@ -5,7 +5,7 @@ from typing import Protocol, cast from evdev import InputDevice, InputEvent, ecodes, list_devices, ff from enum import Enum from pyudev import Context, Monitor, MonitorObserver, Device -from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView +from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView, QTableWidgetItem from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer from PySide6.QtGui import QKeyEvent, QMouseEvent from portprotonqt.logger import get_logger @@ -13,7 +13,7 @@ from portprotonqt.image_utils import FullscreenDialog from portprotonqt.custom_widgets import NavLabel, AutoSizeButton from portprotonqt.game_card import GameCard from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config, read_gamepad_type -from portprotonqt.dialogs import AddGameDialog, WinetricksDialog +from portprotonqt.dialogs import AddGameDialog from portprotonqt.virtual_keyboard import VirtualKeyboard logger = get_logger(__name__) @@ -455,6 +455,171 @@ class InputManager(QObject): except Exception as e: logger.error("Error in FileExplorer dpad handler: %s", e) + def enable_winetricks_mode(self, winetricks_dialog): + """Setup gamepad handling for WinetricksDialog""" + try: + self.winetricks_dialog = winetricks_dialog + self.original_button_handler = self.handle_button_slot + self.original_dpad_handler = self.handle_dpad_slot + self.original_gamepad_state = self._gamepad_handling_enabled + self.handle_button_slot = self.handle_winetricks_button + self.handle_dpad_slot = self.handle_winetricks_dpad + self._gamepad_handling_enabled = True + # Reset dpad timer for table nav + self.dpad_timer.stop() + self.current_dpad_code = None + self.current_dpad_value = 0 + logger.debug("Gamepad handling successfully connected for WinetricksDialog") + except Exception as e: + logger.error(f"Error connecting gamepad handlers for Winetricks: {e}") + + def disable_winetricks_mode(self): + """Restore original main window handlers""" + try: + if self.winetricks_dialog: + self.handle_button_slot = self.original_button_handler + self.handle_dpad_slot = self.original_dpad_handler + self._gamepad_handling_enabled = self.original_gamepad_state + self.winetricks_dialog = None + self.dpad_timer.stop() + self.current_dpad_code = None + self.current_dpad_value = 0 + logger.debug("Gamepad handling successfully restored from Winetricks") + except Exception as e: + logger.error(f"Error restoring gamepad handlers from Winetricks: {e}") + + def handle_winetricks_button(self, button_code, value): + if self.winetricks_dialog is None: + return + if value == 0: # Ignore releases + return + try: + # Always check for popups first, including QMessageBox + popup = QApplication.activePopupWidget() + if popup: + if isinstance(popup, QMessageBox): + if button_code in BUTTONS['confirm'] or button_code in BUTTONS['back']: + popup.accept() # Close QMessageBox with A or B + return + elif isinstance(popup, QMenu): + if button_code in BUTTONS['confirm']: # A: Select menu item + focused = popup.activeAction() + if focused: + focused.trigger() + return + elif button_code in BUTTONS['back']: # B: Close menu + popup.close() + return + + # Additional check for top-level QMessageBox (in case not active popup yet) + for widget in QApplication.topLevelWidgets(): + if isinstance(widget, QMessageBox) and widget.isVisible(): + if button_code in BUTTONS['confirm'] or button_code in BUTTONS['back']: + widget.accept() + return + + focused = QApplication.focusWidget() + if button_code in BUTTONS['confirm']: # A: Toggle checkbox + if isinstance(focused, QTableWidget): + current_row = focused.currentRow() + if current_row >= 0: + checkbox_item = focused.item(current_row, 0) + if checkbox_item and isinstance(checkbox_item, QTableWidgetItem): + new_state = Qt.CheckState.Checked if checkbox_item.checkState() == Qt.CheckState.Unchecked else Qt.CheckState.Unchecked + checkbox_item.setCheckState(new_state) + return + elif button_code in BUTTONS['add_game']: # X: Install (no force) + self.winetricks_dialog.install_selected(force=False) + return + elif button_code in BUTTONS['prev_dir']: # Y: Force Install + self.winetricks_dialog.install_selected(force=True) + return + elif button_code in BUTTONS['back']: # B: Cancel + self.winetricks_dialog.reject() + return + elif button_code in BUTTONS['prev_tab']: # LB: Prev Tab + current_index = self.winetricks_dialog.tab_widget.currentIndex() + new_index = max(0, current_index - 1) + self.winetricks_dialog.tab_widget.setCurrentIndex(new_index) + self._focus_first_row_in_current_table() + return + elif button_code in BUTTONS['next_tab']: # RB: Next Tab + current_index = self.winetricks_dialog.tab_widget.currentIndex() + new_index = min(self.winetricks_dialog.tab_widget.count() - 1, current_index + 1) + self.winetricks_dialog.tab_widget.setCurrentIndex(new_index) + self._focus_first_row_in_current_table() + return + # Fallback: Activate focused widget (e.g., buttons) + self._parent.activateFocusedWidget() + except Exception as e: + logger.error(f"Error in handle_winetricks_button: {e}") + + def handle_winetricks_dpad(self, code, value, now): + if self.winetricks_dialog is None: + return + try: + if value == 0: # Release: Stop repeat + self.dpad_timer.stop() + self.current_dpad_code = None + self.current_dpad_value = 0 + return + + # Start/update repeat timer for hold navigation + if self.current_dpad_code != code or self.current_dpad_value != value: + self.dpad_timer.stop() + self.dpad_timer.setInterval(150 if self.dpad_timer.isActive() else 300) # Initial slower, then faster repeat + self.dpad_timer.start() + self.current_dpad_code = code + self.current_dpad_value = value + + table = self._get_current_table() + if not table or table.rowCount() == 0: + return + + current_row = table.currentRow() + if code == ecodes.ABS_HAT0Y: # Up/Down: Navigate rows + if value < 0: # Up + new_row = max(0, current_row - 1) + elif value > 0: # Down + new_row = min(table.rowCount() - 1, current_row + 1) + else: + return + if new_row != current_row: + table.setCurrentCell(new_row, 0) # Focus checkbox column + table.setFocus(Qt.FocusReason.OtherFocusReason) + elif code == ecodes.ABS_HAT0X: # Left/Right: Switch tabs + if value < 0: # Left: Prev tab + current_index = self.winetricks_dialog.tab_widget.currentIndex() + new_index = max(0, current_index - 1) + self.winetricks_dialog.tab_widget.setCurrentIndex(new_index) + elif value > 0: # Right: Next tab + current_index = self.winetricks_dialog.tab_widget.currentIndex() + new_index = min(self.winetricks_dialog.tab_widget.count() - 1, current_index + 1) + self.winetricks_dialog.tab_widget.setCurrentIndex(new_index) + self._focus_first_row_in_current_table() + except Exception as e: + logger.error(f"Error in handle_winetricks_dpad: {e}") + + def _get_current_table(self): + """Get the current visible table from the tab widget's stacked container.""" + if self.winetricks_dialog is None: + return None + current_container = self.winetricks_dialog.tab_widget.currentWidget() + if current_container and isinstance(current_container, QStackedWidget): + current_table = current_container.widget(1) # Table is at index 1 (after preloader) + if isinstance(current_table, QTableWidget): + return current_table + return None + + def _focus_first_row_in_current_table(self): + """Focus the first row in the current table after tab switch.""" + if self.winetricks_dialog is None: + return + table = self._get_current_table() + if table and table.rowCount() > 0: + table.setCurrentCell(0, 0) + table.setFocus(Qt.FocusReason.OtherFocusReason) + def handle_navigation_repeat(self): """Плавное повторение движения с переменной скоростью для FileExplorer""" try: @@ -705,39 +870,6 @@ class InputManager(QObject): self._parent.toggleGame(self._parent.current_exec_line, None) return - - if isinstance(active, WinetricksDialog): - if button_code in BUTTONS['confirm']: # A button - toggle checkbox - current_table = active.tab_widget.currentWidget() - if isinstance(current_table, QTableWidget): - current_row = current_table.currentRow() - if current_row >= 0: - checkbox = current_table.item(current_row, 0) - if checkbox: - checkbox.setCheckState( - Qt.CheckState.Unchecked if checkbox.checkState() == Qt.CheckState.Checked else Qt.CheckState.Checked - ) - return - elif button_code in BUTTONS['add_game']: # X button - install - active.install_selected(force=False) - return - elif button_code in BUTTONS['prev_dir']: # Y button - force install - active.install_selected(force=True) - return - elif button_code in BUTTONS['back']: # B button - close dialog - active.reject() - return - elif button_code in BUTTONS['prev_tab']: # LB - previous tab - current_idx = active.tab_widget.currentIndex() - new_idx = (current_idx - 1) % active.tab_widget.count() - active.tab_widget.setCurrentIndex(new_idx) - return - elif button_code in BUTTONS['next_tab']: # RB - next tab - current_idx = active.tab_widget.currentIndex() - new_idx = (current_idx + 1) % active.tab_widget.count() - active.tab_widget.setCurrentIndex(new_idx) - return - # Standard navigation if button_code in BUTTONS['confirm']: self._parent.activateFocusedWidget() diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index f5bca5b..4df9263 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -260,6 +260,10 @@ class MainWindow(QMainWindow): GamepadType.XBOX: "xbox_y", GamepadType.PLAYSTATION: "ps_square", }, + 'prev_dir': { + GamepadType.XBOX: "xbox_y", + GamepadType.PLAYSTATION: "ps_square", + }, } return mappings.get(action, {}).get(gtype, "placeholder")