feat(settings): added initial gamepad navigation
All checks were successful
Code check / Check code (push) Successful in 1m20s

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
2025-11-21 00:08:02 +05:00
parent b2a1046f9d
commit 836e6cdd36
2 changed files with 502 additions and 2 deletions

View File

@@ -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()

View File

@@ -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: