From 836e6cdd36c0de20ab4078bd0128d1ae23c11a51 Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Fri, 21 Nov 2025 00:08:02 +0500 Subject: [PATCH] feat(settings): added initial gamepad navigation Signed-off-by: Boris Yumankulov --- portprotonqt/dialogs.py | 149 ++++++++++++++ portprotonqt/input_manager.py | 355 +++++++++++++++++++++++++++++++++- 2 files changed, 502 insertions(+), 2 deletions(-) diff --git a/portprotonqt/dialogs.py b/portprotonqt/dialogs.py index 0db2dcea..a59fa617 100644 --- a/portprotonqt/dialogs.py +++ b/portprotonqt/dialogs.py @@ -125,6 +125,15 @@ def create_dialog_hints_widget(theme, main_window, input_manager, context='defau ("prev_tab", _("Prev Tab")), # LB / L1 ("next_tab", _("Next Tab")), # RB / R1 ] + elif context == 'settings': + dialog_actions = [ + ("confirm", _("Toggle")), # A / Cross + ("add_game", _("Save")), # X / Triangle + ("prev_dir", _("Search")), # 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) @@ -1723,6 +1732,8 @@ class ExeSettingsDialog(QDialog): self.setModal(True) self.resize(1100, 720) self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE) + # Set focus policy to handle focus changes properly + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # Load toggle settings from config module self.toggle_settings = get_toggle_settings() @@ -1741,6 +1752,27 @@ class ExeSettingsDialog(QDialog): self.current_theme_name = read_theme_from_config() + # Enable settings dialog-specific mode + if self.input_manager: + self.input_manager.enable_settings_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='settings') + 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) + + # Initialize virtual keyboard + self.init_virtual_keyboard() + # Load current settings (includes list-db) self.load_current_settings() @@ -1757,6 +1789,19 @@ class ExeSettingsDialog(QDialog): self.main_layout.setContentsMargins(10, 10, 10, 10) self.main_layout.setSpacing(10) + # Search bar + search_layout = QHBoxLayout() + self.search_label = QLabel(_("Search:")) + self.search_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) + self.search_edit = QLineEdit() + self.search_edit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE) + self.search_edit.setPlaceholderText(_("Search settings...")) + self.search_edit.textChanged.connect(self.filter_settings) + self.search_edit.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + search_layout.addWidget(self.search_label) + search_layout.addWidget(self.search_edit) + self.main_layout.addLayout(search_layout) + # Tab widget self.tab_widget = QTabWidget() self.tab_widget.setStyleSheet(self.theme.WINETRICKS_TAB_STYLE) @@ -2009,6 +2054,9 @@ class ExeSettingsDialog(QDialog): if len(setting['options']) == 1: combo.setEnabled(False) + # Set focus policy for combo box + combo.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.advanced_table.setCellWidget(row, 1, combo) self.advanced_widgets[setting['key']] = combo self.original_display_values[setting['key']] = current_val @@ -2029,6 +2077,75 @@ class ExeSettingsDialog(QDialog): desc_item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) self.advanced_table.setItem(row, 2, desc_item) + def init_virtual_keyboard(self): + """Initialize virtual keyboard""" + self.keyboard = VirtualKeyboard(self, theme=self.theme, button_width=40) + self.keyboard.hide() + self.keyboard.current_input_widget = None + + def show_virtual_keyboard(self, widget=None): + """Show virtual keyboard for search or text input""" + if not widget: + # Default to search edit + widget = self.search_edit + + if not widget or not widget.isVisible(): + return + + # Set the current input widget + self.keyboard.current_input_widget = widget + + # Position the keyboard + keyboard_height = 220 + self.keyboard.setFixedWidth(self.width()) + self.keyboard.setFixedHeight(keyboard_height) + self.keyboard.move(0, self.height() - keyboard_height) + + # Show and raise keyboard + self.keyboard.setParent(self) + self.keyboard.show() + self.keyboard.raise_() + + # Focus on first button of keyboard + first_button = self.keyboard.findFirstFocusableButton() + if first_button: + # First hide the current focus to prevent conflicts + focused_widget = QApplication.focusWidget() + if focused_widget and focused_widget != self.keyboard: + focused_widget.clearFocus() + + # Then focus the keyboard button + QTimer.singleShot(50, lambda: first_button.setFocus()) + + def filter_settings(self, text): + """Filter settings based on search text.""" + # Filter main settings table + search_text = text.lower() + for row in range(self.settings_table.rowCount()): + name_item = self.settings_table.item(row, 0) # Setting name + desc_item = self.settings_table.item(row, 2) # Description + should_show = False + + if name_item and search_text in name_item.text().lower(): + should_show = True + elif desc_item and search_text in desc_item.text().lower(): + should_show = True + + self.settings_table.setRowHidden(row, not should_show) + + # Filter advanced settings table + for row in range(self.advanced_table.rowCount()): + name_item = self.advanced_table.item(row, 0) # Setting name + desc_item = self.advanced_table.item(row, 2) # Description + should_show = False + + if name_item and search_text in name_item.text().lower(): + should_show = True + elif desc_item and search_text in desc_item.text().lower(): + should_show = True + + self.advanced_table.setRowHidden(row, not should_show) + def apply_changes(self): """Apply changes by collecting diffs from both main and advanced tabs.""" changes = [] @@ -2089,8 +2206,40 @@ class ExeSettingsDialog(QDialog): self.load_current_settings() QMessageBox.information(self, _("Success"), _("Settings updated successfully.")) + def keyPressEvent(self, event): + """Override key press event to handle combo box interaction properly.""" + # If a combo box in the advanced table is active and has an open dropdown, + # we need to handle Escape key specially to prevent dialog closure + focused_widget = QApplication.focusWidget() + if (event.key() == Qt.Key.Key_Escape and + isinstance(focused_widget, QComboBox) and + focused_widget.view().isVisible()): + # If a combo box dropdown is open, just close the dropdown instead of the dialog + focused_widget.hidePopup() + self.advanced_table.setFocus() + return + super().keyPressEvent(event) + def closeEvent(self, event): + # Hide virtual keyboard if visible + if hasattr(self, 'keyboard') and self.keyboard.isVisible(): + self.keyboard.hide() + if self.input_manager: + self.input_manager.disable_settings_mode() super().closeEvent(event) def reject(self): + # Hide virtual keyboard if visible + if hasattr(self, 'keyboard') and self.keyboard.isVisible(): + self.keyboard.hide() + if self.input_manager: + self.input_manager.disable_settings_mode() super().reject() + + def accept(self): + # Hide virtual keyboard if visible + if hasattr(self, 'keyboard') and self.keyboard.isVisible(): + self.keyboard.hide() + if self.input_manager: + self.input_manager.disable_settings_mode() + super().accept() diff --git a/portprotonqt/input_manager.py b/portprotonqt/input_manager.py index 08eba4d2..d32c438e 100644 --- a/portprotonqt/input_manager.py +++ b/portprotonqt/input_manager.py @@ -526,6 +526,39 @@ class InputManager(QObject): except Exception as e: logger.error(f"Error restoring gamepad handlers from Winetricks: {e}") + def enable_settings_mode(self, settings_dialog): + """Setup gamepad handling for ExeSettingsDialog""" + try: + self.settings_dialog = settings_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_settings_button + self.handle_dpad_slot = self.handle_settings_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 SettingsDialog") + except Exception as e: + logger.error(f"Error connecting gamepad handlers for SettingsDialog: {e}") + + def disable_settings_mode(self): + """Restore original main window handlers""" + try: + if self.settings_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.settings_dialog = None + self.dpad_timer.stop() + self.current_dpad_code = None + self.current_dpad_value = 0 + logger.debug("Gamepad handling successfully restored from Settings") + except Exception as e: + logger.error(f"Error restoring gamepad handlers from Settings: {e}") + def handle_winetricks_button(self, button_code, value): if self.winetricks_dialog is None: return @@ -592,6 +625,143 @@ class InputManager(QObject): except Exception as e: logger.error(f"Error in handle_winetricks_button: {e}") + def handle_settings_button(self, button_code, value): + if self.settings_dialog is None or value == 0: + return + + try: + kb = getattr(self.settings_dialog, 'keyboard', None) + + # Virtual keyboard + if kb and kb.isVisible(): + if button_code in BUTTONS['back']: + kb.hide() + if kb.current_input_widget: + kb.current_input_widget.setFocus() + return + if button_code in BUTTONS['confirm'] or button_code in BUTTONS['context_menu']: + kb.activateFocusedKey() + return + if button_code in BUTTONS['prev_tab']: + kb.on_lang_click() + return + if button_code in BUTTONS['next_tab']: + kb.on_shift_click(not kb.shift_pressed) + return + if button_code in BUTTONS['add_game']: + kb.on_backspace_pressed() + return + return + + # Pop-ups + popup = QApplication.activePopupWidget() + if popup: + if isinstance(popup, (QMessageBox, QDialog)): + if button_code in BUTTONS['confirm'] | BUTTONS['back']: + popup.accept() + return + if isinstance(popup, QMenu): + if button_code in BUTTONS['confirm'] and popup.activeAction(): + popup.activeAction().trigger() + return + if button_code in BUTTONS['back']: + popup.close() + return + + table = self._get_current_settings_table() + + # Ищем любой открытый комбобокс в Advanced-вкладке + open_combo = None + if table is not None and table == self.settings_dialog.advanced_table: + for r in range(table.rowCount()): + w = table.cellWidget(r, 1) + if isinstance(w, QComboBox) and w.view().isVisible(): + open_combo = w + break + + # B — закрываем открытый комбобокс или весь диалог + if button_code in BUTTONS['back']: + if open_combo: + open_combo.hidePopup() + if table is not None: + table.setFocus() + else: + self.settings_dialog.reject() + return + + # A — главное действие + if button_code in BUTTONS['confirm']: + # Если есть открытый комбобокс — подтверждаем выбор в нём + if open_combo: + view = open_combo.view() + model_index = view.currentIndex() + if model_index.isValid(): + open_combo.setCurrentIndex(model_index.row()) + open_combo.hidePopup() + if table is not None: + table.setFocus() + return + + # Обычная логика: чекбоксы, открытие комбо, ввод текста + focused = QApplication.focusWidget() + if isinstance(focused, QTableWidget) and table and focused.currentRow() >= 0: + row = focused.currentRow() + cell = focused.cellWidget(row, 1) + + # Main tab — чекбоксы + if table == self.settings_dialog.settings_table: + item = focused.item(row, 1) + # Only allow toggling if the item is user checkable (enabled) + if item and item.flags() & Qt.ItemFlag.ItemIsUserCheckable: + item.setCheckState( + Qt.CheckState.Checked + if item.checkState() == Qt.CheckState.Unchecked + else Qt.CheckState.Unchecked + ) + return + + # Advanced tab + if isinstance(cell, QComboBox): + # Only allow opening combo box if it's enabled + if cell.isEnabled(): + cell.showPopup() # открываем, если закрыт + cell.setFocus() + return + if isinstance(cell, QLineEdit): + cell.setFocus() + self.settings_dialog.show_virtual_keyboard(cell) + return + + if isinstance(focused, QLineEdit): + self.settings_dialog.show_virtual_keyboard(focused) + return + + # X — Apply + if button_code in BUTTONS['add_game']: + self.settings_dialog.apply_changes() + return + + # Y — поиск + клавиатура + if button_code in BUTTONS['prev_dir']: + self.settings_dialog.search_edit.setFocus() + self.settings_dialog.show_virtual_keyboard(self.settings_dialog.search_edit) + return + + # LB / RB — переключение вкладок + if button_code in BUTTONS['prev_tab']: + idx = max(0, self.settings_dialog.tab_widget.currentIndex() - 1) + self.settings_dialog.tab_widget.setCurrentIndex(idx) + self._focus_first_row_in_current_settings_table() + elif button_code in BUTTONS['next_tab']: + idx = min(self.settings_dialog.tab_widget.count() - 1, self.settings_dialog.tab_widget.currentIndex() + 1) + self.settings_dialog.tab_widget.setCurrentIndex(idx) + self._focus_first_row_in_current_settings_table() + else: + self._parent.activateFocusedWidget() + + except Exception as e: + logger.error(f"Error in handle_settings_button: {e}") + def handle_winetricks_dpad(self, code, value, now): if self.winetricks_dialog is None: return @@ -617,9 +787,21 @@ class InputManager(QObject): current_row = table.currentRow() if code == ecodes.ABS_HAT0Y: # Up/Down: Navigate rows if value < 0: # Up - new_row = max(0, current_row - 1) + # Find the next visible row above the current row + new_row = current_row - 1 + while new_row >= 0 and table.isRowHidden(new_row): + new_row -= 1 + if new_row < 0: + # If no visible row above, stay at current position + new_row = current_row elif value > 0: # Down - new_row = min(table.rowCount() - 1, current_row + 1) + # Find the next visible row below the current row + new_row = current_row + 1 + while new_row < table.rowCount() and table.isRowHidden(new_row): + new_row += 1 + if new_row >= table.rowCount(): + # If no visible row below, stay at current position + new_row = current_row else: return if new_row != current_row: @@ -658,6 +840,175 @@ class InputManager(QObject): table.setCurrentCell(0, 0) table.setFocus(Qt.FocusReason.OtherFocusReason) + def _focus_first_row_in_current_settings_table(self): + """Focus the first row in the current settings table after tab switch.""" + if self.settings_dialog is None: + return + current_table = self._get_current_settings_table() + if current_table and current_table.rowCount() > 0: + # For the advanced settings table, focus on column 1 (value column) which contains the widgets + # For the main settings table, focus on column 0 (name column) which contains checkboxes + focus_column = 1 if current_table == self.settings_dialog.advanced_table else 0 + current_table.setCurrentCell(0, focus_column) + current_table.setFocus(Qt.FocusReason.OtherFocusReason) + + def _get_current_settings_table(self): + """Get the current visible table from the settings dialog's tab widget.""" + if self.settings_dialog is None: + return None + current_index = self.settings_dialog.tab_widget.currentIndex() + if current_index == 0: + return self.settings_dialog.settings_table + elif current_index == 1: + return self.settings_dialog.advanced_table + return None + + def handle_settings_dpad(self, code, value, now): + if self.settings_dialog is None: + return + try: + # Check if virtual keyboard is visible - if so, handle keyboard navigation instead + if (hasattr(self.settings_dialog, 'keyboard') and + self.settings_dialog.keyboard.isVisible()): + # Handle keyboard navigation with D-pad + if code in (ecodes.ABS_HAT0X, ecodes.ABS_X): + normalized_value = 0 + if code == ecodes.ABS_X: # Left stick + # Apply deadzone + if abs(value) < self.dead_zone: + self.current_dpad_code = None + self.current_dpad_value = 0 + self.axis_moving = False + self.dpad_timer.stop() + return + normalized_value = 1 if value > self.dead_zone else -1 + else: # D-pad + normalized_value = value # D-pad already gives -1, 0, 1 + + if normalized_value != 0: + if normalized_value > 0: # Right + self.settings_dialog.keyboard.move_focus_right() + elif normalized_value < 0: # Left + self.settings_dialog.keyboard.move_focus_left() + return + + # Handle vertical navigation for keyboard + elif code in (ecodes.ABS_HAT0Y, ecodes.ABS_Y): + normalized_value = 0 + if code == ecodes.ABS_Y: # Left stick + # Apply deadzone + if abs(value) < self.dead_zone: + self.current_dpad_code = None + self.current_dpad_value = 0 + self.axis_moving = False + self.dpad_timer.stop() + return + normalized_value = 1 if value > self.dead_zone else -1 + else: # D-pad + normalized_value = value # D-pad already gives -1, 0, 1 + + if normalized_value != 0: + if normalized_value > 0: # Down + self.settings_dialog.keyboard.move_focus_down() + elif normalized_value < 0: # Up + self.settings_dialog.keyboard.move_focus_up() + return + return # Don't continue with table navigation if keyboard is visible + + # Get the current settings table first + table = self._get_current_settings_table() + if not table or table.rowCount() == 0: + return + + # Check if a combo box in advanced settings has an open dropdown - if so, handle combo box navigation + if (table == self.settings_dialog.advanced_table and + table.currentRow() >= 0 and + table.currentColumn() == 1): # Value column + cell_widget = table.cellWidget(table.currentRow(), 1) + if isinstance(cell_widget, QComboBox) and cell_widget.view().isVisible(): + # Only handle combo box dropdown navigation for vertical movements + if code == ecodes.ABS_HAT0Y and value != 0: + current_index = cell_widget.currentIndex() + if value < 0: # Up: move to previous item + new_index = max(0, current_index - 1) + elif value > 0: # Down: move to next item + new_index = min(cell_widget.count() - 1, current_index + 1) + else: + return + if new_index != current_index: + cell_widget.setCurrentIndex(new_index) + cell_widget.setCurrentText(cell_widget.itemText(new_index)) # Ensure text is updated + # If combo box is active, don't continue with table navigation (for any direction) + return # Don't continue with table navigation if combo box is active + # If not a combo box or dropdown not visible, continue with regular table navigation below + # Continue with regular table navigation + + 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 + + current_row = table.currentRow() + if code == ecodes.ABS_HAT0Y: # Up/Down: Navigate rows + if value < 0: # Up + # Find the next visible row above the current row + new_row = current_row - 1 + while new_row >= 0 and table.isRowHidden(new_row): + new_row -= 1 + if new_row < 0: + # If no visible row above, stay at current position + new_row = current_row + elif value > 0: # Down + # Find the next visible row below the current row + new_row = current_row + 1 + while new_row < table.rowCount() and table.isRowHidden(new_row): + new_row += 1 + if new_row >= table.rowCount(): + # If no visible row below, stay at current position + new_row = current_row + else: + return + if new_row != current_row: + # For the advanced settings table, focus on column 1 (value column) which contains the widgets + # For the main settings table, focus on column 0 (name column) which contains checkboxes + focus_column = 1 if table == self.settings_dialog.advanced_table else 0 + table.setCurrentCell(new_row, focus_column) + table.setFocus(Qt.FocusReason.OtherFocusReason) + elif code == ecodes.ABS_HAT0X: # Left/Right: Switch tabs or navigate in cells + if value < 0: # Left + current_col = table.currentColumn() + if current_col > 0: + # Move to previous column in the same row + table.setCurrentCell(current_row, max(0, current_col - 1)) + else: + # Switch to previous tab if at first column + current_index = self.settings_dialog.tab_widget.currentIndex() + new_index = max(0, current_index - 1) + self.settings_dialog.tab_widget.setCurrentIndex(new_index) + self._focus_first_row_in_current_settings_table() + elif value > 0: # Right + current_col = table.currentColumn() + if current_col < table.columnCount() - 1: + # Move to next column in the same row + table.setCurrentCell(current_row, min(table.columnCount() - 1, current_col + 1)) + else: + # Switch to next tab if at last column + current_index = self.settings_dialog.tab_widget.currentIndex() + new_index = min(self.settings_dialog.tab_widget.count() - 1, current_index + 1) + self.settings_dialog.tab_widget.setCurrentIndex(new_index) + self._focus_first_row_in_current_settings_table() + except Exception as e: + logger.error(f"Error in handle_settings_dpad: {e}") + def handle_navigation_repeat(self): """Плавное повторение движения с переменной скоростью для FileExplorer""" try: