refactor: add native SDL3 gamepad backend #237

Merged
Boria138 merged 1 commits from sdl3-so into main 2026-08-13 16:29:03 +00:00
38 changed files with 5731 additions and 4743 deletions

No files matched your search

+5 -3
View File
@@ -128,9 +128,11 @@ jobs:
steps:
- name: Install build dependencies
run: |
dnf install -y git rpmdevtools meson ninja-build python3-devel \
dnf upgrade -y --exclude=openh264,mozilla-openh264
dnf install -y --disablerepo=fedora-cisco-openh264 \
git rpmdevtools meson ninja-build python3-devel \
systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \
gcc gettext
gcc gettext 'pkgconfig(sdl3)'
- name: Setup rpmbuild environment
run: |
@@ -172,7 +174,7 @@ jobs:
run: |
apt-get update
apt-get install -y build-essential debhelper dpkg-dev gettext git \
libvulkan-dev meson ninja-build nodejs npm \
libsdl3-dev libvulkan-dev meson ninja-build nodejs npm \
pkgconf python3
- name: Checkout
+5 -3
View File
@@ -135,9 +135,11 @@ jobs:
steps:
- name: Install build dependencies
run: |
dnf install -y git rpmdevtools meson ninja-build python3-devel \
dnf upgrade -y --exclude=openh264,mozilla-openh264
dnf install -y --disablerepo=fedora-cisco-openh264 \
git rpmdevtools meson ninja-build python3-devel \
systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \
gcc gettext
gcc gettext 'pkgconfig(sdl3)'
- name: Setup rpmbuild environment
run: |
@@ -257,7 +259,7 @@ jobs:
run: |
apt-get update
apt-get install -y build-essential debhelper dpkg-dev gettext git \
libvulkan-dev meson ninja-build nodejs npm \
libsdl3-dev libvulkan-dev meson ninja-build nodejs npm \
pkgconf python3
- name: Checkout PR
+107 -15
View File
@@ -878,21 +878,113 @@ install_data(
```
PortProtonQt/
├── portprotonqt/ # Python package
│ ├── app.py # Entry point
│ ├── main_window.py # Main window
│ ├── game_card.py # Game card widget
│ ├── steam_api.py # Steam integration
│ ├── egs_api.py # Epic Games integration
│ ├── theme_manager.py # Theme management
│ ├── logger.py # Logging
│ └── themes/ # Theme files
├── tests/ # Unit tests
├── build-aux/ # Build resources
├── dev-scripts/ # Development scripts
├── documentation/ # Documentation
├── meson.build # Meson config
├── pyproject.toml # Python config
├── portprotonqt/ # Python package
│ ├── animations/ # UI animation modules
│ │ ├── detail_background.py
│ │ ├── detail_page.py
│ │ ├── game_card.py
│ │ ├── library_controls.py
│ │ └── virtual_keyboard.py
│ ├── config/ # Application configuration
│ │ ├── base.py
│ │ ├── cache.py
│ │ ├── display.py
│ │ ├── favorites.py
│ │ ├── game.py
│ │ ├── gamepad.py
│ │ ├── portproton.py
│ │ ├── proxy.py
│ │ ├── ui.py
│ │ ├── validators.py
│ │ └── window.py
│ ├── debug_utils/ # Debug data collection and processing
│ │ ├── debug_log_manager.py
│ │ ├── env_utils.py
│ │ ├── game_debug.py
│ │ ├── gpu_info.py
│ │ ├── log_processor.py
│ │ ├── system_info.py
│ │ └── xorg_utils.py
│ ├── detail_pages/ # Game detail page components
│ │ ├── utils.py
│ │ └── widgets.py
│ ├── dialogs/ # Application dialogs
│ │ ├── appimage_update.py
│ │ ├── base.py
│ │ ├── compatibility_report.py
│ │ ├── dialog_utils.py
│ │ ├── file_explorer.py
│ │ ├── prefix_backup.py
│ │ ├── proton_manager.py
│ │ ├── settings_dialog.py
│ │ ├── settings_gamescope.py
│ │ ├── settings_mangohud.py
│ │ ├── settings_vkbasalt.py
│ │ ├── wine_downloader.py
│ │ ├── wine_extractor.py
│ │ ├── wine_loader.py
│ │ └── winetricks_dialog.py
│ ├── input_manager/ # Keyboard and gamepad input routing
│ │ ├── buttons.py
│ │ ├── constants.py
│ │ ├── dialog_modes.py
│ │ ├── dpad.py
│ │ ├── file_explorer.py
│ │ ├── keyboard.py
│ │ ├── mixin.py
│ │ ├── runtime.py
│ │ ├── settings.py
│ │ └── settings_visual.py
│ ├── scripts_utils/ # Helpers used by PortProton scripts
│ │ ├── dbus_tools.py
│ │ ├── easyterm.py
│ │ ├── graphics_detector.py
│ │ ├── idle_inhibit.py
│ │ ├── json_tools.py
│ │ ├── mount_points.py
│ │ ├── power_profiles.py
│ │ ├── prefix_backup.py
│ │ └── shortcut_tools.py
│ ├── steam_api/ # Steam API, cache, and shortcuts
│ │ ├── api.py
│ │ ├── cache.py
│ │ ├── shortcuts.py
│ │ └── utils.py
│ ├── system_manager/ # System settings backends
│ │ ├── audio.py
│ │ ├── bluetooth.py
│ │ ├── common.py
│ │ ├── network.py
│ │ └── storage.py
│ ├── tabs/ # Main window tabs and workers
│ │ ├── autoinstall_tab.py
│ │ ├── control_hints.py
│ │ ├── gog_tab.py
│ │ ├── library_tab.py
│ │ ├── settings_tab.py
│ │ ├── system_tab.py
│ │ ├── theme_store.py
│ │ ├── theme_store_workers.py
│ │ ├── theme_tab.py
│ │ ├── wine_tab.py
│ │ └── workers.py
│ ├── locales/ # Translation catalogs
│ ├── terminal_schemes/ # Terminal color schemes
│ ├── themes/ # Built-in themes and assets
│ ├── app.py # Entry point
│ ├── game_card.py # Game card widget
│ ├── gog_api.py # GOG integration
│ ├── main_window.py # Main window
│ ├── native_gamepad.py # Native SDL3 gamepad backend
│ ├── portproton_api.py # PortProton integration
│ ├── theme_manager.py # Theme management
│ └── logger.py # Logging
├── tests/ # Unit tests
├── build-aux/ # Build resources
├── dev-scripts/ # Development scripts
├── documentation/ # Documentation
├── meson.build # Meson config
├── pyproject.toml # Python config
└── .pre-commit-config.yaml
```
@@ -43,6 +43,7 @@ pacman -Rdd --noconfirm libpipewire qt6-multimedia-ffmpeg
/usr/bin/portprotonqt* \
/usr/bin/update-desktop-database \
/usr/bin/vk_gpu_info \
/usr/lib/portprotonqt/libportprotonqt_gamepad.so \
/usr/lib/libQt6Core.so* \
/usr/lib/libQt6Gui.so* \
/usr/lib/libQt6Multimedia.so* \
+1 -1
View File
@@ -2,7 +2,7 @@ pkgname=('portprotonqt')
pkgver=1.3.2
pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam"
arch=('any')
arch=('x86_64')
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
license=('GPL-3.0')
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
+1 -1
View File
@@ -2,7 +2,7 @@ pkgname=('portprotonqt-git')
pkgver=.
pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam (development build)"
arch=('any')
arch=('x86_64')
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
license=('GPL-3.0')
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
+2 -1
View File
@@ -22,6 +22,7 @@ BuildRequires: git
BuildRequires: gettext
BuildRequires: systemd-rpm-macros
BuildRequires: vulkan-loader-devel
BuildRequires: pkgconfig(sdl3)
BuildRequires: gcc
Obsoletes: python3-%{pypi_name}-git < %{version}-%{release}
@@ -42,7 +43,6 @@ Requires: python3-pillow-qt
Requires: python3-rapidfuzz
Requires: python3-libarchive-c
Requires: perl-Image-ExifTool
Requires: SDL3
Requires: qt6-qtsvg
Requires: qt6-qtimageformats
Requires: qt6-qtmultimedia
@@ -108,6 +108,7 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
%files -f %{oname}/%{pypi_name}.lang
%{_bindir}/%{pypi_name}
%{_bindir}/vk_gpu_info
%{_libdir}/%{pypi_name}/
%{python3_sitelib}/%{pypi_name}/
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
+2 -1
View File
@@ -19,6 +19,7 @@ BuildRequires: git
BuildRequires: gettext
BuildRequires: systemd-rpm-macros
BuildRequires: vulkan-loader-devel
BuildRequires: pkgconfig(sdl3)
BuildRequires: gcc
Obsoletes: python3-%{pypi_name} < %{version}-%{release}
@@ -39,7 +40,6 @@ Requires: python3-pillow-qt
Requires: python3-rapidfuzz
Requires: python3-libarchive-c
Requires: perl-Image-ExifTool
Requires: SDL3
Requires: qt6-qtsvg
Requires: qt6-qtimageformats
Requires: qt6-qtmultimedia
@@ -106,6 +106,7 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
%files -f %{oname}/%{pypi_name}.lang
%{_bindir}/%{pypi_name}
%{_bindir}/vk_gpu_info
%{_libdir}/%{pypi_name}/
%{python3_sitelib}/%{pypi_name}/
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
Binary file not shown.
+1
View File
@@ -5,6 +5,7 @@ Maintainer: Ivan Mazhukin <vanyamajukin@outlook.com>
Build-Depends:
debhelper-compat (= 13),
gettext,
libsdl3-dev,
libvulkan-dev,
meson,
ninja-build,
+1
View File
@@ -1,5 +1,6 @@
usr/bin/portprotonqt
usr/bin/vk_gpu_info
usr/lib/portprotonqt/
usr/lib/python3/dist-packages/portprotonqt/
usr/lib/sysusers.d/portprotonqt.conf
usr/lib/udev/rules.d/60-portprotonqt.rules
+1
View File
@@ -5,6 +5,7 @@
override_dh_auto_configure:
dh_auto_configure -- \
-Dlibdir=/usr/lib \
-Dpython_purelibdir=/usr/lib/python3/dist-packages \
-Dudev_rulesdir=/usr/lib/udev/rules.d
+6 -14
View File
@@ -13,15 +13,15 @@ conf = configuration_data()
conf.set('PYTHON', python3.full_path())
version_conf = configuration_data()
version_conf.set_quoted('APP_VERSION', meson.project_version())
version_conf.set_quoted('APP_COMMIT', '')
version_conf.set('APP_VERSION', meson.project_version())
version_conf.set('APP_COMMIT', '')
if get_option('nightly')
git = find_program('git', required: false)
if git.found()
git_commit = run_command(git, 'rev-parse', '--short', 'HEAD', check: false)
if git_commit.returncode() == 0
version_conf.set_quoted('APP_COMMIT', git_commit.stdout().strip())
version_conf.set('APP_COMMIT', git_commit.stdout().strip())
endif
endif
endif
@@ -30,6 +30,8 @@ endif
prefix = get_option('prefix')
datadir = prefix / get_option('datadir')
bindir = prefix / get_option('bindir')
libdir = prefix / get_option('libdir')
conf.set('GAMEPAD_LIBRARY_DIR', libdir / meson.project_name())
# Use custom purelibdir if specified, otherwise use default
purelibdir_opt = get_option('python_purelibdir')
@@ -38,17 +40,7 @@ pkgdatadir = pythondir / meson.project_name()
# C compiler for vk_gpu_info
cc = meson.get_compiler('c')
configured_version = configure_file(
input: 'portprotonqt/_version.py.in',
output: '_version.py',
configuration: version_conf,
)
install_data(
configured_version,
install_dir: pythondir / meson.project_name(),
)
sdl3_dep = dependency('sdl3', required: true)
# Install Python package
subdir('portprotonqt')
-2
View File
@@ -1,2 +0,0 @@
APP_VERSION = @APP_VERSION@
APP_COMMIT = @APP_COMMIT@
+8 -11
View File
@@ -1,7 +1,6 @@
import argparse
import sys
import os
import importlib
import subprocess
import threading
import urllib.error
@@ -12,16 +11,6 @@ from logging import Logger
__app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt"
try:
version_module = importlib.import_module("portprotonqt._version")
APP_COMMIT = version_module.APP_COMMIT
APP_VERSION = version_module.APP_VERSION
except ImportError:
APP_COMMIT = ""
APP_VERSION = "1.3.2"
__app_version__ = os.environ.get("PORTPROTONQT_VERSION", APP_VERSION)
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import (
QApplication,
@@ -60,6 +49,14 @@ from portprotonqt.cli import (
)
from portprotonqt.localization import _, get_steam_language
APP_VERSION = "@APP_VERSION@"
APP_COMMIT = "@APP_COMMIT@"
if APP_VERSION == "@APP_VERSION@":
APP_COMMIT = ""
APP_VERSION = "1.3.2"
__app_version__ = APP_VERSION
COUNTER_DOWNLOAD_URL = "http://cloud.linux-gaming.ru:8081/api/download/{version}"
def get_version():
+6 -6
View File
@@ -193,12 +193,12 @@ class FileExplorer(DraggableDialog):
self.main_layout.addWidget(self.hints_widget)
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)
)
def update_hints(*_args: object) -> None:
update_dialog_hints(
self.hints_labels, self.main_window, self.input_manager,
theme_manager, self.current_theme_name,
)
self.input_manager.connect_surface_updates('file_explorer', update_hints)
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
def async_load_thumbnails(self, files, mime_db):
+4 -10
View File
@@ -278,18 +278,12 @@ class ProtonManager(DraggableDialog):
self.tab_widget.currentChanged.connect(self.tab_changed)
if self.input_manager and self.main_window:
self.input_manager.button_event.connect(
lambda *args: update_dialog_hints(
def update_hints(*_args: object) -> None:
update_dialog_hints(
self.hints_labels, self.main_window, self.input_manager,
theme_manager, self.current_theme_name
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
)
)
self.input_manager.connect_surface_updates('proton_manager_dialog', update_hints)
update_dialog_hints(
self.hints_labels, self.main_window, self.input_manager,
theme_manager, self.current_theme_name
+11 -7
View File
@@ -211,7 +211,11 @@ class ExeSettingsDialog(
if self.input_manager:
self.input_manager.enable_settings_mode(self)
self.input_manager.gamepad_hotplug.connect(self._update_vkbasalt_toggle_key_visibility)
self.input_manager.connect_surface_signal(
'settings_dialog',
self.input_manager.gamepad_hotplug,
self._update_vkbasalt_toggle_key_visibility,
)
self.hints_widget, self.hints_labels = create_dialog_hints_widget(
self.theme, self.main_window, self.input_manager, context='settings'
@@ -219,12 +223,12 @@ class ExeSettingsDialog(
self.main_layout.addWidget(self.hints_widget)
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)
)
def update_hints(*_args: object) -> None:
update_dialog_hints(
self.hints_labels, self.main_window, self.input_manager,
theme_manager, self.current_theme_name,
)
self.input_manager.connect_surface_updates('settings_dialog', update_hints)
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
self.init_virtual_keyboard()
+6 -6
View File
@@ -86,12 +86,12 @@ class WinetricksDialog(DraggableDialog):
self.main_layout.addWidget(self.hints_widget)
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)
)
def update_hints(*_args: object) -> None:
update_dialog_hints(
self.hints_labels, self.main_window, self.input_manager,
theme_manager, self.current_theme_name,
)
self.input_manager.connect_surface_updates('winetricks_dialog', update_hints)
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
def setup_ui(self):
+122
View File
@@ -0,0 +1,122 @@
#include <SDL3/SDL.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
typedef struct {
SDL_Gamepad *gamepad;
SDL_JoystickID instance_id;
int sdl_type;
} PortProtonGamepad;
static PortProtonGamepad *open_gamepad(SDL_JoystickID instance_id)
{
SDL_Gamepad *gamepad = SDL_OpenGamepad(instance_id);
if (gamepad == NULL) {
return NULL;
}
PortProtonGamepad *result = malloc(sizeof(*result));
if (result == NULL) {
SDL_CloseGamepad(gamepad);
SDL_SetError("Failed to allocate gamepad handle");
return NULL;
}
result->gamepad = gamepad;
result->instance_id = instance_id;
result->sdl_type = SDL_GetGamepadTypeForID(instance_id);
return result;
}
PortProtonGamepad *portproton_gamepad_find(void)
{
SDL_ClearError();
if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) {
return NULL;
}
SDL_PumpEvents();
SDL_UpdateGamepads();
int count = 0;
SDL_JoystickID *gamepads = SDL_GetGamepads(&count);
if (gamepads == NULL) {
return NULL;
}
if (count == 0) {
SDL_free(gamepads);
SDL_ClearError();
return NULL;
}
PortProtonGamepad *result = NULL;
for (int index = 0; index < count && result == NULL; index++) {
result = open_gamepad(gamepads[index]);
}
SDL_free(gamepads);
return result;
}
const char *portproton_gamepad_get_error(void)
{
const char *error = SDL_GetError();
return error != NULL ? error : "";
}
void portproton_gamepad_close(PortProtonGamepad *gamepad)
{
if (gamepad == NULL) {
return;
}
if (gamepad->gamepad != NULL) {
SDL_CloseGamepad(gamepad->gamepad);
}
free(gamepad);
}
bool portproton_gamepad_connected(const PortProtonGamepad *gamepad)
{
return gamepad != NULL && gamepad->gamepad != NULL &&
SDL_GamepadConnected(gamepad->gamepad);
}
void portproton_gamepad_update(void)
{
SDL_UpdateGamepads();
}
int portproton_gamepad_get_button(const PortProtonGamepad *gamepad, int button)
{
if (gamepad == NULL || gamepad->gamepad == NULL) {
return 0;
}
return SDL_GetGamepadButton(gamepad->gamepad, button);
}
int16_t portproton_gamepad_get_axis(const PortProtonGamepad *gamepad, int axis)
{
if (gamepad == NULL || gamepad->gamepad == NULL) {
return 0;
}
return SDL_GetGamepadAxis(gamepad->gamepad, axis);
}
const char *portproton_gamepad_get_name(const PortProtonGamepad *gamepad)
{
if (gamepad == NULL || gamepad->gamepad == NULL) {
return "";
}
const char *name = SDL_GetGamepadName(gamepad->gamepad);
return name != NULL ? name : "";
}
uint32_t portproton_gamepad_get_instance_id(const PortProtonGamepad *gamepad)
{
return gamepad != NULL ? gamepad->instance_id : 0;
}
int portproton_gamepad_get_type(const PortProtonGamepad *gamepad)
{
return gamepad != NULL ? gamepad->sdl_type : 0;
}
void portproton_gamepad_shutdown(void)
{
SDL_QuitSubSystem(SDL_INIT_GAMEPAD);
}
-244
View File
@@ -1,244 +0,0 @@
import ctypes
import ctypes.util
from dataclasses import dataclass
from enum import Enum
from functools import lru_cache
from typing import Any
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
SDL_INIT_GAMEPAD = 0x00002000
SDL_GAMEPAD_TYPE_STANDARD = 1
SDL_GAMEPAD_TYPE_XBOX360 = 2
SDL_GAMEPAD_TYPE_XBOXONE = 3
SDL_GAMEPAD_TYPE_PS3 = 4
SDL_GAMEPAD_TYPE_PS4 = 5
SDL_GAMEPAD_TYPE_PS5 = 6
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO = 7
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT = 8
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT = 9
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR = 10
SDL_GAMEPAD_TYPE_GAMECUBE = 11
SDL_GAMEPAD_TYPE_STEAM = 12
SDL3_XBOX_LIKE_TYPES = {
SDL_GAMEPAD_TYPE_STANDARD,
SDL_GAMEPAD_TYPE_XBOX360,
SDL_GAMEPAD_TYPE_XBOXONE,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR,
SDL_GAMEPAD_TYPE_GAMECUBE,
SDL_GAMEPAD_TYPE_STEAM,
}
SDL3_PLAYSTATION_TYPES = {
SDL_GAMEPAD_TYPE_PS3,
SDL_GAMEPAD_TYPE_PS4,
SDL_GAMEPAD_TYPE_PS5,
}
SDL_GAMEPAD_BUTTON_SOUTH = 0
SDL_GAMEPAD_BUTTON_EAST = 1
SDL_GAMEPAD_BUTTON_WEST = 2
SDL_GAMEPAD_BUTTON_NORTH = 3
SDL_GAMEPAD_BUTTON_BACK = 4
SDL_GAMEPAD_BUTTON_GUIDE = 5
SDL_GAMEPAD_BUTTON_START = 6
SDL_GAMEPAD_BUTTON_LEFT_SHOULDER = 9
SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER = 10
SDL_GAMEPAD_BUTTON_DPAD_UP = 11
SDL_GAMEPAD_BUTTON_DPAD_DOWN = 12
SDL_GAMEPAD_BUTTON_DPAD_LEFT = 13
SDL_GAMEPAD_BUTTON_DPAD_RIGHT = 14
SDL_GAMEPAD_AXIS_LEFTX = 0
SDL_GAMEPAD_AXIS_LEFTY = 1
SDL_GAMEPAD_AXIS_RIGHTX = 2
SDL_GAMEPAD_AXIS_RIGHTY = 3
SDL_GAMEPAD_AXIS_LEFT_TRIGGER = 4
SDL_GAMEPAD_AXIS_RIGHT_TRIGGER = 5
class GamepadType(Enum):
XBOX = "Xbox"
PLAYSTATION = "PlayStation"
UNKNOWN = "Unknown"
def _get_sdl3_error(sdl: ctypes.CDLL) -> str:
error = sdl.SDL_GetError()
if not error:
return ""
return error.decode(errors="replace")
def _configure_sdl3_gamepad_api(sdl: ctypes.CDLL) -> None:
sdl.SDL_InitSubSystem.argtypes = [ctypes.c_uint32]
sdl.SDL_InitSubSystem.restype = ctypes.c_bool
sdl.SDL_GetGamepads.argtypes = [ctypes.POINTER(ctypes.c_int)]
sdl.SDL_GetGamepads.restype = ctypes.POINTER(ctypes.c_uint32)
sdl.SDL_GetGamepadTypeForID.argtypes = [ctypes.c_uint32]
sdl.SDL_GetGamepadTypeForID.restype = ctypes.c_int
sdl.SDL_GetGamepadNameForID.argtypes = [ctypes.c_uint32]
sdl.SDL_GetGamepadNameForID.restype = ctypes.c_char_p
sdl.SDL_OpenGamepad.argtypes = [ctypes.c_uint32]
sdl.SDL_OpenGamepad.restype = ctypes.c_void_p
sdl.SDL_CloseGamepad.argtypes = [ctypes.c_void_p]
sdl.SDL_CloseGamepad.restype = None
sdl.SDL_GamepadConnected.argtypes = [ctypes.c_void_p]
sdl.SDL_GamepadConnected.restype = ctypes.c_bool
sdl.SDL_GetGamepadName.argtypes = [ctypes.c_void_p]
sdl.SDL_GetGamepadName.restype = ctypes.c_char_p
sdl.SDL_GetGamepadButton.argtypes = [ctypes.c_void_p, ctypes.c_int]
sdl.SDL_GetGamepadButton.restype = ctypes.c_bool
sdl.SDL_GetGamepadAxis.argtypes = [ctypes.c_void_p, ctypes.c_int]
sdl.SDL_GetGamepadAxis.restype = ctypes.c_int16
sdl.SDL_UpdateGamepads.argtypes = []
sdl.SDL_UpdateGamepads.restype = None
sdl.SDL_PumpEvents.argtypes = []
sdl.SDL_PumpEvents.restype = None
sdl.SDL_QuitSubSystem.argtypes = [ctypes.c_uint32]
sdl.SDL_QuitSubSystem.restype = None
sdl.SDL_GetError.argtypes = []
sdl.SDL_GetError.restype = ctypes.c_char_p
sdl.SDL_free.argtypes = [ctypes.c_void_p]
sdl.SDL_free.restype = None
@lru_cache(maxsize=1)
def _load_sdl3() -> ctypes.CDLL | None:
library_names = (
ctypes.util.find_library("SDL3"),
"libSDL3.so.0",
"libSDL3.so",
)
for library_name in library_names:
if not library_name:
continue
try:
sdl = ctypes.CDLL(library_name)
_configure_sdl3_gamepad_api(sdl)
except (AttributeError, OSError) as e:
logger.debug("Failed to load SDL3 from %s: %s", library_name, e)
continue
if sdl.SDL_InitSubSystem(SDL_INIT_GAMEPAD):
return sdl
logger.debug("Failed to initialize SDL3 gamepad subsystem: %s", _get_sdl3_error(sdl))
return None
def _decode_sdl3_name(name: bytes | None) -> str:
if not name:
return ""
return name.decode(errors="replace")
@dataclass
class SDLGamepad:
sdl: ctypes.CDLL
controller: ctypes.c_void_p
name: str
path: str
instance_id: int
def close(self) -> None:
try:
self.sdl.SDL_CloseGamepad(self.controller)
except (AttributeError, OSError) as e:
logger.debug("Failed to close SDL gamepad: %s", e)
def connected(self) -> bool:
return bool(self.sdl.SDL_GamepadConnected(self.controller))
def update(self) -> None:
self.sdl.SDL_UpdateGamepads()
def get_button(self, button: int) -> int:
return int(self.sdl.SDL_GetGamepadButton(self.controller, button))
def get_axis(self, axis: int) -> int:
return int(self.sdl.SDL_GetGamepadAxis(self.controller, axis))
def find_gamepad() -> SDLGamepad | None:
sdl = _load_sdl3()
if sdl is None:
return None
sdl.SDL_PumpEvents()
sdl.SDL_UpdateGamepads()
count = ctypes.c_int()
gamepads = sdl.SDL_GetGamepads(ctypes.byref(count))
if not gamepads:
return None
try:
return _open_first_sdl3_gamepad(sdl, gamepads, count.value)
finally:
sdl.SDL_free(gamepads)
def _open_first_sdl3_gamepad(
sdl: ctypes.CDLL,
gamepads: Any,
count: int,
) -> SDLGamepad | None:
for index in range(count):
instance_id = int(gamepads[index])
gamepad = sdl.SDL_OpenGamepad(instance_id)
if not gamepad:
logger.debug("Skipping SDL3 gamepad %s: %s", instance_id, _get_sdl3_error(sdl))
continue
return SDLGamepad(
sdl=sdl,
controller=gamepad,
name=_decode_sdl3_name(sdl.SDL_GetGamepadName(gamepad)),
path=f"sdl3-gamepad:{instance_id}",
instance_id=instance_id,
)
return None
def detect_gamepad_type(gamepad: SDLGamepad) -> GamepadType:
sdl = _load_sdl3()
if sdl is None:
return GamepadType.XBOX
count = ctypes.c_int()
gamepads = sdl.SDL_GetGamepads(ctypes.byref(count))
if not gamepads:
return GamepadType.XBOX
try:
gamepad_type = _find_sdl3_gamepad_type(sdl, gamepads, count.value, gamepad.name)
finally:
sdl.SDL_free(gamepads)
return gamepad_type or GamepadType.XBOX
def _find_sdl3_gamepad_type(
sdl: ctypes.CDLL,
gamepads: Any,
count: int,
target_name: str,
) -> GamepadType | None:
for index in range(count):
gamepad_id = gamepads[index]
sdl_name = _decode_sdl3_name(sdl.SDL_GetGamepadNameForID(gamepad_id))
if count > 1 and sdl_name.casefold() != target_name.casefold():
continue
gamepad_type = _gamepad_type_from_sdl3_value(sdl.SDL_GetGamepadTypeForID(gamepad_id))
if gamepad_type is not None:
return gamepad_type
return None
def _gamepad_type_from_sdl3_value(sdl_type: int) -> GamepadType | None:
if sdl_type in SDL3_PLAYSTATION_TYPES:
return GamepadType.PLAYSTATION
if sdl_type in SDL3_XBOX_LIKE_TYPES:
return GamepadType.XBOX
return None
def shutdown() -> None:
sdl = _load_sdl3()
if sdl is not None:
sdl.SDL_QuitSubSystem(SDL_INIT_GAMEPAD)
File diff suppressed because it is too large. Load diff
+597
View File
@@ -0,0 +1,597 @@
import time
import os
import math
from typing import Protocol, cast, Any
from evdev import UInput, ecodes
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QMenu, QMessageBox, QTableWidget, QSlider
from PySide6.QtCore import Qt, QObject, Signal, Slot, QTimer, QThread
from portprotonqt.logger import get_logger
from portprotonqt.sound_manager import SoundManager
from portprotonqt.game_card import GameCard
from portprotonqt.config import display_config, window_config
from portprotonqt.dialogs import AddGameDialog
from portprotonqt.input_manager.constants import (
BUTTONS,
GamepadType as GamepadType,
PAD_AXIS_RIGHT_Y,
PAD_DPAD_X,
PAD_DPAD_Y,
)
from portprotonqt.native_gamepad import SDLGamepad
from portprotonqt.input_manager.buttons import ButtonInputMixin
from portprotonqt.input_manager.dialog_modes import DialogInputModesMixin
from portprotonqt.input_manager.dpad import DpadInputMixin
from portprotonqt.input_manager.file_explorer import FileExplorerInputMixin
from portprotonqt.input_manager.keyboard import KeyboardInputMixin
from portprotonqt.input_manager.runtime import GamepadRuntimeMixin
from portprotonqt.input_manager.settings import SettingsInputMixin
from portprotonqt.input_manager.settings_visual import SettingsVisualNavigationMixin
logger = get_logger(__name__)
class MainWindowProtocol(Protocol):
def activateFocusedWidget(self) -> None:
...
def goBackDetailPage(self, page: QWidget | None) -> None:
...
def switchTab(self, index: int) -> None:
...
def openAddGameDialog(self, exe_path: str | None = None) -> None:
...
def toggleGame(self, exec_line: str | None, button: QWidget | None = None) -> None:
...
def on_slider_released(self) -> None:
...
def on_auto_slider_released(self) -> None:
...
def isActiveWindow(self) -> bool:
...
def refreshGames(self) -> None:
...
def handleSystemTableGamepadAction(self, table: QTableWidget, action: str) -> bool:
...
def handleSystemGamepadAction(self, action: str) -> bool:
...
stackedWidget: QStackedWidget
tabButtons: dict[int, QWidget]
gamesListWidget: QWidget
autoInstallContainer: QWidget | None
currentDetailPage: QWidget | None
current_exec_line: str | None
current_add_game_dialog: AddGameDialog | None
game_library_manager: Any # GameLibraryManager - using Any to avoid circular import
auto_size_slider: QSlider | None
class MouseEmulationThread(QThread):
"""Thread for creating UInput virtual mouse device without blocking UI."""
finished = Signal(bool) # Emitted when done (True = success, False = failed)
def run(self):
"""Run UInput device creation in background thread."""
try:
if not os.path.exists('/dev/uinput'):
logger.error("EMUL: /dev/uinput does not exist")
self.finished.emit(False)
return
if not os.access('/dev/uinput', os.W_OK):
logger.error("EMUL: No write access to /dev/uinput")
self.finished.emit(False)
return
ui = UInput({
ecodes.EV_KEY: [ecodes.BTN_LEFT, ecodes.BTN_RIGHT],
ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL],
}, name="Virtual DPad Mouse")
self.finished.emit(True)
# Store device in thread result
self._device = ui
except PermissionError as e:
logger.error("EMUL: Permission denied for /dev/uinput: %s", e)
self.finished.emit(False)
except Exception as ex:
logger.error(f"EMUL: Error creating virtual mouse: {ex}", exc_info=True)
self.finished.emit(False)
def get_device(self) -> UInput | None:
"""Get the created UInput device after thread finishes."""
return getattr(self, '_device', None)
class InputManager(
FileExplorerInputMixin,
DialogInputModesMixin,
SettingsInputMixin,
SettingsVisualNavigationMixin,
ButtonInputMixin,
DpadInputMixin,
KeyboardInputMixin,
GamepadRuntimeMixin,
QObject,
):
"""
Manages input from gamepads and keyboards for navigating the application interface.
Supports gamepad hotplugging, button and axis events, and keyboard shortcuts.
"""
# Signals for gamepad events
button_event = Signal(int, int) # Signal for button events: (code, value) where value=1 (press), 0 (release)
dpad_moved = Signal(int, int, float) # Signal for D-pad movements
toggle_fullscreen = Signal(bool) # Signal for toggling fullscreen mode (True for fullscreen, False for normal)
gamepad_hotplug = Signal(str) # 'add' or 'remove'
def __init__(
self,
main_window: MainWindowProtocol,
axis_deadzone: float = 0.5,
initial_axis_move_delay: float = 0.3,
repeat_axis_move_delay: float = 0.15
):
super().__init__(cast(QObject, main_window))
self._parent = main_window
self._gamepad_handling_enabled = True
self.gamepad_type = self._get_configured_gamepad_type()
self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None)
self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None)
self._parent.current_add_game_dialog = getattr(self._parent, 'current_add_game_dialog', None)
self._parent.autoInstallContainer = getattr(self._parent, 'autoInstallContainer', None)
self.axis_deadzone = axis_deadzone
self.initial_axis_move_delay = initial_axis_move_delay
self.repeat_axis_move_delay = repeat_axis_move_delay
self.current_axis_delay = initial_axis_move_delay
self.last_move_time = 0.0
self.axis_moving = False
self.gamepad: SDLGamepad | None = None
self.running = True
self._is_fullscreen = display_config.get_fullscreen()
self.lt_pressed = False
self.rt_pressed = False
self.last_trigger_time = 0.0
self.trigger_cooldown = 0.2
# Mouse emulation attributes
self.mouse_emulation_enabled = True
self.ui = None
self.mouse_emulation_thread: MouseEmulationThread | None = None
self.stick_x_raw = 0
self.stick_y_raw = 0
# Axis parameters (will be filled from kernel)
self.center_x = 127 # X axis center
self.center_y = 127 # Y axis center
self.min_value = 0 # axis minimum
self.max_value = 255 # axis maximum
self.deadzone_value = 15 # deadzone from kernel (flat parameter)
self.scroll_axis_code = PAD_AXIS_RIGHT_Y
self.scroll_center = self.center_y
self.scroll_min_value = self.min_value
self.scroll_max_value = self.max_value
self.scroll_deadzone_value = self.deadzone_value
self.sensitivity = 20.0
# Dynamic attributes for different modes (declared here to satisfy type checkers)
self.winetricks_dialog: Any = None
self.settings_dialog: Any = None
self.file_explorer: Any = None
self.proton_manager_dialog: Any = None
self.appimage_update_dialog: Any = None
self._input_surfaces: list[Any] = []
self._input_surface_base_state: bool | None = None
self.scroll_accumulator = 0.0
self.scroll_sensitivity = 0.15
self.scroll_threshold = 0.2
self.last_update = time.time()
self.update_interval = 0.016 # ~60 FPS
self.emulation_active = False
self.emulation_triggered = False
self.start_held = False
self.select_held = False
self.pending_menu_fullscreen_time = 0.0
self.guide_held = False
# Variables for key combination handling
self.guide_pressed_time = 0
self.select_pressed_time = 0
self.guide_timer = QTimer(self)
self.guide_timer.setSingleShot(True)
self.guide_timer.timeout.connect(self._handle_guide_timeout)
self.guide_combination_timeout = 0.3 # 300ms timeout for combination
self.in_guide_combination_attempt = False # Flag to track if we're in a guide+select combination attempt
self._button_states: dict[int, int] = {}
self._hat_states: dict[int, tuple[int, int]] = {}
self._axis_states: dict[int, int] = {}
self._gamepad_polling_suspended = False
self._last_gamepad_check_time = 0.0
self._last_gamepad_error: str | None = None
# Focus check timer for emulation flag (runs in main thread)
self.focus_check_timer = QTimer(self)
self.focus_check_timer.timeout.connect(self._update_emulation_flag)
self.focus_check_timer.start(100) # Check every 100ms
logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled)
if self.mouse_emulation_enabled:
# Initialize mouse emulation asynchronously to avoid blocking startup
QTimer.singleShot(0, self._async_enable_mouse_emulation)
# FileExplorer specific attributes
self.file_explorer = None
self.nav_timer = QTimer(self)
self.nav_timer.timeout.connect(self.handle_navigation_repeat)
self.current_direction = 0
self.last_nav_time = 0
self.initial_nav_delay = 0.1 # Initial delay before first repeat (sec)
self.repeat_nav_delay = 0.05 # Interval between repeats (sec)
self.stick_activated = False
self.stick_value = 0 # Current stick value (for smoothness)
self.dead_zone = 8000 # Stick deadzone
self._is_gamescope_session = 'gamescope' in os.environ.get('DESKTOP_SESSION', '').lower()
# Add variables for continuous D-pad movement
self.dpad_timer = QTimer(self)
self.dpad_timer.timeout.connect(self.handle_dpad_repeat)
self.current_dpad_code = None # Tracks the current D-pad axis (e.g., ABS_HAT0X, ABS_HAT0Y)
self.current_dpad_value = 0 # Tracks the current D-pad direction value (e.g., -1, 1)
# Connect signals to slots
self.button_event.connect(self.handle_button_slot)
self.dpad_moved.connect(self.handle_dpad_slot)
self.toggle_fullscreen.connect(self.handle_fullscreen_slot)
# Install wheel event filter
app = QApplication.instance()
if app is not None:
app.installEventFilter(self)
self.init_gamepad()
def _async_enable_mouse_emulation(self):
"""Asynchronously enable mouse emulation to avoid blocking startup."""
logger.info("EMUL: Attempting to create UInput virtual mouse...")
self.mouse_emulation_thread = MouseEmulationThread()
self.mouse_emulation_thread.finished.connect(self._on_mouse_emulation_finished)
self.mouse_emulation_thread.start()
def _on_mouse_emulation_finished(self, success: bool):
"""Handle mouse emulation thread completion."""
if success and self.mouse_emulation_thread:
device = self.mouse_emulation_thread.get_device()
if device:
self.ui = device
self.mouse_emulation_enabled = True
logger.info("EMUL: Virtual mouse created successfully")
else:
self.mouse_emulation_enabled = False
def _update_emulation_flag(self):
"""Update emulation_active flag based on Qt app focus (main thread only)."""
# True when mouse emulation is enabled (works regardless of focus)
# The difference is whether other inputs are disabled (based on focus)
self.emulation_active = self.mouse_emulation_enabled
if not self.emulation_active:
self.emulation_triggered = False
def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None:
"""Common navigation logic for game cards in a container."""
if container is None:
return
game_cards = [
card for card in container.findChildren(GameCard)
if card.isVisible() and card.isEnabled()
]
focused = QApplication.focusWidget()
if game_cards and focused not in game_cards:
self._focus_grid_card(game_cards[0])
return
moved = self._navigate_card_grid(game_cards, code, value)
if not moved and code == PAD_DPAD_Y and value < 0 and focused in game_cards:
first_row = self._get_card_grid_rows(game_cards)[0]
if focused in first_row:
self._parent.tabButtons[tab_index].setFocus(Qt.FocusReason.OtherFocusReason)
def _get_card_grid_rows(self, cards: list[QWidget]) -> list[list[QWidget]]:
"""Group cards into rows ordered by their visual position."""
rows = {}
y_tolerance = 10 # Allow slight variations in y-position
for card in cards:
y = card.pos().y()
for row_y in rows:
if abs(y - row_y) <= y_tolerance:
rows[row_y].append(card)
break
else:
rows[y] = [card]
sorted_rows = [row for _y, row in sorted(rows.items())]
for row_cards in sorted_rows:
row_cards.sort(key=lambda card: card.pos().x())
return sorted_rows
def _grid_navigation_target(
self, rows: list[list[QWidget]], focused: QWidget, code: int, value: int
) -> QWidget | None:
row_index = next(index for index, row in enumerate(rows) if focused in row)
if code == PAD_DPAD_X:
flat_cards = [card for row in rows for card in row]
card_index = flat_cards.index(focused) + value
return flat_cards[card_index] if 0 <= card_index < len(flat_cards) else None
target_row_index = row_index + value
if not 0 <= target_row_index < len(rows):
return None
focused_x = focused.pos().x() + focused.width() / 2
return min(
rows[target_row_index],
key=lambda card: abs(card.pos().x() + card.width() / 2 - focused_x),
)
def _focus_grid_card(self, card: QWidget) -> None:
card.setFocus(Qt.FocusReason.OtherFocusReason)
scroll_area = card.parentWidget()
while scroll_area and not isinstance(scroll_area, QScrollArea):
scroll_area = scroll_area.parentWidget()
if isinstance(scroll_area, QScrollArea):
scroll_area.ensureWidgetVisible(card, 50, 50)
def _navigate_card_grid(self, cards: list[QWidget], code: int, value: int) -> bool:
"""Move focus through a card grid using its visual layout."""
if not cards or value == 0:
return False
rows = self._get_card_grid_rows(cards)
focused = QApplication.focusWidget()
if focused not in cards:
return False
target = self._grid_navigation_target(rows, focused, code, value)
if target is None:
return False
self._focus_grid_card(target)
return True
def handle_navigation_repeat(self):
"""Smooth movement repeat with variable speed for FileExplorer"""
try:
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list') or not self.file_explorer.file_list:
return
if self.current_direction != 0:
now = time.time()
# Dynamic interval based on stick_value
dynamic_delay = self.repeat_nav_delay / self.stick_value
if now - self.last_nav_time >= dynamic_delay:
self.file_explorer.move_selection(self.current_direction)
self.last_nav_time = now
except Exception as e:
logger.error(f"Error in navigation repeat: {e}")
def disable_mouse_emulation(self):
"""Disable mouse emulation mode (closes virtual mouse device)."""
logger.info("EMUL: Disabling mouse emulation...")
# Stop the thread if still running
thread = self.mouse_emulation_thread
if thread and thread.isRunning():
thread.wait()
self.mouse_emulation_thread = None
if self.ui:
try:
self.ui.close()
logger.info("EMUL: Virtual mouse closed")
except Exception as e:
logger.error("EMUL: Error closing virtual mouse: %s", e)
self.ui = None
self.mouse_emulation_enabled = False
self.stick_x_raw = 0
self.stick_y_raw = 0
self.scroll_accumulator = 0.0
def handle_scroll(self, raw_value):
"""Handle scrolling from right stick Y"""
if not self._is_mouse_emulation_active() or not self.ui:
return
# Normalize from center
centered_value = raw_value - self.scroll_center
if abs(centered_value) < self.scroll_deadzone_value:
self.scroll_accumulator = 0.0
return
# Normalize value (-1.0 to 1.0)
range_val = (self.scroll_max_value - self.scroll_min_value) / 2
if range_val <= 0:
return
normalized = centered_value / range_val
# Accumulate scroll
self.scroll_accumulator += normalized * self.scroll_sensitivity
# Send scroll events
while abs(self.scroll_accumulator) >= self.scroll_threshold:
scroll_step = 1 if self.scroll_accumulator > 0 else -1
self.scroll_wheel(-scroll_step)
self.scroll_accumulator -= scroll_step * self.scroll_threshold
def update_mouse_position(self):
"""Constant update of mouse position based on stick state"""
if not self.ui or not self.emulation_active:
return
# Center values
x = self.stick_x_raw - self.center_x
y = self.stick_y_raw - self.center_y
# Apply deadzone from kernel
magnitude = math.sqrt(x * x + y * y)
if magnitude < self.deadzone_value:
return
if magnitude > 0:
norm_x = x / magnitude
norm_y = y / magnitude
else:
return
# Normalize to axis range
max_range = (self.max_value - self.min_value) / 2
adjusted_magnitude = (magnitude - self.deadzone_value) / (max_range - self.deadzone_value)
adjusted_magnitude = max(0.0, min(1.0, adjusted_magnitude))
# Non-linear curve
adjusted_magnitude = math.pow(adjusted_magnitude, 1.5)
speed = adjusted_magnitude * self.sensitivity
dx = int(norm_x * speed)
dy = int(norm_y * speed)
if dx != 0 or dy != 0:
self.move_mouse(dx, dy)
def move_mouse(self, dx, dy):
"""Move system cursor"""
if self.ui:
self.ui.write(ecodes.EV_REL, ecodes.REL_X, dx)
self.ui.write(ecodes.EV_REL, ecodes.REL_Y, dy)
self.ui.syn()
def scroll_wheel(self, steps):
"""Mouse wheel scroll"""
if self.ui:
self.ui.write(ecodes.EV_REL, ecodes.REL_WHEEL, steps)
self.ui.syn()
def click_left(self):
"""Left mouse button click"""
if self.ui:
self.ui.write(ecodes.EV_KEY, ecodes.BTN_LEFT, 1)
self.ui.syn()
self.ui.write(ecodes.EV_KEY, ecodes.BTN_LEFT, 0)
self.ui.syn()
def click_right(self):
"""Right mouse button click"""
if self.ui:
self.ui.write(ecodes.EV_KEY, ecodes.BTN_RIGHT, 1)
self.ui.syn()
self.ui.write(ecodes.EV_KEY, ecodes.BTN_RIGHT, 0)
self.ui.syn()
@Slot(bool)
def handle_fullscreen_slot(self, enable: bool) -> None:
try:
window = self._parent
if not isinstance(window, QWidget):
return
if enable and not self._is_fullscreen:
if not window.isFullScreen():
window_config.set_geometry(window.width(), window.height())
window.showFullScreen()
self._is_fullscreen = True
elif not enable and self._is_fullscreen:
window.showNormal()
width, height = window_config.get_geometry()
if width > 0 and height > 0:
window.resize(width, height)
self._is_fullscreen = False
window_config.set_geometry(width, height)
except Exception as e:
logger.error(f"Error in handle_fullscreen_slot: {e}", exc_info=True)
def disable_gamepad_handling(self) -> None:
"""Disable gamepad event handling."""
self._gamepad_handling_enabled = False
self.dpad_timer.stop()
self.nav_timer.stop()
def enable_gamepad_handling(self) -> None:
"""Enable gamepad event handling."""
self._gamepad_handling_enabled = True
def suspend_gamepad_polling(self) -> None:
"""Disable PPQT gamepad handling while keeping mouse emulation available."""
self._gamepad_polling_suspended = True
self._gamepad_handling_enabled = False
self.dpad_timer.stop()
self.nav_timer.stop()
def resume_gamepad_polling(self) -> None:
"""Resume SDL controller polling after the external game exits."""
self._gamepad_polling_suspended = False
self._gamepad_handling_enabled = True
self.check_gamepad()
def _handle_guide_timeout(self) -> None:
if self.guide_held:
time_since_guide = time.time() - self.guide_pressed_time
time_since_select = time.time() - self.select_pressed_time
if (self.select_pressed_time > self.guide_pressed_time and
time_since_select <= self.guide_combination_timeout and
time_since_guide <= self.guide_combination_timeout):
logger.debug("Guide + Select combination detected, refreshing game grid")
self._parent.refreshGames()
else:
logger.debug("Guide button pressed alone")
self._reset_guide_combination()
def _handle_common_ui_elements(self, button_code):
"""
Common handler for common UI elements like QMessageBox, QMenu, etc.
Returns True if the event was handled, False otherwise.
"""
# Check for popup widgets first
popup = QApplication.activePopupWidget()
if popup:
if isinstance(popup, QMessageBox):
self._handle_qmessagebox_button(popup, button_code)
SoundManager().play("click")
return True
elif isinstance(popup, QMenu):
if button_code in BUTTONS['confirm']:
SoundManager().play("click")
if popup.activeAction():
popup.activeAction().trigger()
popup.close()
elif button_code in BUTTONS['back']:
SoundManager().play("back")
popup.close()
return True
# Check for top-level QMessageBox specifically
active = QApplication.activeWindow()
if isinstance(active, QMessageBox):
self._handle_qmessagebox_button(active, button_code)
return True
# Check for top-level message boxes (additional check)
for widget in QApplication.topLevelWidgets():
if isinstance(widget, QMessageBox) and widget.isVisible() and widget != active:
self._handle_qmessagebox_button(widget, button_code)
return True
return False # Not handled by common handler
def _handle_qmessagebox_button(self, msg_box, button_code):
"""
Unified handler for QMessageBox across all modes.
For single button dialogs, A button accepts the dialog.
For multiple button dialogs, navigate between buttons and allow selection.
"""
if button_code in BUTTONS['confirm']:
# Check if there's a focused button in the message box
focused_widget = msg_box.focusWidget()
if focused_widget:
# If a specific button is focused, click/activate it
focused_widget.click()
else:
# If no button is focused, accept with default behavior
msg_box.accept()
elif button_code in BUTTONS['back']:
# For back button, reject the dialog (typically cancels or selects cancel button)
msg_box.reject()
+355
View File
@@ -0,0 +1,355 @@
import time
from PySide6.QtCore import QPoint, QTimer, Slot, Qt
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QDialog,
QLineEdit,
QListView,
QSlider,
QStackedWidget,
QTableWidget,
QWidget,
)
from portprotonqt.dialogs import AddGameDialog
from portprotonqt.game_card import GameCard
from portprotonqt.image_utils import FullscreenDialog
from portprotonqt.input_manager.constants import (
BUTTONS,
SIZE_SLIDER_STEP,
SYSTEM_AUDIO_SECTION_INDEX,
SYSTEM_VOLUME_STEP,
)
from portprotonqt.input_manager.mixin import InputMixin
from portprotonqt.logger import get_logger
from portprotonqt.sound_manager import SoundManager
from portprotonqt.virtual_keyboard import VirtualKeyboard
logger = get_logger(__name__)
class ButtonInputMixin(InputMixin):
@Slot(int, int)
def handle_button_slot(self, button_code: int, value: int) -> None:
if self._route_surface_button(button_code, value):
return
self._handle_default_button(button_code, value)
def _handle_default_button(self, button_code: int, value: int) -> None:
if value == 1 and self._handle_common_ui_elements(button_code):
return
active_window = QApplication.activeWindow()
if self._route_button_to_keyboard(active_window, button_code, value):
return
if value == 0:
return
if not self._gamepad_handling_enabled:
return
try:
app = QApplication.instance()
active = QApplication.activeWindow()
focused = QApplication.focusWidget()
if not app or not active:
return
if self._route_button_to_text_input(active, focused, button_code):
return
if self._handle_guide_combination(button_code, time.time()):
return
if self._handle_combo_button(focused, button_code):
return
if self._handle_list_view_button(focused, button_code):
return
if self._handle_active_window_button(active, button_code):
return
if self._open_focused_context_menu(focused, button_code):
return
if self._handle_system_table_button(focused, button_code):
return
if self._handle_system_quick_button(button_code):
return
self._handle_standard_button(focused, button_code, value)
except Exception as e:
logger.error(f"Error in handle_button_slot: {e}", exc_info=True)
def _open_focused_context_menu(self, focused: QWidget | None, button_code: int) -> bool:
if button_code not in BUTTONS['context_menu'] or focused is None:
return False
if isinstance(focused, QTableWidget):
current_row = focused.currentRow()
current_col = max(focused.currentColumn(), 0)
item = focused.item(current_row, current_col) if current_row >= 0 else None
if item is None and current_row >= 0:
item = focused.item(current_row, 0)
point = (
focused.visualItemRect(item).center()
if item is not None
else focused.viewport().rect().center()
)
focused.customContextMenuRequested.emit(point)
return True
if focused.contextMenuPolicy() == Qt.ContextMenuPolicy.CustomContextMenu:
focused.customContextMenuRequested.emit(focused.rect().center())
return True
if not isinstance(focused, GameCard):
return False
position = QPoint(focused.width() // 2, focused.height() // 2)
menu = focused._show_context_menu(position)
if menu:
menu.setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _handle_combo_button(self, focused: QWidget | None, button_code: int) -> bool:
if not isinstance(focused, QComboBox):
return False
if button_code in BUTTONS['confirm']:
focused.showPopup()
return True
if button_code in BUTTONS['back'] and focused.view().isVisible():
focused.hidePopup()
return True
return False
def _route_button_to_text_input(
self, active: QWidget, focused: QWidget | None, button_code: int
) -> bool:
keyboard = (
getattr(active, 'keyboard', None)
if isinstance(active, AddGameDialog)
else getattr(self._parent, 'keyboard', None)
)
if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit):
if keyboard:
keyboard.show_for_widget(focused)
return True
if button_code not in BUTTONS['prev_dir']:
return False
combo = focused if isinstance(focused, QComboBox) else None
if combo is None and focused is not None:
combo = self._find_parent_combo(focused)
if combo is not None and combo.isEditable():
line_edit = combo.lineEdit()
if line_edit is not None and keyboard:
line_edit.setFocus()
keyboard.show_for_widget(line_edit)
return True
current_tab = self._parent.stackedWidget.currentIndex()
search_name = 'searchEdit' if current_tab == 0 else 'autoInstallSearchLineEdit'
search_edit = getattr(self._parent, search_name, None) if current_tab in (0, 1) else None
if search_edit is None:
return False
search_edit.setFocus()
return True
def _find_parent_combo(self, widget: QWidget) -> QComboBox | None:
parent = widget.parentWidget()
while parent:
if isinstance(parent, QComboBox):
return parent
parent = parent.parentWidget()
return None
def _activate_list_view_selection(self, view: QListView) -> None:
index = view.currentIndex()
if not index.isValid():
return
combo = self._find_parent_combo(view)
if combo:
combo.setCurrentIndex(index.row())
combo.hidePopup()
combo.setFocus(Qt.FocusReason.OtherFocusReason)
return
view.activated.emit(index)
view.clicked.emit(index)
view.hide()
def _handle_list_view_button(self, focused: QWidget | None, button_code: int) -> bool:
if not isinstance(focused, QListView):
return False
if button_code in BUTTONS['confirm']:
self._activate_list_view_selection(focused)
return True
if button_code not in BUTTONS['back']:
return False
combo = self._find_parent_combo(focused)
if combo:
combo.hidePopup()
combo.setFocus(Qt.FocusReason.OtherFocusReason)
else:
focused.clearSelection()
focused.hide()
return False
def _handle_system_table_button(self, focused: QWidget | None, button_code: int) -> bool:
if not isinstance(focused, QTableWidget):
return False
handler = getattr(self._parent, "handleSystemTableGamepadAction", None)
if not callable(handler):
return False
routes = (
('confirm', "confirm", "click"),
('back', "back", "back"),
('prev_dir', "prev_dir", "click"),
('add_game', "add_game", "click"),
)
for button_name, action, sound in routes:
if button_code in BUTTONS[button_name] and handler(focused, action):
SoundManager().play(sound)
return True
return False
def _handle_active_window_button(self, active: QWidget, button_code: int) -> bool:
if button_code in BUTTONS['back'] and isinstance(active, QDialog):
active.reject()
return True
if not isinstance(active, FullscreenDialog):
return False
if button_code in BUTTONS['prev_tab']:
active.show_prev()
elif button_code in BUTTONS['next_tab']:
active.show_next()
elif button_code in BUTTONS['back']:
active.close()
return True
def _handle_system_quick_button(self, button_code: int) -> bool:
handler = getattr(self._parent, "handleSystemGamepadAction", None)
if not callable(handler) or button_code not in BUTTONS['add_game']:
return False
if not handler("add_game"):
return False
SoundManager().play("open")
return True
def _handle_standard_button(
self, focused: QWidget | None, button_code: int, value: int
) -> None:
if button_code in BUTTONS['confirm']:
if not isinstance(focused, GameCard):
SoundManager().play_widget_sound(focused)
QTimer.singleShot(0, self._parent.activateFocusedWidget)
elif button_code in BUTTONS['back']:
self._handle_standard_back()
elif button_code in BUTTONS['add_game']:
if self._parent.stackedWidget.currentIndex() == 0:
SoundManager().play("open")
self._parent.openAddGameDialog()
elif button_code in BUTTONS['prev_tab']:
self._switch_visible_tab(-1)
elif button_code in BUTTONS['next_tab']:
self._switch_visible_tab(1)
elif button_code in BUTTONS['increase_size'] and value > 0:
self._adjust_active_slider(1)
elif button_code in BUTTONS['decrease_size'] and value > 0:
self._adjust_active_slider(-1)
def _handle_standard_back(self) -> None:
SoundManager().play("back")
if self._is_theme_store_visible():
store_stack = getattr(self._parent, "themeStoreStack", None)
detail_page = getattr(self._parent, "themeStoreDetailPage", None)
show_list = getattr(self._parent, '_show_theme_store_list', None)
if store_stack is not None and store_stack.currentWidget() == detail_page:
if callable(show_list):
show_list()
return
self._parent.goBackDetailPage(getattr(self._parent, 'currentDetailPage', None))
def _adjust_active_slider(self, direction: int) -> bool:
current_tab = self._parent.stackedWidget.currentIndex()
if current_tab == getattr(self._parent, "system_tab_index", -1):
return self._adjust_system_volume(direction)
if current_tab == 0:
manager = getattr(self._parent, 'game_library_manager', None)
slider = getattr(manager, 'sizeSlider', None)
callback = getattr(self._parent, 'on_slider_released', None)
elif current_tab == 1:
slider = getattr(self._parent, 'auto_size_slider', None)
callback = getattr(self._parent, 'on_auto_slider_released', None)
elif current_tab == getattr(self._parent, "theme_tab_index", -1):
slider = getattr(self._parent, 'themeStoreSizeSlider', None)
callback = getattr(self._parent, '_on_theme_store_slider_released', None)
else:
return False
if not isinstance(slider, QSlider):
return False
slider.setValue(slider.value() + direction * SIZE_SLIDER_STEP)
if callable(callback):
callback()
return True
def _adjust_system_volume(self, direction: int) -> bool:
section_stack = getattr(self._parent, "systemSectionStack", None)
slider = getattr(self._parent, "audioVolumeSlider", None)
if (
not isinstance(section_stack, QStackedWidget)
or section_stack.currentIndex() != SYSTEM_AUDIO_SECTION_INDEX
):
return False
if not isinstance(slider, QSlider):
return False
slider.setValue(slider.value() + direction * SYSTEM_VOLUME_STEP)
apply_volume = getattr(self._parent, "_applySelectedAudioVolume", None)
if callable(apply_volume):
apply_volume()
return True
def _handle_guide_combination(self, button_code: int, current_time: float) -> bool:
if button_code in BUTTONS['guide']:
self.guide_held = True
self.guide_pressed_time = current_time
self.in_guide_combination_attempt = True
self.guide_timer.start(int(self.guide_combination_timeout * 1000))
return True
if button_code not in BUTTONS['menu'] or not self.guide_held:
return False
self.select_pressed_time = current_time
if current_time - self.guide_pressed_time > self.guide_combination_timeout:
self._reset_guide_combination()
return False
self.guide_timer.stop()
logger.debug("Guide + Select combination detected, refreshing game grid")
self._parent.refreshGames()
self._reset_guide_combination()
return True
def _reset_guide_combination(self) -> None:
self.guide_held = False
self.in_guide_combination_attempt = False
self.guide_pressed_time = 0
self.select_pressed_time = 0
def _route_button_to_keyboard(
self, active_window: QWidget | None, button_code: int, value: int
) -> bool:
if isinstance(active_window, AddGameDialog):
focused = QApplication.focusWidget()
if button_code in BUTTONS['confirm'] and value == 1 and isinstance(focused, QLineEdit):
active_window.show_keyboard_for_widget(focused)
return True
keyboard = getattr(active_window, 'keyboard', None)
if keyboard and keyboard.isVisible():
self.handle_virtual_keyboard(button_code, value)
return True
dialog_keyboard = getattr(active_window, 'keyboard', None) if active_window else None
if isinstance(dialog_keyboard, VirtualKeyboard) and dialog_keyboard.isVisible():
self.handle_virtual_keyboard(button_code, value)
return True
keyboard = getattr(self._parent, 'keyboard', None)
if keyboard and keyboard.isVisible():
self.handle_virtual_keyboard(button_code, value)
return True
return False
+131
View File
@@ -0,0 +1,131 @@
from enum import Enum
from PySide6.QtCore import Qt
SDL_GAMEPAD_TYPE_STANDARD = 1
SDL_GAMEPAD_TYPE_XBOX360 = 2
SDL_GAMEPAD_TYPE_XBOXONE = 3
SDL_GAMEPAD_TYPE_PS3 = 4
SDL_GAMEPAD_TYPE_PS4 = 5
SDL_GAMEPAD_TYPE_PS5 = 6
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO = 7
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT = 8
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT = 9
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR = 10
SDL_GAMEPAD_TYPE_GAMECUBE = 11
SDL_GAMEPAD_TYPE_STEAM = 12
SDL_GAMEPAD_BUTTON_SOUTH = 0
SDL_GAMEPAD_BUTTON_EAST = 1
SDL_GAMEPAD_BUTTON_WEST = 2
SDL_GAMEPAD_BUTTON_NORTH = 3
SDL_GAMEPAD_BUTTON_BACK = 4
SDL_GAMEPAD_BUTTON_GUIDE = 5
SDL_GAMEPAD_BUTTON_START = 6
SDL_GAMEPAD_BUTTON_LEFT_SHOULDER = 9
SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER = 10
SDL_GAMEPAD_BUTTON_DPAD_UP = 11
SDL_GAMEPAD_BUTTON_DPAD_DOWN = 12
SDL_GAMEPAD_BUTTON_DPAD_LEFT = 13
SDL_GAMEPAD_BUTTON_DPAD_RIGHT = 14
SDL_GAMEPAD_AXIS_LEFTX = 0
SDL_GAMEPAD_AXIS_LEFTY = 1
SDL_GAMEPAD_AXIS_RIGHTX = 2
SDL_GAMEPAD_AXIS_RIGHTY = 3
SDL_GAMEPAD_AXIS_LEFT_TRIGGER = 4
SDL_GAMEPAD_AXIS_RIGHT_TRIGGER = 5
class GamepadType(Enum):
XBOX = "Xbox"
PLAYSTATION = "PlayStation"
UNKNOWN = "Unknown"
SDL3_XBOX_LIKE_TYPES = {
SDL_GAMEPAD_TYPE_STANDARD,
SDL_GAMEPAD_TYPE_XBOX360,
SDL_GAMEPAD_TYPE_XBOXONE,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR,
SDL_GAMEPAD_TYPE_GAMECUBE,
SDL_GAMEPAD_TYPE_STEAM,
}
SDL3_PLAYSTATION_TYPES = {
SDL_GAMEPAD_TYPE_PS3,
SDL_GAMEPAD_TYPE_PS4,
SDL_GAMEPAD_TYPE_PS5,
}
PAD_BUTTON_SOUTH = 1000
PAD_BUTTON_EAST = 1001
PAD_BUTTON_WEST = 1002
PAD_BUTTON_NORTH = 1003
PAD_BUTTON_SELECT = 1004
PAD_BUTTON_GUIDE = 1005
PAD_BUTTON_START = 1006
PAD_BUTTON_LEFT_SHOULDER = 1007
PAD_BUTTON_RIGHT_SHOULDER = 1008
PAD_AXIS_LEFT_TRIGGER = 1100
PAD_AXIS_RIGHT_TRIGGER = 1101
PAD_AXIS_LEFT_X = 1200
PAD_AXIS_LEFT_Y = 1201
PAD_AXIS_RIGHT_X = 1202
PAD_AXIS_RIGHT_Y = 1203
PAD_DPAD_X = 1300
PAD_DPAD_Y = 1301
INPUT_AXIS_SCALE = 32767
KEY_BACKSPACE = Qt.Key.Key_Backspace.value
KEY_DOWN = Qt.Key.Key_Down.value
KEY_ENTER = Qt.Key.Key_Enter.value
KEY_ESCAPE = Qt.Key.Key_Escape.value
KEY_F5 = Qt.Key.Key_F5.value
KEY_F10 = Qt.Key.Key_F10.value
KEY_F11 = Qt.Key.Key_F11.value
KEY_LEFT = Qt.Key.Key_Left.value
KEY_RETURN = Qt.Key.Key_Return.value
KEY_RIGHT = Qt.Key.Key_Right.value
KEY_UP = Qt.Key.Key_Up.value
KEY_E = ord("e")
KEY_Q = ord("q")
MOD_CTRL = 1
MOD_SHIFT = 2
SIZE_SLIDER_STEP = 10
SYSTEM_AUDIO_SECTION_INDEX = 4
SYSTEM_VOLUME_STEP = 5
BUTTONS = {
'confirm': {PAD_BUTTON_SOUTH},
'back': {PAD_BUTTON_EAST},
'add_game': {PAD_BUTTON_WEST},
'prev_dir': {PAD_BUTTON_NORTH},
'prev_tab': {PAD_BUTTON_LEFT_SHOULDER},
'next_tab': {PAD_BUTTON_RIGHT_SHOULDER},
'context_menu': {PAD_BUTTON_START},
'menu': {PAD_BUTTON_SELECT},
'guide': {PAD_BUTTON_GUIDE},
'increase_size': {PAD_AXIS_RIGHT_TRIGGER},
'decrease_size': {PAD_AXIS_LEFT_TRIGGER},
}
SDL_CONTROLLER_BUTTON_TO_PAD = {
SDL_GAMEPAD_BUTTON_SOUTH: PAD_BUTTON_SOUTH,
SDL_GAMEPAD_BUTTON_EAST: PAD_BUTTON_EAST,
SDL_GAMEPAD_BUTTON_WEST: PAD_BUTTON_WEST,
SDL_GAMEPAD_BUTTON_NORTH: PAD_BUTTON_NORTH,
SDL_GAMEPAD_BUTTON_BACK: PAD_BUTTON_SELECT,
SDL_GAMEPAD_BUTTON_GUIDE: PAD_BUTTON_GUIDE,
SDL_GAMEPAD_BUTTON_START: PAD_BUTTON_START,
SDL_GAMEPAD_BUTTON_LEFT_SHOULDER: PAD_BUTTON_LEFT_SHOULDER,
SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER: PAD_BUTTON_RIGHT_SHOULDER,
}
SDL_CONTROLLER_AXIS_TO_PAD = {
SDL_GAMEPAD_AXIS_LEFTX: PAD_AXIS_LEFT_X,
SDL_GAMEPAD_AXIS_LEFTY: PAD_AXIS_LEFT_Y,
SDL_GAMEPAD_AXIS_RIGHTX: PAD_AXIS_RIGHT_X,
SDL_GAMEPAD_AXIS_RIGHTY: PAD_AXIS_RIGHT_Y,
SDL_GAMEPAD_AXIS_LEFT_TRIGGER: PAD_AXIS_LEFT_TRIGGER,
SDL_GAMEPAD_AXIS_RIGHT_TRIGGER: PAD_AXIS_RIGHT_TRIGGER,
}
+711
View File
@@ -0,0 +1,711 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any, cast
from PySide6.QtCore import QObject, Qt
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QCheckBox,
QComboBox,
QListView,
QListWidget,
QMenu,
QStackedWidget,
QTableWidget,
QWidget,
)
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.input_manager.constants import BUTTONS, PAD_DPAD_X, PAD_DPAD_Y
from portprotonqt.logger import get_logger
from portprotonqt.input_manager.mixin import InputMixin
from portprotonqt.sound_manager import SoundManager
logger = get_logger(__name__)
@dataclass
class InputSurface:
name: str
dialog: Any
button_handler: Callable[[int, int], None]
dpad_handler: Callable[[int, int, float], None]
connections: list[tuple[Any, Callable[..., Any]]] = field(default_factory=list)
class DialogInputModesMixin(InputMixin):
# WINETRICKS MODE
def enable_winetricks_mode(self, winetricks_dialog):
"""Setup gamepad handling for WinetricksDialog"""
try:
self._setup_mode_handlers(
winetricks_dialog,
self.handle_winetricks_button,
self.handle_winetricks_dpad,
'winetricks_dialog'
)
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._restore_original_handlers('winetricks_dialog')
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 or value == 0:
return
try:
# Handle common UI elements like QMessageBox, QMenu, etc.
if self._handle_common_ui_elements(button_code):
return
# Winetricks-specific button handling
focused = QApplication.focusWidget()
if button_code in BUTTONS['confirm']: # A: Toggle checkbox
if isinstance(focused, QTableWidget):
self.handle_table_confirm(focused)
return
elif button_code in BUTTONS['add_game']: # X: Install
self.winetricks_dialog.install_selected(force=False)
elif button_code in BUTTONS['prev_dir']: # Y: Force Install
self.winetricks_dialog.install_selected(force=True)
elif button_code in BUTTONS['back']: # B: Cancel
self.winetricks_dialog.reject()
elif button_code in BUTTONS['prev_tab']: # LB
current_index = self.winetricks_dialog.tab_widget.currentIndex()
new_index = max(0, current_index - 1)
self.winetricks_dialog.tab_widget.setCurrentIndex(new_index)
if new_index != current_index:
SoundManager().play("tab_switch")
self._focus_first_row_in_current_table()
elif button_code in BUTTONS['next_tab']: # RB
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)
if new_index != current_index:
SoundManager().play("tab_switch")
self._focus_first_row_in_current_table()
else:
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
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
return
# Timer setup
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)
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 == PAD_DPAD_Y: # Up/Down
step = -1 if value < 0 else 1
new_row = current_row + step
# Skip hidden rows
while 0 <= new_row < table.rowCount() and table.isRowHidden(new_row):
new_row += step
# Bounds check
if new_row < 0:
new_row = current_row
if new_row >= table.rowCount():
new_row = current_row
if new_row != current_row:
table.setCurrentCell(new_row, 0)
table.setFocus(Qt.FocusReason.OtherFocusReason)
except Exception as e:
logger.error(f"Error in handle_winetricks_dpad: {e}")
def _get_current_table(self):
if self.winetricks_dialog:
current_container = self.winetricks_dialog.tab_widget.currentWidget()
if isinstance(current_container, QStackedWidget):
current_table = current_container.widget(1)
if isinstance(current_table, QTableWidget):
return current_table
return None
def _focus_first_row_in_current_table(self):
table = self._get_current_table()
if table and table.rowCount() > 0:
table.setCurrentCell(0, 0)
table.setFocus(Qt.FocusReason.OtherFocusReason)
def _get_navigable_menu_actions(self, menu: QMenu) -> list:
return [
action
for action in menu.actions()
if not action.isSeparator() and action.isEnabled() and action.isVisible()
]
def _navigate_menu_actions(self, menu: QMenu, direction_down: bool) -> None:
actions = self._get_navigable_menu_actions(menu)
if not actions:
return
current_action = menu.activeAction()
if current_action not in actions:
target_index = 0 if direction_down else len(actions) - 1
menu.setActiveAction(actions[target_index])
SoundManager().play("navigate")
return
current_index = actions.index(current_action)
step = 1 if direction_down else -1
next_index = (current_index + step) % len(actions)
menu.setActiveAction(actions[next_index])
SoundManager().play("navigate")
# TABLE NAVIGATION METHODS
def handle_table_navigation(self, table: QTableWidget, code: int, value: int):
"""
Handle navigation in table
Args:
table: QTableWidget for navigation handling
code: Event code (usually ABS_HAT0X or ABS_HAT0Y)
value: Event value (direction)
"""
row_count = table.rowCount()
if row_count <= 0:
return
current_row = table.currentRow()
if current_row < 0:
current_row = 0
table.setCurrentCell(0, 0)
if code == PAD_DPAD_Y and value != 0:
# Vertical navigation
if value > 0: # Down
new_row = min(current_row + 1, row_count - 1)
elif value < 0: # Up
new_row = max(current_row - 1, 0)
else:
return
table.setCurrentCell(new_row, table.currentColumn())
item = table.item(new_row, table.currentColumn())
if item:
table.scrollToItem(
item,
QAbstractItemView.ScrollHint.PositionAtCenter
)
table.setFocus(Qt.FocusReason.OtherFocusReason)
return
elif code == PAD_DPAD_X and value != 0:
# Horizontal navigation
col_count = table.columnCount()
current_col = table.currentColumn()
if current_col < 0:
current_col = 0
if value < 0: # Left
new_col = max(current_col - 1, 0)
elif value > 0: # Right
new_col = min(current_col + 1, col_count - 1)
else:
return
table.setCurrentCell(table.currentRow(), new_col)
table.setFocus(Qt.FocusReason.OtherFocusReason)
return
def handle_table_confirm(self, table: QTableWidget):
"""
Handle confirmation (e.g., A press) for table
Args:
table: QTableWidget for confirmation handling
"""
current_row = table.currentRow()
current_col = table.currentColumn()
if current_row >= 0 and current_col >= 0:
cell_widget = table.cellWidget(current_row, current_col)
if isinstance(cell_widget, QCheckBox) and cell_widget.isEnabled():
cell_widget.setChecked(not cell_widget.isChecked())
return True
if cell_widget is not None:
checkbox = cell_widget.findChild(QCheckBox)
if checkbox and checkbox.isEnabled():
checkbox.setChecked(not checkbox.isChecked())
return True
# Check if the cell contains a checkbox
item = table.item(current_row, current_col)
if item and (item.flags() & Qt.ItemFlag.ItemIsUserCheckable):
# Toggle the checkbox state
new_state = Qt.CheckState.Checked if item.checkState() == Qt.CheckState.Unchecked else Qt.CheckState.Unchecked
item.setCheckState(new_state)
return True
# Call custom confirm callback if exists
callback = getattr(table, '_on_confirm_callback', None) # type: ignore
if callback and callable(callback):
callback(table, current_row, current_col)
return True
# WIDGET NAVIGATION METHODS
def setup_widget_navigation(self, widget: QWidget, navigation_type: str = "default", **kwargs):
"""
Set navigation for widget
Args:
widget: QWidget for navigation setup
navigation_type: Navigation type ('table', 'list', 'combo', 'default')
**kwargs: Additional parameters for navigation
"""
widget.installEventFilter(cast(QObject, self))
# Use direct assignment for custom navigation properties, with type ignore for pyright
widget._navigation_type = navigation_type # type: ignore
for key, value in kwargs.items():
setattr(widget, f'_{key}', value)
def handle_widget_navigation(self, widget: QWidget, code: int, value: int):
"""
Handle navigation in widget
Args:
widget: QWidget for navigation handling
code: Event code (usually ABS_HAT0X or ABS_HAT0Y)
value: Event value (direction)
"""
nav_type = getattr(widget, '_navigation_type', 'default') # type: ignore
if nav_type == 'table' and isinstance(widget, QTableWidget):
self.handle_table_navigation(widget, code, value)
elif nav_type == 'list' and isinstance(widget, QListWidget):
self.handle_list_navigation(widget, code, value)
elif nav_type == 'combo' and isinstance(widget, QComboBox):
self.handle_combo_navigation(widget, code, value)
else:
# Default navigation behavior
if isinstance(widget, QTableWidget):
self.handle_table_navigation(widget, code, value)
elif isinstance(widget, QListWidget):
self.handle_list_navigation(widget, code, value)
elif isinstance(widget, QComboBox):
self.handle_combo_navigation(widget, code, value)
def handle_list_navigation(self, list_widget: QListWidget, code: int, value: int):
"""
Handle navigation in list
Args:
list_widget: QListWidget for navigation handling
code: Event code (usually ABS_HAT0X or ABS_HAT0Y)
value: Event value (direction)
"""
if code == PAD_DPAD_Y and value != 0:
model = list_widget.model()
current_index = list_widget.currentIndex()
if model and current_index.isValid():
row_count = model.rowCount()
current_row = current_index.row()
if value > 0: # Down
next_row = min(current_row + 1, row_count - 1)
list_widget.setCurrentIndex(model.index(next_row, current_index.column()))
elif value < 0: # Up
prev_row = max(current_row - 1, 0)
list_widget.setCurrentIndex(model.index(prev_row, current_index.column()))
list_widget.scrollTo(list_widget.currentIndex(), QListView.ScrollHint.PositionAtCenter)
def handle_combo_navigation(self, combo_widget: QComboBox, code: int, value: int):
"""
Handle navigation in combo widget
Args:
combo_widget: QComboBox for navigation handling
code: Event code (usually ABS_HAT0X or ABS_HAT0Y)
value: Event value (direction)
"""
if code == PAD_DPAD_Y and value != 0:
current_index = combo_widget.currentIndex()
if value > 0: # Down
new_index = min(current_index + 1, combo_widget.count() - 1)
elif value < 0: # Up
new_index = max(current_index - 1, 0)
else:
return
if new_index != current_index:
combo_widget.setCurrentIndex(new_index)
def _setup_mode_handlers(self, dialog_instance, button_handler, dpad_handler, dialog_attr_name):
"""Register a dialog as the active input surface."""
if not self._input_surfaces:
self._input_surface_base_state = self._gamepad_handling_enabled
previous_surface = next(
(surface for surface in self._input_surfaces if surface.name == dialog_attr_name),
None,
)
if previous_surface is not None:
for signal, callback in previous_surface.connections:
signal.disconnect(callback)
self._input_surfaces = [
surface for surface in self._input_surfaces
if surface.name != dialog_attr_name
]
surface = InputSurface(
dialog_attr_name,
dialog_instance,
button_handler,
dpad_handler,
)
self._input_surfaces.append(surface)
setattr(self, dialog_attr_name, dialog_instance)
self._gamepad_handling_enabled = True
self._reset_surface_navigation()
def _restore_original_handlers(self, dialog_attr_name):
"""Remove a dialog input surface and restore the preceding surface."""
surface = next(
(item for item in reversed(self._input_surfaces) if item.name == dialog_attr_name),
None,
)
if surface is None:
logger.warning("Cannot restore input surface: %s is not registered", dialog_attr_name)
return
for signal, callback in surface.connections:
signal.disconnect(callback)
self._input_surfaces.remove(surface)
setattr(self, dialog_attr_name, None)
if not self._input_surfaces and self._input_surface_base_state is not None:
self._gamepad_handling_enabled = self._input_surface_base_state
self._input_surface_base_state = None
self._reset_surface_navigation()
def connect_surface_updates(
self,
dialog_attr_name: str,
callback: Callable[..., Any],
) -> None:
"""Connect a callback for the lifetime of a dialog input surface."""
surface = next(
(item for item in reversed(self._input_surfaces) if item.name == dialog_attr_name),
None,
)
if surface is None:
logger.warning("Cannot connect input surface: %s is not registered", dialog_attr_name)
return
for signal in (self.button_event, self.dpad_moved):
signal.connect(callback)
surface.connections.append((signal, callback))
def connect_surface_signal(
self,
dialog_attr_name: str,
signal: Any,
callback: Callable[..., Any],
) -> None:
"""Connect one signal for the lifetime of a dialog input surface."""
surface = next(
(item for item in reversed(self._input_surfaces) if item.name == dialog_attr_name),
None,
)
if surface is None:
logger.warning("Cannot connect input surface: %s is not registered", dialog_attr_name)
return
signal.connect(callback)
surface.connections.append((signal, callback))
def _reset_surface_navigation(self) -> None:
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
def _route_surface_button(self, button_code: int, value: int) -> bool:
if not self._input_surfaces:
return False
self._input_surfaces[-1].button_handler(button_code, value)
return True
def _route_surface_dpad(self, code: int, value: int, current_time: float) -> bool:
if not self._input_surfaces:
return False
self._input_surfaces[-1].dpad_handler(code, value, current_time)
return True
# PROTON MANAGER SUPPORT
def enable_proton_manager_mode(self, proton_manager_dialog):
"""Setup gamepad handling for ProtonManagerDialog"""
try:
self._setup_mode_handlers(
proton_manager_dialog,
self.handle_proton_manager_button,
self.handle_proton_manager_dpad,
'proton_manager_dialog'
)
logger.debug("Gamepad handling successfully connected for ProtonManager")
except Exception as e:
logger.error(f"Error connecting gamepad handlers for ProtonManager: {e}")
def disable_proton_manager_mode(self):
"""Restore original main window handlers"""
try:
if self.proton_manager_dialog:
self._restore_original_handlers('proton_manager_dialog')
logger.debug("Gamepad handling successfully restored from ProtonManager")
except Exception as e:
logger.error(f"Error restoring gamepad handlers from ProtonManager: {e}")
def handle_proton_manager_button(self, button_code, value):
if self.proton_manager_dialog is None or value == 0:
return
try:
# Handle common UI elements like QMessageBox, QMenu, etc.
if self._handle_common_ui_elements(button_code):
return
# ProtonManager-specific button handling
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_widget = focused.cellWidget(current_row, 0)
if checkbox_widget:
checkbox = checkbox_widget.findChild(QCheckBox)
if checkbox and checkbox.isEnabled():
checkbox.setChecked(not checkbox.isChecked())
return
elif button_code in BUTTONS['add_game']: # X: Download
self.proton_manager_dialog.download_selected()
elif button_code in BUTTONS['prev_dir']: # Y: Clear
self.proton_manager_dialog.clear_selection()
elif button_code in BUTTONS['back']: # B: Cancel/Close
# Cancel any active downloads/extractions before closing
if (self.proton_manager_dialog.current_extraction_thread and
self.proton_manager_dialog.current_extraction_thread.isRunning()) or \
(self.proton_manager_dialog.current_download_thread and
hasattr(self.proton_manager_dialog.current_download_thread, 'isRunning') and
self.proton_manager_dialog.current_download_thread.isRunning()):
# If there's an active download/extraction, cancel it
self.proton_manager_dialog.cancel_current_download()
else:
# If no active processes, just close the dialog
self.proton_manager_dialog.reject()
elif button_code in BUTTONS['prev_tab']: # LB: Previous tab
current_index = self.proton_manager_dialog.tab_widget.currentIndex()
new_index = max(0, current_index - 1)
self.proton_manager_dialog.tab_widget.setCurrentIndex(new_index)
if new_index != current_index:
SoundManager().play("tab_switch")
self._focus_first_row_in_current_proton_manager_table()
elif button_code in BUTTONS['next_tab']: # RB: Next tab
current_index = self.proton_manager_dialog.tab_widget.currentIndex()
new_index = min(self.proton_manager_dialog.tab_widget.count() - 1, current_index + 1)
self.proton_manager_dialog.tab_widget.setCurrentIndex(new_index)
if new_index != current_index:
SoundManager().play("tab_switch")
self._focus_first_row_in_current_proton_manager_table()
else:
self._parent.activateFocusedWidget()
except Exception as e:
logger.error(f"Error in handle_proton_manager_button: {e}")
def handle_proton_manager_dpad(self, code, value, now):
if self.proton_manager_dialog is None:
return
try:
if value == 0: # Release
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
return
# Timer setup
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)
self.dpad_timer.start()
self.current_dpad_code = code
self.current_dpad_value = value
table = self._get_current_proton_manager_table()
if not table or table.rowCount() == 0:
return
current_row = table.currentRow()
if code == PAD_DPAD_Y: # Up/Down
step = -1 if value < 0 else 1
new_row = current_row + step
# Skip hidden rows
while 0 <= new_row < table.rowCount() and table.isRowHidden(new_row):
new_row += step
# Bounds check
if new_row < 0:
new_row = current_row
if new_row >= table.rowCount():
new_row = current_row
if new_row != current_row:
table.setCurrentCell(new_row, 0)
table.setFocus(Qt.FocusReason.OtherFocusReason)
except Exception as e:
logger.error(f"Error in handle_proton_manager_dpad: {e}")
def _get_current_proton_manager_table(self):
if self.proton_manager_dialog:
current_container = self.proton_manager_dialog.tab_widget.currentWidget()
if current_container:
table = current_container.findChild(QTableWidget)
return table
return None
def _focus_first_row_in_current_proton_manager_table(self):
table = self._get_current_proton_manager_table()
if table and table.rowCount() > 0:
table.setCurrentCell(0, 0)
table.setFocus(Qt.FocusReason.OtherFocusReason)
def enable_appimage_update_mode(self, appimage_update_dialog):
"""Setup gamepad handling for AppImageUpdateDialog"""
try:
self._setup_mode_handlers(
appimage_update_dialog,
self.handle_appimage_update_button,
self.handle_appimage_update_dpad,
'appimage_update_dialog'
)
logger.debug("Gamepad handling successfully connected for AppImageUpdateDialog")
except Exception as e:
logger.error(f"Error connecting gamepad handlers for AppImageUpdateDialog: {e}")
def disable_appimage_update_mode(self):
"""Restore original main window handlers"""
try:
if self.appimage_update_dialog:
self._restore_original_handlers('appimage_update_dialog')
logger.debug("Gamepad handling successfully restored from AppImageUpdateDialog")
except Exception as e:
logger.error(f"Error restoring gamepad handlers from AppImageUpdateDialog: {e}")
def handle_appimage_update_button(self, button_code, value):
if self.appimage_update_dialog is None or value == 0:
return
try:
if self._handle_common_ui_elements(button_code):
return
focused = QApplication.focusWidget()
if button_code in BUTTONS['confirm']:
if isinstance(focused, AutoSizeButton):
focused.click()
return
elif button_code in BUTTONS['back']:
self.appimage_update_dialog.reject()
return
self._parent.activateFocusedWidget()
except Exception as e:
logger.error(f"Error in handle_appimage_update_button: {e}")
def handle_appimage_update_dpad(self, code, value, now):
if self.appimage_update_dialog is None:
return
try:
if value == 0:
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
return
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)
self.dpad_timer.start()
self.current_dpad_code = code
self.current_dpad_value = value
changelog = self.appimage_update_dialog.changelog_text
buttons = self.appimage_update_dialog.action_buttons
if changelog and changelog.hasFocus():
if code == PAD_DPAD_Y and value != 0:
scrollbar = changelog.verticalScrollBar()
at_bottom = scrollbar.value() >= scrollbar.maximum()
at_top = scrollbar.value() <= scrollbar.minimum()
if value > 0 and at_bottom:
if buttons:
buttons[0].setFocus(Qt.FocusReason.OtherFocusReason)
return
if value < 0 and at_top:
return
step = 40
scrollbar.setValue(scrollbar.value() + (step if value > 0 else -step))
return
if code == PAD_DPAD_Y and value != 0 and buttons:
focused = QApplication.focusWidget()
if focused in buttons:
if value < 0 and focused == buttons[0]:
if changelog:
changelog.setFocus(Qt.FocusReason.OtherFocusReason)
return
if code == PAD_DPAD_X and value != 0 and buttons:
focused = QApplication.focusWidget()
if focused in buttons:
idx = buttons.index(focused)
if value < 0:
new_idx = max(0, idx - 1)
else:
new_idx = min(len(buttons) - 1, idx + 1)
buttons[new_idx].setFocus(Qt.FocusReason.OtherFocusReason)
else:
buttons[0].setFocus(Qt.FocusReason.OtherFocusReason)
except Exception as e:
logger.error(f"Error in handle_appimage_update_dpad: {e}")
+527
View File
@@ -0,0 +1,527 @@
import time
from PySide6.QtCore import Qt, Slot
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QDialog,
QListView,
QMenu,
QMessageBox,
QScrollArea,
QTableWidget,
QWidget,
)
from portprotonqt.custom_widgets import AutoSizeButton, NavLabel
from portprotonqt.dialogs import AddGameDialog
from portprotonqt.game_card import GameCard
from portprotonqt.image_utils import FullscreenDialog
from portprotonqt.input_manager.constants import (
PAD_AXIS_LEFT_X,
PAD_AXIS_LEFT_Y,
PAD_DPAD_X,
PAD_DPAD_Y,
)
from portprotonqt.input_manager.mixin import InputMixin
from portprotonqt.logger import get_logger
from portprotonqt.sound_manager import SoundManager
from portprotonqt.virtual_keyboard import VirtualKeyboard
logger = get_logger(__name__)
class DpadInputMixin(InputMixin):
def handle_dpad_repeat(self) -> None:
"""Handle repeated D-pad input while the D-pad is held."""
if self.current_dpad_code is not None and self.current_dpad_value != 0:
now = time.time()
if (now - self.last_move_time) >= self.current_axis_delay:
self.handle_dpad_slot(self.current_dpad_code, self.current_dpad_value, now)
self.last_move_time = now
self.current_axis_delay = self.repeat_axis_move_delay
def _get_theme_tab_focusables(self) -> list[QWidget]:
"""Return focusable widgets for the themes tab gamepad navigation."""
if self._is_theme_store_visible():
return self._get_theme_store_focusables()
widgets = []
for attr_name in ("themesCombo", "themeVariantCombo", "screenshotsCarousel", "applyButton"):
widget = getattr(self._parent, attr_name, None)
if self._is_visible_enabled_widget(widget):
widgets.append(widget)
return widgets
def _is_visible_enabled_widget(self, widget: object) -> bool:
return isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled()
def _is_theme_store_visible(self) -> bool:
content_stack = getattr(self._parent, "themeContentStack", None)
store_page = getattr(self._parent, "themeStorePage", None)
return content_stack is not None and content_stack.currentWidget() == store_page
def _get_theme_store_focusables(self) -> list[QWidget]:
store_stack = getattr(self._parent, "themeStoreStack", None)
detail_page = getattr(self._parent, "themeStoreDetailPage", None)
if store_stack is not None and store_stack.currentWidget() == detail_page:
return self._get_theme_store_detail_focusables()
widgets = []
for attr_name in ("themesCombo", "themeStoreSortCombo"):
widget = getattr(self._parent, attr_name, None)
if self._is_visible_enabled_widget(widget):
widgets.append(widget)
widgets.extend(self._get_theme_store_cards())
return widgets
def _get_theme_store_detail_focusables(self) -> list[QWidget]:
widgets = []
for attr_name in (
"themeStoreBackButton",
"themeStoreDownloadButton",
"themeStoreCarousel",
"themeStoreDarkButton",
"themeStoreLightButton",
):
widget = getattr(self._parent, attr_name, None)
if self._is_visible_enabled_widget(widget):
widgets.append(widget)
return widgets
def _get_theme_store_cards(self) -> list[QWidget]:
grid_widget = getattr(self._parent, "themeStoreGridWidget", None)
if not isinstance(grid_widget, QWidget):
return []
cards = grid_widget.findChildren(QWidget, "themeStoreCard")
return [card for card in cards if card.isVisible() and card.isEnabled()]
def _handle_theme_tab_navigation(self, code: int, value: int) -> bool:
"""Handle D-pad focus movement in themes tab."""
theme_tab_index = getattr(self._parent, "theme_tab_index", None)
if theme_tab_index is None or self._parent.stackedWidget.currentIndex() != theme_tab_index:
return False
if self._is_theme_store_visible() and self._handle_theme_store_grid_navigation(code, value):
return True
focused = QApplication.focusWidget()
if code == PAD_DPAD_X and focused in self._get_theme_carousels():
self._scroll_theme_carousel(focused, value)
return True
focusables = self._get_theme_tab_focusables()
if not focusables:
return False
focused = QApplication.focusWidget()
if focused not in focusables:
focusables[0].setFocus(Qt.FocusReason.OtherFocusReason)
return True
current_index = focusables.index(focused)
if code == PAD_DPAD_Y and value > 0 or code == PAD_DPAD_X and value > 0:
next_index = (current_index + 1) % len(focusables)
else:
next_index = (current_index - 1) % len(focusables)
focusables[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
self._ensure_theme_store_widget_visible(focusables[next_index])
return True
def _handle_theme_store_grid_navigation(self, code: int, value: int) -> bool:
cards = self._get_theme_store_cards()
return self._navigate_card_grid(cards, code, value)
def _ensure_theme_store_widget_visible(self, widget: QWidget) -> None:
scroll_area = getattr(self._parent, "themeStoreScrollArea", None)
if isinstance(scroll_area, QScrollArea):
scroll_area.ensureWidgetVisible(widget)
def _get_theme_carousels(self) -> list[QWidget]:
widgets = []
for attr_name in ("screenshotsCarousel", "themeStoreCarousel"):
widget = getattr(self._parent, attr_name, None)
if self._is_visible_enabled_widget(widget):
widgets.append(widget)
return widgets
def _scroll_theme_carousel(self, carousel: QWidget, value: int) -> None:
if value > 0:
scroll_right = getattr(carousel, "scroll_right", None)
if callable(scroll_right):
scroll_right()
elif value < 0:
scroll_left = getattr(carousel, "scroll_left", None)
if callable(scroll_left):
scroll_left()
def _focus_first_dialog_widget(self, dialog: QDialog) -> bool:
focusables = dialog.findChildren(
QWidget,
options=Qt.FindChildOption.FindChildrenRecursively,
)
focusables = [widget for widget in focusables if widget.focusPolicy() & Qt.FocusPolicy.StrongFocus]
if not focusables:
return False
focusables[0].setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _handle_add_game_checkbox_dpad(self, dialog: AddGameDialog, code: int, value: int) -> bool:
if code not in (PAD_DPAD_X, PAD_AXIS_LEFT_X):
return False
if code == PAD_AXIS_LEFT_X:
if abs(value) < self.dead_zone:
return True
value = 1 if value > self.dead_zone else -1
checkboxes = [
dialog.add_to_steam_checkbox,
dialog.add_to_menu_checkbox,
dialog.add_to_desktop_checkbox,
]
focused = QApplication.focusWidget()
if not isinstance(focused, QCheckBox) or focused not in checkboxes:
return False
current_index = checkboxes.index(focused)
next_index = current_index + (1 if value > 0 else -1)
if not 0 <= next_index < len(checkboxes):
return False
checkboxes[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _handle_dialog_dpad(self, active: QWidget, code: int, value: int) -> bool:
focused = QApplication.focusWidget()
if isinstance(active, QMessageBox) and not isinstance(focused, QTableWidget):
if not focused or not active.focusWidget():
self._focus_first_dialog_widget(active)
elif code in (PAD_DPAD_X, PAD_DPAD_Y):
active.focusNextChild() if value > 0 else active.focusPreviousChild()
return True
if isinstance(active, AddGameDialog) and code in (PAD_DPAD_X, PAD_AXIS_LEFT_X):
return self._handle_add_game_checkbox_dpad(active, code, value)
if not isinstance(active, QDialog):
return False
if code not in (PAD_DPAD_X, PAD_DPAD_Y):
return False
if code == PAD_DPAD_Y and isinstance(focused, QTableWidget):
return False
if not focused or not active.focusWidget():
self._focus_first_dialog_widget(active)
return True
active.focusNextChild() if value > 0 else active.focusPreviousChild()
return True
def _handle_popup_dpad(self, popup: QWidget | None, code: int, value: int) -> bool:
if not isinstance(popup, QMenu):
return False
if code == PAD_DPAD_Y:
self._navigate_menu_actions(popup, direction_down=value > 0)
return True
def _handle_list_dpad(self, focused: QWidget | None, code: int, value: int) -> bool:
if not isinstance(focused, QListView) or code != PAD_DPAD_Y:
return False
model = focused.model()
current_index = focused.currentIndex()
if not model or not current_index.isValid():
return True
next_row = current_index.row() + (1 if value > 0 else -1)
next_row = max(0, min(next_row, model.rowCount() - 1))
focused.setCurrentIndex(model.index(next_row, current_index.column()))
focused.scrollTo(focused.currentIndex(), QListView.ScrollHint.PositionAtCenter)
return True
def _handle_fullscreen_dpad(self, active: QWidget, code: int, value: int) -> bool:
if not isinstance(active, FullscreenDialog) or code != PAD_DPAD_X:
return False
if value < 0:
active.show_prev()
else:
active.show_next()
return True
def _handle_system_section_horizontal(self, code: int, value: int, initial_press: bool) -> bool:
if code != PAD_DPAD_X or not initial_press:
return False
if self._parent.stackedWidget.currentIndex() != getattr(self._parent, "system_tab_index", -1):
return False
switch_relative = getattr(self._parent, "switchSystemSectionRelative", None)
if not callable(switch_relative) or not switch_relative(1 if value > 0 else -1):
return False
section_stack = getattr(self._parent, "systemSectionStack", None)
section_buttons = getattr(self._parent, "systemSectionButtons", [])
if section_stack is None or not section_buttons:
return True
current_index = section_stack.currentIndex()
if 0 <= current_index < len(section_buttons):
section_buttons[current_index].setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _focus_toolbar_from_first_card(self, code: int, value: int) -> bool:
focused = QApplication.focusWidget()
current_index = self._parent.stackedWidget.currentIndex()
if code != PAD_DPAD_Y or value >= 0 or current_index not in (0, 1):
return False
if not isinstance(focused, GameCard):
return False
if current_index == 0:
container = self._parent.gamesListWidget
toolbar_widgets = self._get_library_toolbar_widgets()
focus_target = toolbar_widgets[0] if toolbar_widgets else None
else:
container = self._parent.autoInstallContainer
focus_target = getattr(self._parent, 'autoInstallSearchLineEdit', None)
if container is None or focus_target is None:
return False
cards: list[QWidget] = [
card for card in container.findChildren(GameCard)
if card.isVisible() and card.isEnabled()
]
if not cards or focused not in self._get_card_grid_rows(cards)[0]:
return False
focus_target.setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _handle_system_section_vertical(self, code: int, value: int) -> bool:
if code != PAD_DPAD_Y:
return False
if self._parent.stackedWidget.currentIndex() != getattr(self._parent, "system_tab_index", -1):
return False
focused = QApplication.focusWidget()
section_buttons = getattr(self._parent, "systemSectionButtons", [])
if focused not in section_buttons:
return False
if value < 0:
return True
section_stack = getattr(self._parent, "systemSectionStack", None)
focus_targets = getattr(self._parent, "systemSectionFocusTargets", [])
if section_stack is None:
return True
current_index = section_stack.currentIndex()
if 0 <= current_index < len(focus_targets):
target = focus_targets[current_index]
if target is not None and target.isVisible() and target.isEnabled():
target.setFocus(Qt.FocusReason.OtherFocusReason)
return True
@Slot(int, int, float)
def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None:
if self._route_surface_dpad(code, value, current_time):
return
self._handle_default_dpad(code, value, current_time)
def _handle_default_dpad(self, code: int, value: int, current_time: float) -> None:
active_window = QApplication.activeWindow()
if isinstance(active_window, AddGameDialog):
keyboard = getattr(active_window, 'keyboard', None)
else:
keyboard = getattr(self._parent, 'keyboard', None)
if value == 0:
self._reset_dpad_repeat()
return
is_initial_press = not self.axis_moving
self._start_dpad_repeat(code, value, current_time)
if keyboard and keyboard.isVisible() and self._handle_keyboard_dpad(keyboard, code, value):
return
if not self._gamepad_handling_enabled:
return
if not hasattr(self._parent, 'gamesListWidget') or self._parent.gamesListWidget is None:
logger.error("gamesListWidget not available yet, skipping D-pad navigation")
return
try:
self._route_dpad_navigation(code, value, is_initial_press)
except Exception as e:
logger.error(f"Error in handle_dpad_slot: {e}", exc_info=True)
def _route_dpad_navigation(self, code: int, value: int, is_initial_press: bool) -> None:
app = QApplication.instance()
active = QApplication.activeWindow()
focused = QApplication.focusWidget()
popup = QApplication.activePopupWidget()
if not app or not active:
return
if not isinstance(focused, GameCard):
SoundManager().play("navigate")
if self._handle_dialog_dpad(active, code, value):
return
if self._handle_popup_dpad(popup, code, value):
return
if self._handle_list_dpad(focused, code, value):
return
if self._handle_fullscreen_dpad(active, code, value):
return
if self._handle_system_section_horizontal(code, value, is_initial_press):
return
if isinstance(focused, QTableWidget):
self.handle_table_navigation(focused, code, value)
return
if self._focus_toolbar_from_first_card(code, value):
return
if self._handle_library_dpad(code, value):
return
if code in (PAD_DPAD_X, PAD_DPAD_Y) and self._handle_theme_tab_navigation(code, value):
return
if self._handle_system_section_vertical(code, value):
return
if self._handle_detail_page_dpad(code, value):
return
self._handle_page_vertical_dpad(code, value)
def _handle_detail_page_dpad(self, code: int, value: int) -> bool:
direction = self._normalize_dpad_direction(code, value)
if direction is None:
return False
focused = QApplication.focusWidget()
page = self._parent.stackedWidget.currentWidget()
current_detail_page = getattr(self._parent, "currentDetailPage", None)
if not isinstance(focused, AutoSizeButton) or page != current_detail_page:
return False
parent = focused.parentWidget()
if parent is None:
return False
rows = self._get_detail_button_rows(parent)
if len(rows) == 1 and len(rows[0]) <= 1:
return False
target = self._get_detail_button_target(rows, focused, direction)
if target is None:
return False
self._focus_detail_button(target)
return True
def _handle_library_dpad(self, code: int, value: int) -> bool:
if code not in (PAD_DPAD_X, PAD_DPAD_Y):
return False
current_index = self._parent.stackedWidget.currentIndex()
if current_index not in (0, 1):
return False
if self._handle_toolbar_navigation(code, value):
return True
if code == PAD_DPAD_Y and value < 0 and self._focus_tab_from_search(current_index):
return True
container = (
self._parent.gamesListWidget
if current_index == 0
else self._parent.autoInstallContainer
)
if container is not None:
self._navigate_game_cards(container, current_index, code, value)
return True
def _handle_page_vertical_dpad(self, code: int, value: int) -> bool:
if code != PAD_DPAD_Y or value == 0:
return False
focused = QApplication.focusWidget()
if focused is None:
return False
if value < 0:
focused.focusPreviousChild()
return True
if not isinstance(focused, NavLabel):
focused.focusNextChild()
return True
page = self._parent.stackedWidget.currentWidget()
focusables = page.findChildren(
QWidget,
options=Qt.FindChildOption.FindChildrenRecursively,
)
focusables = [
widget for widget in focusables
if widget.focusPolicy() & Qt.FocusPolicy.StrongFocus
]
if not focusables:
return False
focusables[0].setFocus()
return True
def _normalize_dpad_direction(self, code: int, value: int) -> tuple[int, int] | None:
if code in (PAD_DPAD_X, PAD_DPAD_Y):
return (code, value) if value != 0 else None
if code not in (PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y) or abs(value) < self.dead_zone:
return None
normalized_code = PAD_DPAD_X if code == PAD_AXIS_LEFT_X else PAD_DPAD_Y
return normalized_code, 1 if value > self.dead_zone else -1
def _get_detail_button_rows(self, parent: QWidget) -> list[list[AutoSizeButton]]:
buttons = parent.findChildren(
AutoSizeButton,
options=Qt.FindChildOption.FindDirectChildrenOnly,
)
buttons = [button for button in buttons if button.isVisible() and button.isEnabled()]
centers = {button: button.geometry().center() for button in buttons}
rows: list[list[AutoSizeButton]] = []
for button in sorted(buttons, key=lambda item: (centers[item].y(), centers[item].x())):
if not rows or abs(centers[button].y() - centers[rows[-1][0]].y()) > 24:
rows.append([button])
else:
rows[-1].append(button)
for row in rows:
row.sort(key=lambda item: centers[item].x())
return rows
def _get_detail_button_target(
self, rows: list[list[AutoSizeButton]], focused: AutoSizeButton,
direction: tuple[int, int],
) -> AutoSizeButton | None:
row_index = next((index for index, row in enumerate(rows) if focused in row), -1)
if row_index < 0:
return None
code, value = direction
if code == PAD_DPAD_X:
flat = [button for row in rows for button in row]
index = flat.index(focused) + value
return flat[index] if 0 <= index < len(flat) else None
target_row_index = row_index + value
if not 0 <= target_row_index < len(rows):
return None
focused_x = focused.geometry().center().x()
return min(
rows[target_row_index],
key=lambda button: abs(button.geometry().center().x() - focused_x),
)
def _focus_detail_button(self, target: AutoSizeButton) -> None:
target.setFocus(Qt.FocusReason.OtherFocusReason)
parent = target.parentWidget()
while parent and not isinstance(parent, QScrollArea):
parent = parent.parentWidget()
if isinstance(parent, QScrollArea):
parent.ensureWidgetVisible(target, 20, 20)
def _reset_dpad_repeat(self) -> None:
self.current_dpad_code = None
self.current_dpad_value = 0
self.axis_moving = False
self.current_axis_delay = self.initial_axis_move_delay
self.dpad_timer.stop()
def _start_dpad_repeat(self, code: int, value: int, current_time: float) -> None:
self.current_dpad_code = code
self.current_dpad_value = value
if self.axis_moving:
return
self.axis_moving = True
self.last_move_time = current_time
self.current_axis_delay = self.initial_axis_move_delay
self.dpad_timer.start(int(self.repeat_axis_move_delay * 1000))
def _handle_keyboard_dpad(self, keyboard: VirtualKeyboard, code: int, value: int) -> bool:
directions = {
PAD_DPAD_X: (keyboard.move_focus_left, keyboard.move_focus_right),
PAD_AXIS_LEFT_X: (keyboard.move_focus_left, keyboard.move_focus_right),
PAD_DPAD_Y: (keyboard.move_focus_up, keyboard.move_focus_down),
PAD_AXIS_LEFT_Y: (keyboard.move_focus_up, keyboard.move_focus_down),
}
actions = directions.get(code)
if actions is None:
return False
if code in (PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y):
if abs(value) < self.dead_zone:
self._reset_dpad_repeat()
return True
value = 1 if value > self.dead_zone else -1
actions[1 if value > 0 else 0]()
return True
+317
View File
@@ -0,0 +1,317 @@
import os
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QApplication,
QDialog,
QLineEdit,
QMenu,
QPushButton,
QTableWidget,
QWidget,
)
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.input_manager.constants import (
BUTTONS,
PAD_AXIS_LEFT_X,
PAD_AXIS_LEFT_Y,
PAD_DPAD_X,
PAD_DPAD_Y,
)
from portprotonqt.logger import get_logger
from portprotonqt.input_manager.mixin import InputMixin
from portprotonqt.virtual_keyboard import VirtualKeyboard
logger = get_logger(__name__)
class FileExplorerInputMixin(InputMixin):
# FILE EXPLORER MODE
def enable_file_explorer_mode(self, file_explorer):
"""Configure gamepad handling for FileExplorer"""
try:
self._setup_mode_handlers(
file_explorer,
self.handle_file_explorer_button,
self.handle_file_explorer_dpad,
'file_explorer'
)
logger.debug("Gamepad handling successfully connected for FileExplorer")
except Exception as e:
logger.error(f"Error connecting gamepad handlers for FileExplorer: {e}")
def disable_file_explorer_mode(self):
"""Restore original handlers (return default)"""
try:
if self.file_explorer:
# Additional cleanup for file explorer
self.nav_timer.stop()
self._restore_original_handlers('file_explorer')
logger.debug("Gamepad handling successfully restored")
except Exception as e:
logger.error(f"Error restoring gamepad handlers: {e}")
def handle_file_explorer_button(self, button_code, value):
active_window = QApplication.activeWindow()
dialog_keyboard = getattr(active_window, 'keyboard', None) if active_window else None
if isinstance(dialog_keyboard, VirtualKeyboard) and dialog_keyboard.isVisible():
self.handle_virtual_keyboard(button_code, value)
return
keyboard = getattr(self._parent, 'keyboard', None)
if keyboard and keyboard.isVisible():
self.handle_virtual_keyboard(button_code, value)
return
if value == 0: # Ignore releases
return
try:
# 1. Handle Popups (Menus)
popup = QApplication.activePopupWidget()
if isinstance(popup, QMenu):
if button_code in BUTTONS['confirm']: # A button
if popup.activeAction():
popup.activeAction().trigger()
popup.close()
return
elif button_code in BUTTONS['back']: # B button
popup.close()
return
return # Skip other handling if menu is open
active_window = QApplication.activeWindow()
if isinstance(active_window, QDialog) and active_window != self.file_explorer:
if button_code in BUTTONS['confirm']: # A button
focused_widget = QApplication.focusWidget()
if isinstance(focused_widget, QLineEdit):
keyboard = getattr(active_window, 'keyboard', None)
if not keyboard:
keyboard = getattr(self._parent, 'keyboard', None)
if keyboard:
focused_widget.setFocus()
keyboard.show_for_widget(focused_widget)
elif isinstance(focused_widget, QPushButton):
focused_widget.click()
else:
active_window.accept()
elif button_code in BUTTONS['back']: # B button
active_window.reject()
return
# 2. Validate File Explorer state
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list'):
logger.debug("No file explorer or file_list available")
return
focused_widget = QApplication.focusWidget()
# 3. Handle Buttons
if button_code in BUTTONS['confirm']: # A button
# Check if a drive button is focused
if isinstance(focused_widget, AutoSizeButton) and \
hasattr(self.file_explorer, 'drive_buttons') and \
focused_widget in self.file_explorer.drive_buttons:
self.file_explorer.select_drive()
return
if self.file_explorer.file_list.count() == 0:
return
selected = self.file_explorer.file_list.currentItem().text()
full_path = os.path.join(self.file_explorer.current_path, selected)
if os.path.isdir(full_path):
self.file_explorer.current_path = os.path.normpath(full_path)
self.file_explorer.update_file_list()
elif not self.file_explorer.directory_only:
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
self.file_explorer.accept()
else:
logger.debug(f"Selected item is not a directory: {full_path}")
elif button_code in BUTTONS['context_menu']: # Start button
if self.file_explorer.file_list.count() == 0:
return
current_item = self.file_explorer.file_list.currentItem()
if current_item:
item_rect = self.file_explorer.file_list.visualItemRect(current_item)
self.file_explorer.show_folder_context_menu(item_rect.center())
elif button_code in BUTTONS['add_game']: # X button
if self.file_explorer.file_list.count() == 0:
return
selected = self.file_explorer.file_list.currentItem().text()
full_path = os.path.join(self.file_explorer.current_path, selected)
if os.path.isdir(full_path):
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
self.file_explorer.accept()
elif button_code in BUTTONS['back']: # B button
self.file_explorer.close()
elif button_code in BUTTONS['prev_dir']: # Y button
self.file_explorer.previous_dir()
else:
self._handle_default_button(button_code, value)
except Exception as e:
logger.error(f"Error in FileExplorer button handler: {e}")
def handle_file_explorer_dpad(self, code, value, current_time):
try:
active_window = QApplication.activeWindow()
keyboard = getattr(active_window, 'keyboard', None) if active_window else None
if keyboard is None:
keyboard = getattr(self._parent, 'keyboard', None)
if keyboard and keyboard.isVisible():
if code in (PAD_DPAD_X, PAD_AXIS_LEFT_X):
normalized_value = value
if code == PAD_AXIS_LEFT_X:
if abs(value) < self.dead_zone:
return
normalized_value = 1 if value > self.dead_zone else -1
if normalized_value > 0:
keyboard.move_focus_right()
elif normalized_value < 0:
keyboard.move_focus_left()
elif code in (PAD_DPAD_Y, PAD_AXIS_LEFT_Y):
normalized_value = value
if code == PAD_AXIS_LEFT_Y:
if abs(value) < self.dead_zone:
return
normalized_value = 1 if value > self.dead_zone else -1
if normalized_value > 0:
keyboard.move_focus_down()
elif normalized_value < 0:
keyboard.move_focus_up()
return
# 1. Handle Popups (Menus)
popup = QApplication.activePopupWidget()
if isinstance(popup, QMenu):
if code == PAD_DPAD_Y and value != 0:
self._navigate_menu_actions(popup, direction_down=value > 0)
return
focused_widget = QApplication.focusWidget()
if isinstance(active_window, QDialog) and active_window != self.file_explorer:
if not focused_widget or not active_window.focusWidget():
focusables = active_window.findChildren(
QWidget,
options=Qt.FindChildOption.FindChildrenRecursively
)
focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
if focusables:
focusables[0].setFocus(Qt.FocusReason.OtherFocusReason)
return
if code == PAD_DPAD_X and value != 0:
if value > 0:
active_window.focusNextChild()
elif value < 0:
active_window.focusPreviousChild()
return
if code == PAD_DPAD_Y and value != 0 and not isinstance(focused_widget, QTableWidget):
if value > 0:
active_window.focusNextChild()
elif value < 0:
active_window.focusPreviousChild()
return
# 2. Validate State
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list') or not self.file_explorer.file_list:
return
focused_widget = QApplication.focusWidget()
# 3. Handle Drive Buttons Navigation (Horizontal)
if code in (PAD_DPAD_X, PAD_AXIS_LEFT_X) and \
hasattr(self.file_explorer, 'drive_buttons') and \
self.file_explorer.drive_buttons:
if code == PAD_DPAD_X and value == 0:
return
is_drive_button = (
isinstance(focused_widget, AutoSizeButton) and
focused_widget in self.file_explorer.drive_buttons
)
if code == PAD_AXIS_LEFT_X:
if abs(value) < self.dead_zone or not is_drive_button:
return
value = 1 if value > self.dead_zone else -1
if not is_drive_button:
# Focus first drive button if not currently on one
self.file_explorer.drive_buttons[0].setFocus()
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[0])
return
current_idx = self.file_explorer.drive_buttons.index(focused_widget)
next_idx = current_idx
if value < 0: # Left
next_idx = max(current_idx - 1, 0)
elif value > 0: # Right
next_idx = min(current_idx + 1, len(self.file_explorer.drive_buttons) - 1)
if next_idx != current_idx:
self.file_explorer.drive_buttons[next_idx].setFocus()
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
return
# 4. Handle Vertical Navigation (File List vs Drive Buttons)
elif code in (PAD_DPAD_Y, PAD_AXIS_LEFT_Y):
# Move from buttons to list
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.file_explorer.drive_buttons:
if code == PAD_DPAD_Y and value > 0 and self.file_explorer.file_list.count() > 0:
self.file_explorer.file_list.setFocus()
self.file_explorer.file_list.setCurrentRow(0)
self.file_explorer.file_list.scrollToItem(self.file_explorer.file_list.currentItem())
return
# D-pad: Fixed speed
if code == PAD_DPAD_Y:
if value != 0:
self.current_direction = value
self.stick_value = 1.0
if not self.nav_timer.isActive():
self.file_explorer.move_selection(self.current_direction)
self.last_nav_time = current_time
self.nav_timer.start(int(self.initial_nav_delay * 1000))
else:
self.current_direction = 0
self.nav_timer.stop()
# Stick: Analog speed
elif code == PAD_AXIS_LEFT_Y:
if abs(value) < self.dead_zone:
if self.stick_activated:
self.current_direction = 0
self.nav_timer.stop()
self.stick_activated = False
return
normalized_value = (abs(value) - self.dead_zone) / (32768 - self.dead_zone)
speed_factor = 0.3 + (normalized_value * 0.7)
self.current_direction = -1 if value < 0 else 1
self.stick_value = speed_factor
self.stick_activated = True
if not self.nav_timer.isActive():
self.file_explorer.move_selection(self.current_direction)
self.last_nav_time = current_time
self.nav_timer.start(int(self.initial_nav_delay * 1000))
# 5. Fallback
else:
self._handle_default_dpad(code, value, current_time)
except Exception as e:
logger.error(f"Error in FileExplorer dpad handler: {e}")
+606
View File
@@ -0,0 +1,606 @@
import os
import time
from typing import cast
from shiboken6 import isValid
from PySide6.QtCore import QEvent, QObject, QPoint, Qt
from PySide6.QtGui import QKeySequence
from PySide6.QtWidgets import (
QAbstractScrollArea,
QApplication,
QCheckBox,
QComboBox,
QDialog,
QLineEdit,
QListWidget,
QMenu,
QMessageBox,
QScrollArea,
QTableWidget,
QWidget,
)
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.game_card import GameCard
from portprotonqt.image_utils import FullscreenDialog
from portprotonqt.input_manager.constants import (
BUTTONS,
KEY_BACKSPACE,
KEY_DOWN,
KEY_E,
KEY_ENTER,
KEY_ESCAPE,
KEY_F5,
KEY_F10,
KEY_F11,
KEY_LEFT,
KEY_Q,
KEY_RETURN,
KEY_RIGHT,
KEY_UP,
MOD_CTRL,
MOD_SHIFT,
PAD_DPAD_X,
PAD_DPAD_Y,
)
from portprotonqt.input_manager.mixin import InputMixin
from portprotonqt.logger import get_logger
from portprotonqt.sound_manager import SoundManager
from portprotonqt.virtual_keyboard import VirtualKeyboard
logger = get_logger(__name__)
class KeyboardInputMixin(InputMixin):
def handle_virtual_keyboard(self, button_code: int, value: int) -> None:
active_window = QApplication.activeWindow()
keyboard = getattr(active_window, 'keyboard', None) if active_window else None
if keyboard is None:
keyboard = getattr(self._parent, 'keyboard', None)
if not keyboard or not isinstance(keyboard, VirtualKeyboard) or not keyboard.isVisible():
return
# Handle gamepad buttons
if button_code in BUTTONS['confirm']: # A/Cross button - confirm
if value == 1:
keyboard.activateFocusedKey()
elif button_code in BUTTONS['back']: # B/Circle button - hide keyboard
if value == 1:
keyboard.hide()
# Return focus to input field
if keyboard.current_input_widget:
keyboard.current_input_widget.setFocus()
elif button_code in BUTTONS['prev_tab']: # LB/L1 - switch layout
if value == 1:
keyboard.on_lang_click()
elif button_code in BUTTONS['next_tab']: # RB/R1 - toggle Shift
if value == 1:
keyboard.on_shift_click(not keyboard.shift_pressed)
elif button_code in BUTTONS['context_menu']: # Start button - confirm
if value == 1:
keyboard.activateFocusedKey()
elif button_code in BUTTONS['menu']: # Select button - hide keyboard
if value == 1:
keyboard.hide()
# Return focus to input field
if keyboard.current_input_widget:
keyboard.current_input_widget.setFocus()
elif button_code in BUTTONS['add_game']: # X button - Backspace (now holdable)
if value == 1:
keyboard.on_backspace_pressed()
elif value == 0:
keyboard.stop_backspace_repeat()
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
app = QApplication.instance()
if not app:
return QObject.eventFilter(cast(QObject, self), obj, event)
if event.type() == QEvent.Type.Show:
if isinstance(obj, QMenu):
SoundManager().play("open")
elif isinstance(obj, QWidget) and obj.windowType() == Qt.WindowType.Popup:
parent = obj.parent()
while isinstance(parent, QObject):
if isinstance(parent, QComboBox):
if not parent.property("_sound_highlight_connected"):
parent.highlighted.connect(
lambda _index: SoundManager().play("toggle"),
)
parent.setProperty("_sound_highlight_connected", True)
break
parent = parent.parent()
if event.type() == QEvent.Type.Wheel:
combo = obj if isinstance(obj, QComboBox) else None
parent = obj.parent() if isinstance(obj, QObject) else None
while combo is None and isinstance(parent, QObject):
if isinstance(parent, QComboBox):
combo = parent
break
parent = parent.parent()
if isinstance(combo, QComboBox) and not combo.view().isVisible():
# Find parent scroll area
scrollable = combo.parent()
while scrollable:
if isinstance(scrollable, QAbstractScrollArea):
old_focus = QApplication.focusWidget()
QApplication.sendEvent(scrollable.viewport(), event)
if old_focus:
old_focus.setFocus()
return True
scrollable = scrollable.parent()
return True
if event.type() == QEvent.Type.KeyPress:
if self._redirect_gamecard_input_to_search(event):
return True
key = self._qt_event_to_input_key(event)
if key is not None:
return self._handle_input_key_press(key, self._qt_modifiers_to_input(event))
if event.type() == QEvent.Type.KeyRelease:
key = self._qt_event_to_input_key(event)
if key is not None:
return self._handle_input_key_release(key)
if event.type() == QEvent.Type.MouseButtonPress:
button_method = getattr(event, "button", None)
button = button_method() if callable(button_method) else None
if button == Qt.MouseButton.ExtraButton1:
self._handle_back_mouse_button()
return True
if event.type() == QEvent.Type.MouseButtonRelease:
button_method = getattr(event, "button", None)
button = button_method() if callable(button_method) else None
if button == Qt.MouseButton.LeftButton:
SoundManager().play_widget_sound(obj)
# Ensure obj is a QObject
if not isinstance(obj, QObject):
logger.debug(f"Skipping event filter for non-QObject: {type(obj).__name__}")
return False
return QObject.eventFilter(cast(QObject, self), obj, event)
def _qt_event_to_input_key(self, event: QEvent) -> int | None:
native_key = self._native_scan_to_input_key(event)
if native_key is not None:
return native_key
text_method = getattr(event, "text", None)
text = text_method() if callable(text_method) else ""
text = text if isinstance(text, str) else ""
normalized_text = text.lower()
if len(normalized_text) == 1 and normalized_text.isprintable():
return ord(normalized_text)
key_method = getattr(event, "key", None)
key_value = key_method() if callable(key_method) else 0
key = key_value if isinstance(key_value, int) else 0
name = QKeySequence(key).toString()
if len(name) == 1 and name.isprintable():
return ord(name.lower())
key_names = {
"Backspace": KEY_BACKSPACE,
"Down": KEY_DOWN,
"Enter": KEY_ENTER,
"Esc": KEY_ESCAPE,
"F5": KEY_F5,
"F10": KEY_F10,
"F11": KEY_F11,
"Left": KEY_LEFT,
"Return": KEY_RETURN,
"Right": KEY_RIGHT,
"Up": KEY_UP,
}
return key_names.get(name)
def _native_scan_to_input_key(self, event: QEvent) -> int | None:
scan_method = getattr(event, "nativeScanCode", None)
scan_value = scan_method() if callable(scan_method) else 0
scan_code = scan_value if isinstance(scan_value, int) else 0
scan_keys = {
16: KEY_Q,
18: KEY_E,
24: KEY_Q,
26: KEY_E,
}
return scan_keys.get(scan_code)
def _qt_modifiers_to_input(self, event: QEvent) -> int:
modifiers_method = getattr(event, "modifiers", None)
modifiers = modifiers_method() if callable(modifiers_method) else None
input_modifiers = 0
if isinstance(modifiers, Qt.KeyboardModifier) and modifiers & Qt.KeyboardModifier.ControlModifier:
input_modifiers |= MOD_CTRL
if isinstance(modifiers, Qt.KeyboardModifier) and modifiers & Qt.KeyboardModifier.ShiftModifier:
input_modifiers |= MOD_SHIFT
return input_modifiers
def _handle_back_mouse_button(self) -> None:
active_win = QApplication.activeWindow()
focused = self._focused_widget()
if isinstance(focused, QLineEdit):
return
if isinstance(active_win, QDialog):
SoundManager().play("back")
active_win.reject()
return
self._parent.goBackDetailPage(self._parent.currentDetailPage)
def _focused_widget(self) -> QWidget | None:
focused = QApplication.focusWidget()
if focused is None or not isValid(focused):
return None
return focused
_GAMECARD_SEARCH_TABS: dict[int, str] = {
0: 'searchEdit',
1: 'autoInstallSearchLineEdit',
}
def _redirect_gamecard_input_to_search(self, event: QEvent) -> bool:
focused = self._focused_widget()
if not isinstance(focused, GameCard):
return False
tab_index = self._parent.stackedWidget.currentIndex()
attr_name = self._GAMECARD_SEARCH_TABS.get(tab_index)
if attr_name is None:
return False
text_method = getattr(event, "text", None)
text = text_method() if callable(text_method) else ""
text = text if isinstance(text, str) else ""
if len(text) != 1 or not text.isprintable():
return False
search_edit = getattr(self._parent, attr_name, None)
if not isinstance(search_edit, QLineEdit) or not isValid(search_edit):
return False
search_edit.setFocus()
search_edit.insert(text)
return True
def _activate_focused_widget(self, focused: QWidget | None) -> None:
if focused is None or not isValid(focused):
return
try:
self._parent.activateFocusedWidget()
except RuntimeError as e:
logger.debug("Focused widget was deleted before activation: %s", e)
def _handle_input_key_press(self, key: int, modifiers: int) -> bool:
if self._handle_input_system_key(key, modifiers):
return True
if self._handle_input_file_explorer_key(key):
return True
if self._handle_input_text_key(key):
return True
if self._handle_input_dialog_key(key):
return True
if self._handle_input_tab_key(key):
return True
if self._handle_input_arrow_press(key):
return True
return self._handle_input_action_key(key, modifiers)
def _handle_input_system_key(self, key: int, modifiers: int) -> bool:
if key == KEY_F5:
self._parent.refreshGames()
return True
if key == KEY_Q and modifiers & MOD_CTRL:
app = QApplication.instance()
if app is not None:
app.quit()
return True
if key == KEY_F11 and not self._is_gamescope_session:
self.toggle_fullscreen.emit(not self._is_fullscreen)
return True
return False
def _handle_input_file_explorer_key(self, key: int) -> bool:
file_explorer = self.file_explorer
if not file_explorer:
return False
focused = self._focused_widget()
if isinstance(focused, QLineEdit) or self._focused_editable_combo(focused):
return False
if key in (KEY_RETURN, KEY_ENTER):
self._activate_file_explorer_focus()
return True
if key == KEY_BACKSPACE:
file_explorer.previous_dir()
return True
return False
def _activate_file_explorer_focus(self) -> None:
file_explorer = self.file_explorer
if file_explorer is None:
return
focused = self._focused_widget()
if (
isinstance(focused, AutoSizeButton) and
hasattr(file_explorer, 'drive_buttons') and
focused in file_explorer.drive_buttons
):
file_explorer.select_drive()
return
if file_explorer.file_list.count() > 0:
self._activate_file_explorer_item(file_explorer.file_list)
return
self._activate_focused_widget(focused)
def _activate_file_explorer_item(self, focused: QListWidget) -> None:
file_explorer = self.file_explorer
if file_explorer is None:
return
current_item = focused.currentItem()
if current_item is None and focused.count() > 0:
focused.setCurrentRow(0)
current_item = focused.currentItem()
if not current_item:
return
selected = current_item.text()
full_path = os.path.join(file_explorer.current_path, selected)
if os.path.isdir(full_path):
if selected == "../":
file_explorer.previous_dir()
else:
file_explorer.current_path = os.path.normpath(full_path)
file_explorer.update_file_list()
elif not file_explorer.directory_only:
file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
file_explorer.accept()
def _handle_input_text_key(self, key: int) -> bool:
focused = self._focused_widget()
if isinstance(focused, QLineEdit) and key in (KEY_LEFT, KEY_RIGHT):
if key == KEY_LEFT:
focused.cursorBackward(False, 1)
else:
focused.cursorForward(False, 1)
return True
return False
def _focused_editable_combo(self, focused: QWidget | None) -> bool:
if isinstance(focused, QComboBox) and focused.isEditable():
return True
parent = focused.parentWidget() if focused else None
return isinstance(parent, QComboBox) and parent.isEditable()
def _handle_input_dialog_key(self, key: int) -> bool:
active_win = QApplication.activeWindow()
focused = self._focused_widget()
if isinstance(active_win, FullscreenDialog):
return self._handle_input_fullscreen_dialog_key(active_win, key)
if key != KEY_ESCAPE:
return False
settings_dialog = self.settings_dialog
if settings_dialog is not None:
open_combo = self._get_open_settings_combo()
if open_combo:
open_combo.hidePopup()
settings_dialog.advanced_table.setFocus()
return True
if isinstance(focused, QLineEdit):
return False
if isinstance(active_win, QDialog):
active_win.reject()
return True
return False
def _handle_input_fullscreen_dialog_key(self, active_win: FullscreenDialog, key: int) -> bool:
if key in (KEY_ESCAPE, KEY_RETURN, KEY_ENTER, KEY_BACKSPACE):
active_win.close()
return True
if key == KEY_LEFT:
active_win.show_prev()
return True
if key == KEY_RIGHT:
active_win.show_next()
return True
return False
def _handle_input_tab_key(self, key: int) -> bool:
if key not in (KEY_LEFT, KEY_RIGHT):
return False
focused = self._focused_widget()
active = QApplication.activeWindow()
if self.file_explorer or isinstance(active, QMessageBox | QDialog):
return False
if isinstance(focused, GameCard | QLineEdit | QTableWidget | AutoSizeButton | QCheckBox):
return False
return self._switch_visible_tab(-1 if key == KEY_LEFT else 1)
def _focus_tab_from_search(self, current_index: int) -> bool:
focused = self._focused_widget()
search_edit = (
getattr(self._parent, 'searchEdit', None)
if current_index == 0
else getattr(self._parent, 'autoInstallSearchLineEdit', None)
)
if focused is not search_edit:
return False
tab_button = self._parent.tabButtons.get(current_index)
if tab_button is None or not tab_button.isVisible():
return False
tab_button.setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _get_library_toolbar_widgets(self) -> list[QWidget]:
widgets = []
for attr_name in (
"quickLaunchButton",
"addGameButton",
"searchEdit",
"refreshButton",
"deleteMissingExeButton",
"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", "gamesBadgeViewCombo"):
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 _focus_first_game_card(self, container: QWidget | None) -> bool:
if container is None:
return False
for card in container.findChildren(GameCard):
if card.isVisible() and card.isEnabled():
card.setFocus(Qt.FocusReason.OtherFocusReason)
scroll_area = container.parentWidget()
while scroll_area and not isinstance(scroll_area, QScrollArea):
scroll_area = scroll_area.parentWidget()
if isinstance(scroll_area, QScrollArea):
scroll_area.ensureWidgetVisible(card, 50, 50)
return True
return False
def _focus_first_library_card(self) -> bool:
container = getattr(self._parent, "gamesListWidget", None)
return self._focus_first_game_card(container)
def _get_autoinstall_toolbar_widgets(self) -> list[QWidget]:
widgets = []
for attr_name in ("autoInstallSearchLineEdit", "autoInstallRefreshButton"):
widget = getattr(self._parent, attr_name, None)
if isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled():
widgets.append(widget)
return widgets
def _focus_first_autoinstall_card(self) -> bool:
container = getattr(self._parent, "autoInstallContainer", None)
return self._focus_first_game_card(container)
def _handle_toolbar_navigation(self, code: int, value: int) -> bool:
current_index = self._parent.stackedWidget.currentIndex()
if value == 0 or current_index not in (0, 1):
return False
if current_index == 0:
toolbar_widgets = self._get_library_toolbar_widgets()
focus_first_card = self._focus_first_library_card
else:
toolbar_widgets = self._get_autoinstall_toolbar_widgets()
focus_first_card = self._focus_first_autoinstall_card
focused = self._focused_widget()
filter_widgets = self._get_library_filter_widgets() if current_index == 0 else []
if focused in filter_widgets:
if code == PAD_DPAD_X:
widget_index = filter_widgets.index(cast(QWidget, focused))
next_index = widget_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
return focus_first_card()
if focused not in toolbar_widgets:
return False
if code == PAD_DPAD_X:
widget_index = toolbar_widgets.index(cast(QWidget, focused))
next_index = widget_index + (1 if value > 0 else -1)
if 0 <= next_index < len(toolbar_widgets):
toolbar_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 focused is controls_button and self._focus_library_filter_controls():
return True
return focus_first_card()
tab_button = self._parent.tabButtons.get(current_index)
if tab_button is not None and tab_button.isVisible():
tab_button.setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _switch_visible_tab(self, step: int) -> bool:
idx = self._parent.stackedWidget.currentIndex()
visible = [i for i, btn in self._parent.tabButtons.items() if btn.isVisible()]
visible.sort()
if not visible:
return False
try:
current_pos = visible.index(idx)
except ValueError:
current_pos = 0
new_idx = visible[(current_pos + step) % len(visible)]
self._parent.switchTab(new_idx)
self._parent.tabButtons[new_idx].setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _handle_input_arrow_press(self, key: int) -> bool:
dpad = {
KEY_UP: (PAD_DPAD_Y, -1),
KEY_DOWN: (PAD_DPAD_Y, 1),
KEY_LEFT: (PAD_DPAD_X, -1),
KEY_RIGHT: (PAD_DPAD_X, 1),
}.get(key)
if dpad is None:
return False
self.dpad_moved.emit(dpad[0], dpad[1], time.time())
return True
def _handle_input_action_key(self, key: int, modifiers: int) -> bool:
focused = self._focused_widget()
if isinstance(focused, GameCard) and key == KEY_F10 and modifiers & MOD_SHIFT:
pos = QPoint(focused.width() // 2, focused.height() // 2)
focused._show_context_menu(pos)
return True
if key in (KEY_RETURN, KEY_ENTER):
if isinstance(focused, QTableWidget):
self.handle_table_confirm(focused)
else:
self._activate_focused_widget(focused)
return True
elif key in (KEY_ESCAPE, KEY_BACKSPACE):
return self._handle_input_back_key(focused)
elif key == KEY_E and not isinstance(focused, QLineEdit):
if self._parent.stackedWidget.currentIndex() == 0:
self._parent.openAddGameDialog()
return True
return False
def _handle_input_back_key(self, focused: QWidget | None) -> bool:
if isinstance(focused, QLineEdit) or self._focused_editable_combo(focused):
return False
self._parent.goBackDetailPage(self._parent.currentDetailPage)
return True
def _handle_input_key_release(self, key: int) -> bool:
if key in (KEY_UP, KEY_DOWN):
self.dpad_moved.emit(PAD_DPAD_Y, 0, time.time())
return True
elif key in (KEY_LEFT, KEY_RIGHT):
self.dpad_moved.emit(PAD_DPAD_X, 0, time.time())
return True
return False
+6
View File
@@ -0,0 +1,6 @@
from typing import TYPE_CHECKING, Any
class InputMixin:
if TYPE_CHECKING:
def __getattr__(self, name: str) -> Any: ...
+398
View File
@@ -0,0 +1,398 @@
import time
from typing import cast
from PySide6.QtCore import QObject, QTimer
from PySide6.QtWidgets import QApplication
from portprotonqt.config import display_config, gamepad_config
from portprotonqt.input_manager.constants import (
BUTTONS,
GamepadType,
INPUT_AXIS_SCALE,
PAD_AXIS_LEFT_TRIGGER,
PAD_AXIS_LEFT_X,
PAD_AXIS_LEFT_Y,
PAD_AXIS_RIGHT_TRIGGER,
PAD_AXIS_RIGHT_Y,
PAD_BUTTON_START,
PAD_DPAD_X,
PAD_DPAD_Y,
SDL3_PLAYSTATION_TYPES,
SDL3_XBOX_LIKE_TYPES,
SDL_GAMEPAD_BUTTON_DPAD_DOWN,
SDL_GAMEPAD_BUTTON_DPAD_LEFT,
SDL_GAMEPAD_BUTTON_DPAD_RIGHT,
SDL_GAMEPAD_BUTTON_DPAD_UP,
SDL_CONTROLLER_AXIS_TO_PAD,
SDL_CONTROLLER_BUTTON_TO_PAD,
)
from portprotonqt.input_manager.mixin import InputMixin
from portprotonqt.logger import get_logger
from portprotonqt.native_gamepad import (
GamepadBackendError,
SDLGamepad,
find_gamepad,
shutdown as shutdown_gamepad_backend,
)
from portprotonqt.sound_manager import SoundManager
logger = get_logger(__name__)
class GamepadRuntimeMixin(InputMixin):
def init_gamepad(self) -> None:
self.gamepad_hotplug.connect(self._on_gamepad_hotplug)
self._initial_gamepad_check = True
self.check_gamepad()
self._initial_gamepad_check = False
self.gamepad_poll_timer = QTimer(cast(QObject, self))
self.gamepad_poll_timer.setInterval(10)
self.gamepad_poll_timer.timeout.connect(self._poll_gamepad)
self.gamepad_poll_timer.start()
logger.info("Gamepad support initialized with SDL3 events")
def _on_gamepad_hotplug(self, action: str) -> None:
try:
if self._gamepad_polling_suspended:
return
if action == 'add':
self.check_gamepad()
elif action == 'remove':
had_gamepad = self.gamepad is not None
if self.gamepad:
self.gamepad.close()
self.gamepad = None
self._reset_input_state()
self._refresh_gamepad_ui()
self.check_gamepad()
if had_gamepad and not self.gamepad:
SoundManager().play("gamepad_off")
if had_gamepad and not self.gamepad and display_config.get_auto_fullscreen_gamepad() and not display_config.get_fullscreen():
self.toggle_fullscreen.emit(False)
except Exception as e:
logger.error(f"Error in hotplug handler: {e}", exc_info=True)
def check_gamepad(self) -> None:
try:
if self._gamepad_polling_suspended:
return
new_gamepad = self.find_gamepad()
if new_gamepad:
if self.gamepad and new_gamepad.path == self.gamepad.path:
new_gamepad.close()
return
if self.gamepad:
self.gamepad.close()
self.detect_gamepad_axes(new_gamepad)
logger.info(f"Gamepad connected: {new_gamepad.name} at {new_gamepad.path}")
self.gamepad = new_gamepad
self._reset_input_state()
self._last_gamepad_check_time = 0.0
self.gamepad_type = self._get_effective_gamepad_type(new_gamepad)
self._refresh_gamepad_ui()
if not self._initial_gamepad_check:
SoundManager().play("gamepad_connect")
if display_config.get_auto_fullscreen_gamepad() and not display_config.get_fullscreen():
self.toggle_fullscreen.emit(True)
elif self.gamepad:
logger.info("Gamepad no longer detected")
self.gamepad.close()
self.gamepad = None
self._reset_input_state()
self._refresh_gamepad_ui()
SoundManager().play("gamepad_off")
if display_config.get_auto_fullscreen_gamepad() and not display_config.get_fullscreen():
self.toggle_fullscreen.emit(False)
except Exception as e:
logger.error(f"Error checking gamepad: {e}", exc_info=True)
def _reset_input_state(self) -> None:
"""Reset cached joystick state when the active device changes."""
self._button_states.clear()
self._hat_states.clear()
self._axis_states.clear()
self.stick_x_raw = self.center_x
self.stick_y_raw = self.center_y
self.scroll_accumulator = 0.0
self.lt_pressed = False
self.rt_pressed = False
self.start_held = False
self.select_held = False
self.pending_menu_fullscreen_time = 0.0
self.guide_held = False
self.emulation_triggered = False
self.gamepad_type = self._get_configured_gamepad_type()
def _get_configured_gamepad_type(self) -> GamepadType:
"""Return manual gamepad type from config or Unknown for auto mode."""
value = gamepad_config.get_gamepad_type()
if value == "playstation":
return GamepadType.PLAYSTATION
if value == "xbox":
return GamepadType.XBOX
return GamepadType.UNKNOWN
def _get_effective_gamepad_type(self, gamepad: SDLGamepad) -> GamepadType:
"""Use manual gamepad type when configured, otherwise auto-detect."""
configured_type = self._get_configured_gamepad_type()
if configured_type != GamepadType.UNKNOWN:
return configured_type
return self._detect_gamepad_type(gamepad)
def apply_gamepad_type_setting(self) -> None:
"""Apply configured gamepad type to the current device."""
if self.gamepad is None:
self.gamepad_type = self._get_configured_gamepad_type()
return
self.gamepad_type = self._get_effective_gamepad_type(self.gamepad)
def _detect_gamepad_type(self, gamepad: SDLGamepad) -> GamepadType:
"""Read gamepad type from SDL or fall back to Xbox."""
if gamepad.sdl_type in SDL3_PLAYSTATION_TYPES:
return GamepadType.PLAYSTATION
if gamepad.sdl_type in SDL3_XBOX_LIKE_TYPES:
return GamepadType.XBOX
return GamepadType.XBOX
def _refresh_gamepad_ui(self) -> None:
"""Refresh control hints and virtual keyboard after gamepad changes."""
update_hints = getattr(self._parent, "updateControlHints", None)
if callable(update_hints):
update_hints()
keyboard = getattr(self._parent, "keyboard", None)
if keyboard and hasattr(keyboard, "update_keyboard"):
keyboard.update_keyboard()
def find_gamepad(self) -> SDLGamepad | None:
"""Find the first SDL3 gamepad with a standardized mapping."""
try:
gamepad = find_gamepad()
self._last_gamepad_error = None
return gamepad
except GamepadBackendError as error:
error_message = str(error)
if error_message != self._last_gamepad_error:
logger.error("Error finding gamepad: %s", error_message)
self._last_gamepad_error = error_message
except Exception as e:
logger.error(f"Error finding gamepad: {e}", exc_info=True)
return None
def detect_gamepad_axes(self, device: SDLGamepad) -> None:
"""Use normalized SDL axis ranges for navigation and mouse emulation."""
self.min_value = -INPUT_AXIS_SCALE
self.max_value = INPUT_AXIS_SCALE
self.center_x = 0
self.center_y = 0
self.deadzone_value = 4000
self.scroll_axis_code = PAD_AXIS_RIGHT_Y
self.scroll_center = 0
self.scroll_min_value = -INPUT_AXIS_SCALE
self.scroll_max_value = INPUT_AXIS_SCALE
self.scroll_deadzone_value = self.deadzone_value
self.stick_x_raw = self.center_x
self.stick_y_raw = self.center_y
logger.info("Gamepad axes configured for SDL backend: %s", device.name)
def _handle_pending_menu_fullscreen(self, current_time: float) -> None:
if not self.pending_menu_fullscreen_time:
return
if current_time < self.pending_menu_fullscreen_time:
return
self.pending_menu_fullscreen_time = 0.0
if self.start_held or self.in_guide_combination_attempt:
return
self.toggle_fullscreen.emit(not self._is_fullscreen)
def _poll_button_events(self, gamepad: SDLGamepad, current_time: float) -> None:
"""Emit button changes using SDL's standardized controller mapping."""
for button_index, button_code in SDL_CONTROLLER_BUTTON_TO_PAD.items():
value = gamepad.get_button(button_index)
self._handle_button_value(button_index, button_code, value, current_time)
def _handle_button_value(self, button_index: int, button_code: int, value: int, current_time: float) -> None:
if self._button_states.get(button_index) == value:
return
self._button_states[button_index] = value
if button_code in BUTTONS['guide']:
self.guide_held = value == 1
if button_code in BUTTONS['menu']:
self.select_held = value == 1
if button_code == PAD_BUTTON_START:
self.start_held = value == 1
emulation_combo = self._handle_emulation_combo(button_code, value)
if self._is_mouse_emulation_active() and value == 1:
if button_code in BUTTONS['confirm']:
self.click_left()
elif button_code in BUTTONS['back']:
self.click_right()
if self._should_skip_regular_events():
return
self.button_event.emit(button_code, value)
if self._should_schedule_menu_fullscreen(button_code, value, emulation_combo):
self.pending_menu_fullscreen_time = current_time + self.guide_combination_timeout
def _handle_emulation_combo(self, button_code: int, value: int) -> bool:
emulation_combo = value == 1 and (
(button_code in BUTTONS['menu'] and self.start_held) or
(button_code == PAD_BUTTON_START and self.select_held)
)
if emulation_combo:
self.emulation_triggered = not self.emulation_triggered
self.pending_menu_fullscreen_time = 0.0
return emulation_combo
def _should_schedule_menu_fullscreen(self, button_code: int, value: int, emulation_combo: bool) -> bool:
return (
value == 1 and button_code in BUTTONS['menu'] and
not emulation_combo and
not self._is_gamescope_session and not self.in_guide_combination_attempt and
self._parent.isActiveWindow()
)
def _poll_hat_events(self, gamepad: SDLGamepad, current_time: float) -> None:
"""Read D-pad state from SDL's standardized gamepad buttons."""
hat_value = (
self._read_dpad_button_axis(gamepad, SDL_GAMEPAD_BUTTON_DPAD_LEFT, SDL_GAMEPAD_BUTTON_DPAD_RIGHT),
self._read_dpad_button_axis(gamepad, SDL_GAMEPAD_BUTTON_DPAD_UP, SDL_GAMEPAD_BUTTON_DPAD_DOWN),
)
previous_value = self._hat_states.get(0, (0, 0))
self._hat_states[0] = hat_value
if self._is_mouse_emulation_active():
if previous_value[0] != hat_value[0]:
if hat_value[0] < 0:
self.move_mouse(-10, 0)
elif hat_value[0] > 0:
self.move_mouse(10, 0)
if previous_value[1] != hat_value[1]:
if hat_value[1] > 0:
self.move_mouse(0, -10)
elif hat_value[1] < 0:
self.move_mouse(0, 10)
if self._should_skip_regular_events():
return
if previous_value[0] != hat_value[0]:
self.dpad_moved.emit(PAD_DPAD_X, hat_value[0], current_time)
if previous_value[1] != hat_value[1]:
self.dpad_moved.emit(PAD_DPAD_Y, hat_value[1], current_time)
def _read_dpad_button_axis(self, gamepad: SDLGamepad, negative_button: int, positive_button: int) -> int:
negative = gamepad.get_button(negative_button)
positive = gamepad.get_button(positive_button)
return positive - negative
def _poll_axis_events(self, gamepad: SDLGamepad, current_time: float) -> None:
"""Emit axis changes using the same evdev-like codes used by the UI."""
for axis_index, axis_code in SDL_CONTROLLER_AXIS_TO_PAD.items():
raw_value = gamepad.get_axis(axis_index)
previous_value = self._axis_states.get(axis_index)
if previous_value == raw_value:
continue
self._axis_states[axis_index] = raw_value
if axis_code == PAD_AXIS_LEFT_X:
self.stick_x_raw = raw_value
elif axis_code == PAD_AXIS_LEFT_Y:
self.stick_y_raw = raw_value
elif axis_code == self.scroll_axis_code:
self.handle_scroll(raw_value)
if self._should_skip_regular_events():
continue
if axis_code in (PAD_AXIS_LEFT_TRIGGER, PAD_AXIS_RIGHT_TRIGGER):
self._emit_trigger_event(axis_code, raw_value, current_time)
continue
if axis_code not in (PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y):
continue
self.dpad_moved.emit(axis_code, raw_value, current_time)
def _emit_trigger_event(self, axis_code: int, raw_value: int, current_time: float) -> None:
"""Convert SDL trigger axis values into press/release button events."""
if current_time - self.last_trigger_time < self.trigger_cooldown:
return
is_pressed = raw_value > 16384
if axis_code == PAD_AXIS_LEFT_TRIGGER and is_pressed != self.lt_pressed:
self.lt_pressed = is_pressed
self.button_event.emit(axis_code, int(is_pressed))
self.last_trigger_time = current_time
elif axis_code == PAD_AXIS_RIGHT_TRIGGER and is_pressed != self.rt_pressed:
self.rt_pressed = is_pressed
self.button_event.emit(axis_code, int(is_pressed))
self.last_trigger_time = current_time
def _is_mouse_emulation_active(self) -> bool:
"""Return True when the gamepad currently drives mouse emulation."""
return self.mouse_emulation_enabled and self.emulation_active and self.emulation_triggered
def _should_skip_regular_events(self) -> bool:
"""Skip regular UI events while mouse emulation owns the controller."""
return self._is_mouse_emulation_active() and QApplication.activeWindow() is not None
def _poll_gamepad(self) -> None:
if not self.running or self._gamepad_polling_suspended:
return
current_time = time.time()
active_gamepad = self.gamepad
if not active_gamepad:
if current_time - self._last_gamepad_check_time >= 1.0:
self.gamepad_hotplug.emit('add')
self._last_gamepad_check_time = current_time
return
try:
active_gamepad.update()
if not active_gamepad.connected():
self.gamepad_hotplug.emit('remove')
return
self._poll_button_events(active_gamepad, current_time)
self._handle_pending_menu_fullscreen(current_time)
self._poll_hat_events(active_gamepad, current_time)
self._poll_axis_events(active_gamepad, current_time)
if (
current_time - self.last_update >= self.update_interval and
self.mouse_emulation_enabled and self.emulation_active and self.emulation_triggered
):
self.update_mouse_position()
self.last_update = current_time
except Exception as error:
logger.error("Unexpected error in gamepad polling: %s", error, exc_info=True)
def cleanup(self) -> None:
"""
Proper shutdown of gamepad and udev monitor.
"""
try:
# Mouse emulation cleanup
self.disable_mouse_emulation()
# Stop focus check timer
self.focus_check_timer.stop()
# Flag to stop udev monitor loop
self.running = False
# Stop all timers
if hasattr(self, 'gamepad_poll_timer'):
self.gamepad_poll_timer.stop()
self.dpad_timer.stop()
self.nav_timer.stop()
if self.gamepad:
self.gamepad.close()
self.gamepad = None
self._reset_input_state()
self.gamepad_type = self._get_configured_gamepad_type()
shutdown_gamepad_backend()
logger.info("Gamepad cleanup completed")
except Exception as e:
logger.error(f"Error during cleanup: {e}", exc_info=True)
+514
View File
@@ -0,0 +1,514 @@
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QCheckBox,
QComboBox,
QDialog,
QLineEdit,
QPushButton,
QTableWidget,
)
from portprotonqt.input_manager.constants import (
BUTTONS,
PAD_AXIS_LEFT_X,
PAD_AXIS_LEFT_Y,
PAD_DPAD_X,
PAD_DPAD_Y,
)
from portprotonqt.input_manager.mixin import InputMixin
from portprotonqt.logger import get_logger
from portprotonqt.sound_manager import SoundManager
logger = get_logger(__name__)
class SettingsInputMixin(InputMixin):
# SETTINGS MODE
def enable_settings_mode(self, settings_dialog):
"""Setup gamepad handling for ExeSettingsDialog"""
try:
self._setup_mode_handlers(
settings_dialog,
self.handle_settings_button,
self.handle_settings_dpad,
'settings_dialog'
)
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._restore_original_handlers('settings_dialog')
logger.debug("Gamepad handling successfully restored from Settings")
except Exception as e:
logger.error(f"Error restoring gamepad handlers from Settings: {e}")
def handle_settings_button(self, button_code, value):
if self.settings_dialog is None:
return
try:
# 1. Virtual Keyboard Handling
kb = getattr(self.settings_dialog, 'keyboard', None)
if kb and kb.isVisible():
if button_code in BUTTONS['back']:
if value != 0: # Only handle press, not release
kb.hide()
if kb.current_input_widget:
kb.current_input_widget.setFocus()
return # Return early to avoid dialog closing logic
elif button_code in (BUTTONS['confirm'] | BUTTONS['context_menu']):
if value != 0: # Only handle press, not release
kb.activateFocusedKey()
return
elif button_code in BUTTONS['prev_tab']:
if value != 0: # Only handle press, not release
kb.on_lang_click()
return
elif button_code in BUTTONS['next_tab']:
if value != 0: # Only handle press, not release
kb.on_shift_click(not kb.shift_pressed)
return
elif button_code in BUTTONS['add_game']:
if value != 0: # Press event
kb.on_backspace_pressed()
else: # Release event
kb.stop_backspace_repeat()
return
# Handle common UI elements like QMessageBox, QMenu, etc.
# Only handle press events (value != 0), ignore release events
if value != 0 and self._handle_common_ui_elements(button_code):
return
# Handle other QDialogs
if value != 0: # Only handle press events, not releases
popup = QApplication.activePopupWidget()
if isinstance(popup, QDialog):
if button_code in BUTTONS['confirm']:
popup.accept()
elif button_code in BUTTONS['back']:
popup.reject()
return
# 3. Combo Box popup handling in settings (Advanced + MangoHud)
table = self._get_current_settings_table()
open_combo = self._get_open_settings_combo()
# B Button - Close combo or dialog
if button_code in BUTTONS['back']:
if open_combo:
open_combo.hidePopup()
if open_combo.isEditable() and open_combo.lineEdit() is not None:
open_combo.lineEdit().setFocus()
else:
open_combo.setFocus()
else:
self.settings_dialog.reject()
return
if button_code in BUTTONS['context_menu']:
handler = getattr(self.settings_dialog, "handle_settings_context_menu", None)
if callable(handler) and handler():
return
# A Button - Confirm
if button_code in BUTTONS['confirm']:
if open_combo:
view = open_combo.view()
if view.currentIndex().isValid():
open_combo.setCurrentIndex(view.currentIndex().row())
open_combo.hidePopup()
if open_combo.isEditable() and open_combo.lineEdit() is not None:
open_combo.lineEdit().setFocus()
else:
open_combo.setFocus()
return
# Standard interaction
focused = QApplication.focusWidget()
if isinstance(focused, QCheckBox) and focused.isEnabled():
focused.setChecked(not focused.isChecked())
return
if isinstance(focused, QPushButton) and focused.isEnabled():
focused.click()
return
combo_from_focus = None
if isinstance(focused, QComboBox):
combo_from_focus = focused
else:
parent = focused.parentWidget() if focused else None
if isinstance(parent, QComboBox):
combo_from_focus = parent
if combo_from_focus and combo_from_focus.isEnabled():
combo_from_focus.showPopup()
combo_from_focus.setFocus()
return
if isinstance(focused, QTableWidget) and table and focused.currentRow() >= 0:
# Main settings (checkboxes)
if self.settings_dialog and table == self.settings_dialog.settings_table:
self.handle_table_confirm(focused)
return
# Advanced settings
cell = focused.cellWidget(focused.currentRow(), 1)
if isinstance(cell, QComboBox) and 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
if self._is_current_settings_tool_tab():
if isinstance(focused, QCheckBox):
focused.toggle()
return
if isinstance(focused, QPushButton):
focused.click()
return
if isinstance(focused, QComboBox):
focused.showPopup()
return
self._parent.activateFocusedWidget()
return
# 4. Global Shortcuts
if button_code in BUTTONS['add_game']: # X: Apply
self.settings_dialog.apply_changes()
elif button_code in BUTTONS['prev_dir']: # Y: Search + Keyboard
focused = QApplication.focusWidget()
focused_combo = None
if isinstance(focused, QTableWidget) and self.settings_dialog:
table = self._get_current_settings_table()
if table is not None and table == self.settings_dialog.advanced_table:
row = focused.currentRow()
if row >= 0:
cell_widget = focused.cellWidget(row, 1)
if isinstance(cell_widget, QComboBox):
focused_combo = cell_widget
if focused_combo is None:
if isinstance(focused, QComboBox):
focused_combo = focused
else:
parent = focused.parentWidget() if focused else None
if isinstance(parent, QComboBox):
focused_combo = parent
if focused_combo and focused_combo.isEditable() and focused_combo.isEnabled():
line_edit = focused_combo.lineEdit()
if line_edit is not None:
line_edit.setFocus()
self.settings_dialog.show_virtual_keyboard(line_edit)
return
self.settings_dialog.search_edit.setFocus()
self.settings_dialog.show_virtual_keyboard(self.settings_dialog.search_edit)
elif button_code in BUTTONS['prev_tab']: # LB
current_index = self.settings_dialog.tab_widget.currentIndex()
idx = max(0, current_index - 1)
self.settings_dialog.tab_widget.setCurrentIndex(idx)
if idx != current_index:
SoundManager().play("tab_switch")
self._focus_first_row_in_current_settings_table()
elif button_code in BUTTONS['next_tab']: # RB
current_index = self.settings_dialog.tab_widget.currentIndex()
idx = min(self.settings_dialog.tab_widget.count() - 1, current_index + 1)
self.settings_dialog.tab_widget.setCurrentIndex(idx)
if idx != current_index:
SoundManager().play("tab_switch")
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_settings_dpad(self, code, value, now):
if self.settings_dialog is None:
return
try:
# 1. Virtual Keyboard Navigation
kb = getattr(self.settings_dialog, 'keyboard', None)
if kb and kb.isVisible():
normalized_value = 0
# Normalize Stick vs D-pad
if code in (PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y): # Sticks
if abs(value) < self.dead_zone:
self.current_dpad_code = None
self.current_dpad_value = 0
self.dpad_timer.stop()
return
normalized_value = 1 if value > self.dead_zone else -1
else: # D-pad
normalized_value = value
if normalized_value != 0:
if code in (PAD_DPAD_X, PAD_AXIS_LEFT_X):
if normalized_value > 0:
kb.move_focus_right()
else:
kb.move_focus_left()
elif code in (PAD_DPAD_Y, PAD_AXIS_LEFT_Y):
if normalized_value > 0:
kb.move_focus_down()
else:
kb.move_focus_up()
return
if self._is_current_settings_tool_tab():
focused = QApplication.focusWidget()
open_combo = self._get_open_settings_combo()
if open_combo:
if code == PAD_DPAD_Y and value != 0:
view = open_combo.view()
model = view.model()
current_index = view.currentIndex()
if model and current_index.isValid():
row_count = model.rowCount()
current_row = current_index.row()
if value > 0:
next_row = min(current_row + 1, row_count - 1)
else:
next_row = max(current_row - 1, 0)
new_index = model.index(next_row, current_index.column())
view.setCurrentIndex(new_index)
view.scrollTo(new_index, QAbstractItemView.ScrollHint.PositionAtCenter)
return
if code not in (PAD_DPAD_X, PAD_DPAD_Y, PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y):
return
normalized_value = value
if code in (PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y):
if abs(value) < self.dead_zone:
normalized_value = 0
else:
normalized_value = 1 if value > 0 else -1
if normalized_value == 0:
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
return
if code in (PAD_DPAD_X, PAD_DPAD_Y):
if self.current_dpad_code != code or self.current_dpad_value != normalized_value:
self.dpad_timer.stop()
self.dpad_timer.setInterval(120 if self.dpad_timer.isActive() else 220)
self.dpad_timer.start()
self.current_dpad_code = code
self.current_dpad_value = normalized_value
sections = self._get_mangohud_nav_sections()
if not sections:
return
if not focused or not self._find_widget_in_sections(focused, sections):
self._focus_first_row_in_current_settings_table()
return
if code in (PAD_DPAD_X, PAD_AXIS_LEFT_X):
self._move_mangohud_horizontal(focused, normalized_value, sections)
elif code in (PAD_DPAD_Y, PAD_AXIS_LEFT_Y):
self._move_mangohud_vertical(focused, normalized_value, sections)
return
# 2. Combo Box Navigation (within Advanced Table)
table = self._get_current_settings_table()
if not table or table.rowCount() == 0:
return
if self.settings_dialog and table == self.settings_dialog.advanced_table and table.currentRow() >= 0:
cell_widget = table.cellWidget(table.currentRow(), 1)
if isinstance(cell_widget, QComboBox) and cell_widget.view().isVisible():
if code == PAD_DPAD_Y and value != 0:
idx = cell_widget.currentIndex()
new_idx = max(0, idx - 1) if value < 0 else min(cell_widget.count() - 1, idx + 1)
if new_idx != idx:
cell_widget.setCurrentIndex(new_idx)
return # Consume event
# 3. Standard Table Navigation
if value == 0:
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
return
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)
self.dpad_timer.start()
self.current_dpad_code = code
self.current_dpad_value = value
current_row = table.currentRow()
if code == PAD_DPAD_Y: # Up/Down
step = -1 if value < 0 else 1
new_row = current_row + step
while 0 <= new_row < table.rowCount() and table.isRowHidden(new_row):
new_row += step
if 0 <= new_row < table.rowCount():
focus_column = 1
table.setCurrentCell(new_row, focus_column)
self._focus_settings_advanced_value_widget(table, new_row)
elif code == PAD_DPAD_X: # Left/Right
if self._move_settings_inline_button_focus(table, value):
return
current_col = table.currentColumn()
if value < 0: # Left
if current_col > 0:
new_col = max(0, current_col - 1)
table.setCurrentCell(current_row, new_col)
if new_col == 1:
self._focus_settings_advanced_value_widget(table, current_row)
else:
table.setFocus(Qt.FocusReason.OtherFocusReason)
else: # Right
if current_col < table.columnCount() - 1:
new_col = min(table.columnCount() - 1, current_col + 1)
table.setCurrentCell(current_row, new_col)
if new_col == 1:
self._focus_settings_advanced_value_widget(table, current_row)
else:
table.setFocus(Qt.FocusReason.OtherFocusReason)
except Exception as e:
logger.error(f"Error in handle_settings_dpad: {e}")
def _get_current_settings_table(self) -> QTableWidget | None:
if not self.settings_dialog:
return None
getter = getattr(self.settings_dialog, "_get_current_settings_table", None)
if callable(getter):
table = getter()
if isinstance(table, QTableWidget):
return table
return None
def _is_current_settings_tool_tab(self):
if not self.settings_dialog:
return False
current_tab = self.settings_dialog.tab_widget.currentWidget()
return current_tab in (
getattr(self.settings_dialog, "mangohud_tab", None),
getattr(self.settings_dialog, "vkbasalt_tab", None),
getattr(self.settings_dialog, "gamescope_tab", None),
)
def _get_open_settings_combo(self):
"""Return currently opened combo popup in settings dialog if any."""
if not self.settings_dialog:
return None
for combo in self.settings_dialog.findChildren(
QComboBox, options=Qt.FindChildOption.FindChildrenRecursively
):
if combo.isVisible() and combo.view().isVisible():
return combo
return None
def _focus_settings_advanced_value_widget(self, table, row):
"""Focus value widget in settings row when available."""
if not self.settings_dialog:
table.setFocus(Qt.FocusReason.OtherFocusReason)
return
if table == self.settings_dialog.settings_table:
cell_widget = table.cellWidget(row, 1)
if cell_widget is not None:
checkbox = cell_widget.findChild(QCheckBox)
if checkbox and checkbox.isEnabled():
checkbox.setFocus(Qt.FocusReason.OtherFocusReason)
return
table.setFocus(Qt.FocusReason.OtherFocusReason)
return
if table == getattr(self.settings_dialog, "favorites_table", None):
cell_widget = table.cellWidget(row, 1)
if isinstance(cell_widget, (QCheckBox, QComboBox, QLineEdit)):
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
return
if cell_widget is not None:
checkbox = cell_widget.findChild(QCheckBox)
if checkbox and checkbox.isEnabled():
checkbox.setFocus(Qt.FocusReason.OtherFocusReason)
return
table.setFocus(Qt.FocusReason.OtherFocusReason)
return
if table != self.settings_dialog.advanced_table:
table.setFocus(Qt.FocusReason.OtherFocusReason)
return
cell_widget = table.cellWidget(row, 1)
if isinstance(cell_widget, QComboBox):
line_edit = cell_widget.lineEdit()
if cell_widget.isEditable() and line_edit is not None:
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
else:
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
return
if isinstance(cell_widget, QLineEdit):
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
return
if (
cell_widget is not None
and cell_widget.property("ppqt_run_after_exe_widget")
):
line_edit = cell_widget.findChild(QLineEdit)
if line_edit and line_edit.isEnabled():
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
return
table.setFocus(Qt.FocusReason.OtherFocusReason)
def _move_settings_inline_button_focus(self, table: QTableWidget, value: int) -> bool:
"""Move focus between inline line edit and button in settings cell."""
if not self.settings_dialog or table != self.settings_dialog.advanced_table:
return False
if table.currentColumn() != 1:
return False
cell_widget = table.cellWidget(table.currentRow(), 1)
if cell_widget is None or not cell_widget.property("ppqt_run_after_exe_widget"):
return False
line_edit = cell_widget.findChild(QLineEdit)
button = cell_widget.findChild(QPushButton)
focused = QApplication.focusWidget()
if value > 0 and focused == line_edit and button and button.isEnabled():
button.setFocus(Qt.FocusReason.OtherFocusReason)
return True
if value < 0 and focused == button and line_edit and line_edit.isEnabled():
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
return True
return False
def _focus_first_row_in_current_settings_table(self):
table = self._get_current_settings_table()
if table and table.rowCount() > 0:
col = 1
table.setCurrentCell(0, col)
self._focus_settings_advanced_value_widget(table, 0)
return
if self._is_current_settings_tool_tab():
sections = self._get_mangohud_nav_sections()
if sections and sections[0]:
self._focus_mangohud_widget(sections[0][0])
@@ -0,0 +1,500 @@
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QCheckBox, QPushButton, QScrollArea, QSlider, QWidget
from portprotonqt.input_manager.mixin import InputMixin
class SettingsVisualNavigationMixin(InputMixin):
def _get_mangohud_nav_sections(self):
"""Return settings tool focusable widgets grouped by visual sections."""
if not self.settings_dialog:
return []
current_tab = self.settings_dialog.tab_widget.currentWidget()
is_mangohud = current_tab == getattr(self.settings_dialog, "mangohud_tab", None)
is_vkbasalt = current_tab == getattr(self.settings_dialog, "vkbasalt_tab", None)
is_gamescope = current_tab == getattr(self.settings_dialog, "gamescope_tab", None)
if not is_mangohud and not is_vkbasalt and not is_gamescope:
return []
sections = []
value_widgets = []
if is_mangohud:
value_widgets = [
widget for key, widget in self.settings_dialog.mangohud_widgets.items()
if key != 'fps_limit_method' and widget.isVisible() and widget.isEnabled()
]
elif is_gamescope:
value_widgets = [
widget for widget in self.settings_dialog.gamescope_widgets.values()
if widget.isVisible() and widget.isEnabled()
]
value_widgets.extend([
widget for widget in self.settings_dialog.gamescope_resolution_widgets.values()
if widget.isVisible() and widget.isEnabled()
])
mangohud_tab = self.settings_dialog.tab_widget.currentWidget()
preset_buttons = []
if mangohud_tab:
preset_buttons = [
widget for widget in mangohud_tab.findChildren(
QPushButton, options=Qt.FindChildOption.FindChildrenRecursively
)
if widget.isVisible() and widget.isEnabled()
]
preset_section = self._sort_widgets_by_position(preset_buttons) if preset_buttons else []
toggle_widgets = []
if is_vkbasalt:
toggle_widgets = [
checkbox for checkbox in self.settings_dialog.vkbasalt_shader_widgets.values()
if checkbox.isVisible() and checkbox.isEnabled()
]
else:
category_combo_attr = 'mangohud_category_combo' if is_mangohud else 'gamescope_category_combo'
category_stack_attr = 'mangohud_category_stack' if is_mangohud else 'gamescope_category_stack'
category_combo = getattr(self.settings_dialog, category_combo_attr, None)
if category_combo and category_combo.isVisible() and category_combo.isEnabled():
toggle_widgets.append(category_combo)
category_stack = getattr(self.settings_dialog, category_stack_attr, None)
if category_stack:
category_widget = category_stack.currentWidget()
if category_widget:
category_checkboxes = [
checkbox for checkbox in category_widget.findChildren(
QCheckBox, options=Qt.FindChildOption.FindChildrenRecursively
)
if checkbox.isVisible() and checkbox.isEnabled()
]
toggle_widgets.extend(self._sort_widgets_by_position(category_checkboxes))
toggle_section = toggle_widgets if toggle_widgets else []
fps_section = []
if is_mangohud:
fps_widgets = []
fps_limit_method = self.settings_dialog.mangohud_widgets.get('fps_limit_method')
if fps_limit_method and fps_limit_method.isVisible() and fps_limit_method.isEnabled():
fps_widgets.append(fps_limit_method)
fps_widgets.extend([
checkbox for checkbox in self.settings_dialog.mangohud_fps_widgets.values()
if checkbox.isVisible() and checkbox.isEnabled()
])
if fps_widgets:
fps_section = self._sort_widgets_by_position(fps_widgets)
if is_vkbasalt:
extra_edit = getattr(self.settings_dialog, 'vkbasalt_cas_slider', None)
else:
extra_edit_attr = 'mangohud_extra_edit' if is_mangohud else 'gamescope_extra_edit'
extra_edit = getattr(self.settings_dialog, extra_edit_attr, None)
extra_section = [extra_edit] if extra_edit and extra_edit.isVisible() and extra_edit.isEnabled() else []
if is_mangohud:
if preset_section:
sections.append(preset_section)
if toggle_section:
sections.append(toggle_section)
if value_widgets:
sections.append(self._sort_widgets_by_position(value_widgets))
if fps_section:
sections.append(fps_section)
if extra_section:
sections.append(extra_section)
elif is_gamescope:
# Gamescope UI order: presets -> toggles -> values -> extra.
if preset_section:
sections.append(preset_section)
if toggle_section:
sections.append(toggle_section)
if value_widgets:
sections.append(self._sort_widgets_by_position(value_widgets))
if extra_section:
sections.append(extra_section)
else:
if preset_section:
sections.append(preset_section)
if extra_section:
sections.append(extra_section)
if toggle_section:
sections.append(self._sort_widgets_by_position(toggle_section))
return sections
def _sort_widgets_by_position(self, widgets):
"""Sort widgets by their y/x position in settings dialog coordinates."""
return sorted(
widgets,
key=lambda widget: (
widget.mapTo(self.settings_dialog, widget.rect().topLeft()).y(),
widget.mapTo(self.settings_dialog, widget.rect().topLeft()).x(),
),
)
def _find_widget_in_sections(self, widget, sections):
"""Find widget position in sections list."""
for section_index, section in enumerate(sections):
for widget_index, item in enumerate(section):
if item is widget:
return section_index, widget_index
return None
def _focus_mangohud_widget(self, widget):
"""Focus MangoHud widget and ensure it is visible in scroll area."""
self._ensure_mangohud_widget_visible(widget)
widget.setFocus(Qt.FocusReason.OtherFocusReason)
def _ensure_mangohud_widget_visible(self, widget):
"""Auto-scroll MangoHud tab to the currently focused widget."""
if not self.settings_dialog:
return
mangohud_tab = self.settings_dialog.tab_widget.currentWidget()
if not mangohud_tab:
return
scroll_area = mangohud_tab.findChild(QScrollArea)
if scroll_area:
scroll_area.ensureWidgetVisible(widget, 20, 20)
def _move_mangohud_horizontal(self, focused, direction, sections):
"""Move focus left/right inside current MangoHud section."""
position = self._find_widget_in_sections(focused, sections)
if not position:
return
if isinstance(focused, QSlider):
focused.setValue(focused.value() + direction)
return
section_index, _widget_index = position
target = self._find_mangohud_grid_horizontal_target(
focused, direction, sections[section_index]
)
if target:
self._focus_mangohud_widget(target)
return
target = self._find_mangohud_neighbor_in_section(
focused, sections[section_index], direction, is_vertical=False
)
if target:
self._focus_mangohud_widget(target)
def _move_mangohud_vertical(self, focused, direction, sections):
"""Move focus up/down inside section, then across sections."""
position = self._find_widget_in_sections(focused, sections)
if not position:
return
settings_dialog = self.settings_dialog
if not settings_dialog:
return
current_tab = settings_dialog.tab_widget.currentWidget()
is_mangohud = current_tab == getattr(settings_dialog, "mangohud_tab", None)
is_vkbasalt = current_tab == getattr(settings_dialog, "vkbasalt_tab", None)
section_index, _widget_index = position
if is_vkbasalt and isinstance(focused, QSlider) and direction > 0:
target = self._find_vkbasalt_first_effect_widget()
if target:
self._focus_mangohud_widget(target)
return
toggle_boundary_reached = False
if self._is_mangohud_fps_widget(focused):
fps_target = self._find_mangohud_fps_vertical_target(
focused, direction, sections[section_index]
)
if fps_target:
self._focus_mangohud_widget(fps_target)
return
if self._is_mangohud_toggle_widget(focused):
toggle_target = self._find_mangohud_toggle_vertical_target(
focused, direction, sections[section_index]
)
if toggle_target:
self._focus_mangohud_widget(toggle_target)
return
toggle_boundary_reached = True
fps_limit_method = None
if is_mangohud:
fps_limit_method = settings_dialog.mangohud_widgets.get('fps_limit_method')
if direction > 0 and is_mangohud and fps_limit_method and focused is fps_limit_method:
fps_checkboxes = [
checkbox for checkbox in settings_dialog.mangohud_fps_widgets.values()
if checkbox.isVisible() and checkbox.isEnabled()
]
if fps_checkboxes:
first_fps_checkbox = self._sort_widgets_by_position(fps_checkboxes)[0]
self._focus_mangohud_widget(first_fps_checkbox)
return
category_combo = None
category_stack = None
if not is_vkbasalt:
category_combo_attr = 'mangohud_category_combo' if is_mangohud else 'gamescope_category_combo'
category_stack_attr = 'mangohud_category_stack' if is_mangohud else 'gamescope_category_stack'
category_combo = getattr(settings_dialog, category_combo_attr, None)
category_stack = getattr(settings_dialog, category_stack_attr, None)
if direction > 0 and category_combo and focused is category_combo and category_stack:
current_category_widget = category_stack.currentWidget()
if current_category_widget:
category_checkboxes = [
checkbox for checkbox in current_category_widget.findChildren(
QCheckBox, options=Qt.FindChildOption.FindChildrenRecursively
)
if checkbox.isVisible() and checkbox.isEnabled()
]
if category_checkboxes:
first_checkbox = self._sort_widgets_by_position(category_checkboxes)[0]
self._focus_mangohud_widget(first_checkbox)
return
if not toggle_boundary_reached:
target_in_section = self._find_mangohud_neighbor_in_section(
focused, sections[section_index], direction, is_vertical=True
)
if target_in_section:
self._focus_mangohud_widget(target_in_section)
return
target_section_index = section_index + direction
if target_section_index < 0 or target_section_index >= len(sections):
return
target_section = sections[target_section_index]
if not target_section:
return
if category_combo and category_combo in target_section:
self._focus_mangohud_widget(category_combo)
return
if target_section_index == 1:
self._focus_mangohud_widget(target_section[0])
return
fps_limit_method = None
if is_mangohud:
fps_limit_method = settings_dialog.mangohud_widgets.get('fps_limit_method')
if fps_limit_method and fps_limit_method in target_section:
self._focus_mangohud_widget(fps_limit_method)
return
current_center = focused.mapTo(settings_dialog, focused.rect().center()).x()
target_widget = min(
target_section,
key=lambda widget: abs(widget.mapTo(settings_dialog, widget.rect().center()).x() - current_center),
)
self._focus_mangohud_widget(target_widget)
def _find_mangohud_neighbor_in_section(self, focused, section, direction, is_vertical):
"""Find closest focusable neighbor in current section by direction."""
settings_dialog = self.settings_dialog
if not settings_dialog:
return None
focused_center = focused.mapTo(settings_dialog, focused.rect().center())
fx = focused_center.x()
fy = focused_center.y()
candidates = []
for widget in section:
if widget is focused:
continue
center = widget.mapTo(settings_dialog, widget.rect().center())
dx = center.x() - fx
dy = center.y() - fy
if is_vertical:
if direction < 0 and dy >= -4:
continue
if direction > 0 and dy <= 4:
continue
score = abs(dy) + abs(dx) * 3
else:
if direction < 0 and dx >= -4:
continue
if direction > 0 and dx <= 4:
continue
score = abs(dx) + abs(dy) * 3
candidates.append((score, widget))
if not candidates:
return None
candidates.sort(key=lambda item: item[0])
return candidates[0][1]
def _find_mangohud_grid_horizontal_target(
self, focused: QWidget, direction: int, section: list[QWidget]
) -> QWidget | None:
"""Navigate left/right in MangoHud grids with row wrap at edges."""
settings_dialog = self.settings_dialog
if not settings_dialog:
return None
sorted_widgets = sorted(
section,
key=lambda widget: (
widget.mapTo(settings_dialog, widget.rect().center()).y(),
widget.mapTo(settings_dialog, widget.rect().center()).x(),
),
)
rows = []
tolerance = 24
for widget in sorted_widgets:
y = widget.mapTo(settings_dialog, widget.rect().center()).y()
if not rows:
rows.append([widget])
continue
last_y = rows[-1][0].mapTo(settings_dialog, rows[-1][0].rect().center()).y()
if abs(y - last_y) <= tolerance:
rows[-1].append(widget)
else:
rows.append([widget])
for row in rows:
row.sort(key=lambda widget: widget.mapTo(settings_dialog, widget.rect().center()).x())
row_idx = -1
col_idx = -1
for index, row in enumerate(rows):
if focused in row:
row_idx = index
col_idx = row.index(focused)
break
if row_idx == -1:
return None
if direction > 0:
if col_idx + 1 < len(rows[row_idx]):
return rows[row_idx][col_idx + 1]
if row_idx + 1 < len(rows):
return rows[row_idx + 1][0]
return None
if direction < 0:
if col_idx - 1 >= 0:
return rows[row_idx][col_idx - 1]
if row_idx - 1 >= 0:
return rows[row_idx - 1][-1]
return None
return None
def _is_mangohud_fps_widget(self, widget):
"""Check if widget belongs to MangoHud FPS section."""
if not self.settings_dialog:
return False
return widget in set(self.settings_dialog.mangohud_fps_widgets.values())
def _is_mangohud_toggle_widget(self, widget: QWidget) -> bool:
"""Check if widget belongs to MangoHud/Gamescope toggle checkbox section."""
if not self.settings_dialog:
return False
current_tab = self.settings_dialog.tab_widget.currentWidget()
if current_tab == getattr(self.settings_dialog, "mangohud_tab", None):
toggle_keys = getattr(self.settings_dialog, 'mangohud_toggle_widget_keys', {})
return isinstance(widget, QCheckBox) and widget in toggle_keys
if current_tab == getattr(self.settings_dialog, "vkbasalt_tab", None):
toggle_widgets = getattr(self.settings_dialog, 'vkbasalt_shader_widgets', {})
return isinstance(widget, QCheckBox) and widget in set(toggle_widgets.values())
elif current_tab == getattr(self.settings_dialog, "gamescope_tab", None):
toggle_keys = getattr(self.settings_dialog, 'gamescope_toggle_widget_keys', {})
else:
toggle_keys = {}
return isinstance(widget, QCheckBox) and widget in toggle_keys
def _find_mangohud_toggle_vertical_target(
self, focused: QWidget, direction: int, section: list[QWidget]
) -> QWidget | None:
"""Navigate toggle checkboxes down/up with automatic next/prev column jump."""
toggle_widgets = [widget for widget in section if self._is_mangohud_toggle_widget(widget)]
if not toggle_widgets:
return None
if self._is_current_vkbasalt_tab():
return self._find_vkbasalt_effect_vertical_target(focused, direction, toggle_widgets)
return self._find_mangohud_vertical_grid_target(focused, direction, toggle_widgets)
def _is_current_vkbasalt_tab(self) -> bool:
if not self.settings_dialog:
return False
current_tab = self.settings_dialog.tab_widget.currentWidget()
return current_tab == getattr(self.settings_dialog, "vkbasalt_tab", None)
def _find_vkbasalt_first_effect_widget(self) -> QWidget | None:
if not self.settings_dialog:
return None
widgets = [
widget for widget in self.settings_dialog.vkbasalt_shader_widgets.values()
if widget.isVisible() and widget.isEnabled()
]
if not widgets:
return None
return self._sort_widgets_by_position(widgets)[0]
def _find_vkbasalt_effect_vertical_target(
self, focused: QWidget, direction: int, widgets: list[QWidget]
) -> QWidget | None:
if not self.settings_dialog:
return None
target = self._find_mangohud_vertical_grid_target(focused, direction, widgets)
if direction >= 0 or target is None:
return target
focused_pos = focused.mapTo(self.settings_dialog, focused.rect().center())
target_pos = target.mapTo(self.settings_dialog, target.rect().center())
if target_pos.x() < focused_pos.x() and target_pos.y() > focused_pos.y():
return None
return target
def _find_mangohud_fps_vertical_target(self, focused, direction, section):
"""Navigate FPS widgets down/up with automatic next/prev column jump."""
fps_widgets = [widget for widget in section if self._is_mangohud_fps_widget(widget)]
if not fps_widgets:
return None
return self._find_mangohud_vertical_grid_target(focused, direction, fps_widgets)
def _find_mangohud_vertical_grid_target(
self, focused: QWidget, direction: int, widgets: list[QWidget]
) -> QWidget | None:
"""Find vertical target in grid columns with column-to-column wrap."""
settings_dialog = self.settings_dialog
if not settings_dialog:
return None
sorted_widgets = sorted(
widgets,
key=lambda widget: (
widget.mapTo(settings_dialog, widget.rect().center()).x(),
widget.mapTo(settings_dialog, widget.rect().center()).y(),
),
)
columns = []
tolerance = 24
for widget in sorted_widgets:
x = widget.mapTo(settings_dialog, widget.rect().center()).x()
if not columns:
columns.append([widget])
continue
last_x = columns[-1][0].mapTo(settings_dialog, columns[-1][0].rect().center()).x()
if abs(x - last_x) <= tolerance:
columns[-1].append(widget)
else:
columns.append([widget])
col_idx = -1
row_idx = -1
for index, column in enumerate(columns):
if focused in column:
col_idx = index
row_idx = column.index(focused)
break
if col_idx == -1:
return None
if direction > 0:
if row_idx + 1 < len(columns[col_idx]):
return columns[col_idx][row_idx + 1]
if col_idx + 1 < len(columns):
return columns[col_idx + 1][0]
return None
if direction < 0:
if row_idx - 1 >= 0:
return columns[col_idx][row_idx - 1]
if col_idx - 1 >= 0:
return columns[col_idx - 1][-1]
return None
return None
+42 -3
View File
@@ -1,7 +1,6 @@
# Install Python source files
install_data(
['__init__.py',
'app.py',
'appimage_integration.py',
'appimage_updater.py',
'cli.py',
@@ -12,13 +11,11 @@ install_data(
'downloader.py',
'compatibility_report.py',
'game_card.py',
'gamepad_backend.py',
'game_library_manager.py',
'gog_api.py',
'howlongtobeat_api.py',
'image_utils.py',
'icon_extractor.py',
'input_manager.py',
'keyboard_layouts.py',
'localization.py',
'logger.py',
@@ -39,6 +36,48 @@ install_data(
install_dir: pythondir / meson.project_name(),
)
install_data(
configure_file(
input: 'app.py',
output: 'app.py',
configuration: version_conf,
),
install_dir: pythondir / meson.project_name(),
)
install_data(
configure_file(
input: 'native_gamepad.py',
output: 'native_gamepad.py',
configuration: conf,
),
install_dir: pythondir / meson.project_name(),
)
install_data(
['input_manager/__init__.py',
'input_manager/buttons.py',
'input_manager/constants.py',
'input_manager/dialog_modes.py',
'input_manager/dpad.py',
'input_manager/file_explorer.py',
'input_manager/keyboard.py',
'input_manager/mixin.py',
'input_manager/runtime.py',
'input_manager/settings.py',
'input_manager/settings_visual.py',
],
install_dir: pythondir / meson.project_name() / 'input_manager',
)
shared_library(
'portprotonqt_gamepad',
'gamepad_backend.c',
dependencies: sdl3_dep,
install: true,
install_dir: libdir / meson.project_name(),
)
install_data(
['compatibility_rules.json',
'keyboard_layouts.json',
+99
View File
@@ -0,0 +1,99 @@
import ctypes
from dataclasses import dataclass
from pathlib import Path
GAMEPAD_LIBRARY_NAME = "libportprotonqt_gamepad.so"
SYSTEM_GAMEPAD_LIBRARY = Path("@GAMEPAD_LIBRARY_DIR@") / GAMEPAD_LIBRARY_NAME
DEV_GAMEPAD_LIBRARY = (
Path(__file__).parent.parent / "build-aux" / "lib" / GAMEPAD_LIBRARY_NAME
)
def _load_library() -> ctypes.CDLL:
library_path = (
SYSTEM_GAMEPAD_LIBRARY
if SYSTEM_GAMEPAD_LIBRARY.is_file()
else DEV_GAMEPAD_LIBRARY
)
return ctypes.CDLL(str(library_path))
_library = _load_library()
_handle = ctypes.c_void_p
_library.portproton_gamepad_find.argtypes = []
_library.portproton_gamepad_find.restype = _handle
_library.portproton_gamepad_get_error.argtypes = []
_library.portproton_gamepad_get_error.restype = ctypes.c_char_p
_library.portproton_gamepad_close.argtypes = [_handle]
_library.portproton_gamepad_close.restype = None
_library.portproton_gamepad_connected.argtypes = [_handle]
_library.portproton_gamepad_connected.restype = ctypes.c_bool
_library.portproton_gamepad_update.argtypes = []
_library.portproton_gamepad_update.restype = None
_library.portproton_gamepad_get_button.argtypes = [_handle, ctypes.c_int]
_library.portproton_gamepad_get_button.restype = ctypes.c_int
_library.portproton_gamepad_get_axis.argtypes = [_handle, ctypes.c_int]
_library.portproton_gamepad_get_axis.restype = ctypes.c_int16
_library.portproton_gamepad_get_name.argtypes = [_handle]
_library.portproton_gamepad_get_name.restype = ctypes.c_char_p
_library.portproton_gamepad_get_instance_id.argtypes = [_handle]
_library.portproton_gamepad_get_instance_id.restype = ctypes.c_uint32
_library.portproton_gamepad_get_type.argtypes = [_handle]
_library.portproton_gamepad_get_type.restype = ctypes.c_int
_library.portproton_gamepad_shutdown.argtypes = []
_library.portproton_gamepad_shutdown.restype = None
class GamepadBackendError(RuntimeError):
"""SDL failed while discovering the active gamepad."""
@dataclass
class SDLGamepad:
controller: int
name: str
path: str
instance_id: int
sdl_type: int
def close(self) -> None:
if self.controller:
_library.portproton_gamepad_close(self.controller)
self.controller = 0
def __del__(self) -> None:
self.close()
def connected(self) -> bool:
return bool(_library.portproton_gamepad_connected(self.controller))
def update(self) -> None:
_library.portproton_gamepad_update()
def get_button(self, button: int) -> int:
return int(_library.portproton_gamepad_get_button(self.controller, button))
def get_axis(self, axis: int) -> int:
return int(_library.portproton_gamepad_get_axis(self.controller, axis))
def find_gamepad() -> SDLGamepad | None:
controller = _library.portproton_gamepad_find()
if not controller:
error = _library.portproton_gamepad_get_error().decode(errors="replace")
if error:
raise GamepadBackendError(error)
return None
instance_id = int(_library.portproton_gamepad_get_instance_id(controller))
name = _library.portproton_gamepad_get_name(controller).decode(errors="replace")
return SDLGamepad(
controller=controller,
name=name,
path=f"sdl3-gamepad:{instance_id}",
instance_id=instance_id,
sdl_type=int(_library.portproton_gamepad_get_type(controller)),
)
def shutdown() -> None:
_library.portproton_gamepad_shutdown()
+1 -1
View File
@@ -928,7 +928,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
self.settingsDebounceTimer.start()
gamepad_connected = self.input_manager.find_gamepad() is not None
gamepad_connected = self.input_manager.gamepad is not None
if fullscreen or (auto_fullscreen_gamepad and gamepad_connected):
self.showFullScreen()
+621 -6
View File
@@ -1,25 +1,94 @@
"""Tests for gamepad input navigation."""
from types import SimpleNamespace
from typing import cast
from typing import Any, cast
from PySide6.QtCore import QEvent, QObject, Qt
from PySide6.QtWidgets import QApplication, QFrame, QStackedWidget, QWidget
from pytest import MonkeyPatch
from PySide6.QtCore import QEvent, QObject, QStringListModel, Qt
from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QFrame, QLineEdit, QListView, QPushButton, QSlider, QStackedWidget, QTableWidget, QTableWidgetItem, QWidget
from pytest import MonkeyPatch, raises
import portprotonqt.input_manager as input_manager
from portprotonqt.gamepad_backend import (
import portprotonqt.input_manager.buttons as input_buttons
import portprotonqt.input_manager.dpad as input_dpad
import portprotonqt.input_manager.runtime as input_runtime
import portprotonqt.native_gamepad as native_gamepad
from portprotonqt.input_manager.constants import (
GamepadType,
SDL_GAMEPAD_TYPE_PS5,
SDL_GAMEPAD_BUTTON_DPAD_DOWN,
SDL_GAMEPAD_BUTTON_DPAD_UP,
SDLGamepad,
)
from portprotonqt.input_manager import InputManager, MainWindowProtocol, PAD_DPAD_X, PAD_DPAD_Y
from portprotonqt.native_gamepad import GamepadBackendError, SDLGamepad
FIRST_CARD_X = 0
HIDDEN_CARD_X = 20
NEXT_CARD_X = 40
def test_native_gamepad_result_preserves_python_interface(monkeypatch: MonkeyPatch) -> None:
controller = 42
monkeypatch.setattr(native_gamepad._library, "portproton_gamepad_find", lambda: controller)
monkeypatch.setattr(native_gamepad._library, "portproton_gamepad_close", lambda _handle: None)
monkeypatch.setattr(native_gamepad._library, "portproton_gamepad_get_instance_id", lambda _handle: 7)
monkeypatch.setattr(native_gamepad._library, "portproton_gamepad_get_name", lambda _handle: b"DualSense")
monkeypatch.setattr(
native_gamepad._library,
"portproton_gamepad_get_type",
lambda _handle: SDL_GAMEPAD_TYPE_PS5,
)
gamepad = native_gamepad.find_gamepad()
assert gamepad is not None
assert gamepad.controller == controller
assert gamepad.name == "DualSense"
assert gamepad.path == "sdl3-gamepad:7"
manager = InputManager.__new__(InputManager)
assert manager._detect_gamepad_type(gamepad) == GamepadType.PLAYSTATION
gamepad.close()
def test_native_gamepad_reports_sdl_discovery_error(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setattr(native_gamepad._library, "portproton_gamepad_find", lambda: None)
monkeypatch.setattr(
native_gamepad._library,
"portproton_gamepad_get_error",
lambda: b"SDL gamepad initialization failed",
)
with raises(GamepadBackendError, match="SDL gamepad initialization failed"):
native_gamepad.find_gamepad()
def test_native_gamepad_absence_is_not_an_error(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setattr(native_gamepad._library, "portproton_gamepad_find", lambda: None)
monkeypatch.setattr(native_gamepad._library, "portproton_gamepad_get_error", lambda: b"")
assert native_gamepad.find_gamepad() is None
def test_repeated_gamepad_backend_error_is_logged_once(monkeypatch: MonkeyPatch) -> None:
logged_errors: list[tuple[object, ...]] = []
manager = InputManager.__new__(InputManager)
manager._last_gamepad_error = None
def fail_discovery() -> None:
raise GamepadBackendError("SDL initialization failed")
monkeypatch.setattr(input_runtime, "find_gamepad", fail_discovery)
monkeypatch.setattr(
input_runtime.logger,
"error",
lambda *args, **_kwargs: logged_errors.append(args),
)
assert manager.find_gamepad() is None
assert manager.find_gamepad() is None
assert len(logged_errors) == 1
def test_qt_event_to_input_key_supports_cyrillic() -> None:
event = cast(
QEvent,
@@ -77,6 +146,7 @@ def test_game_card_navigation_skips_hidden_cards(monkeypatch: MonkeyPatch) -> No
app.processEvents()
monkeypatch.setattr(input_manager, "GameCard", DummyCard)
monkeypatch.setattr(input_dpad, "GameCard", DummyCard)
manager = InputManager.__new__(InputManager)
manager._parent = cast(MainWindowProtocol, SimpleNamespace(tabButtons={0: QWidget()}))
@@ -151,3 +221,548 @@ def test_library_toolbar_navigation_includes_delete_missing_button() -> None:
assert handled is True
assert QApplication.focusWidget() is widgets[4]
def test_library_size_adjustment_uses_original_step() -> None:
app = QApplication.instance() or QApplication([])
slider = QSlider()
slider.setRange(100, 300)
slider.setValue(200)
slider.setTickInterval(25)
callbacks: list[bool] = []
parent = SimpleNamespace(
stackedWidget=SimpleNamespace(currentIndex=lambda: 0),
system_tab_index=4,
game_library_manager=SimpleNamespace(sizeSlider=slider),
on_slider_released=lambda: callbacks.append(True),
)
manager = InputManager.__new__(InputManager)
manager._parent = cast(MainWindowProtocol, parent)
assert manager._adjust_active_slider(1)
assert slider.value() == 210
assert callbacks == [True]
assert app is not None
def test_system_volume_adjustment_uses_original_step() -> None:
app = QApplication.instance() or QApplication([])
section_stack = QStackedWidget()
for _index in range(5):
section_stack.addWidget(QWidget())
section_stack.setCurrentIndex(4)
slider = QSlider()
slider.setRange(0, 100)
slider.setValue(50)
slider.setPageStep(17)
callbacks: list[bool] = []
parent = SimpleNamespace(
stackedWidget=SimpleNamespace(currentIndex=lambda: 3),
system_tab_index=3,
systemSectionStack=section_stack,
audioVolumeSlider=slider,
_applySelectedAudioVolume=lambda: callbacks.append(True),
)
manager = InputManager.__new__(InputManager)
manager._parent = cast(MainWindowProtocol, parent)
assert manager._adjust_active_slider(-1)
assert slider.value() == 45
assert callbacks == [True]
assert app is not None
def test_context_menu_button_routes_to_table_current_item() -> None:
app = QApplication.instance() or QApplication([])
table = QTableWidget(1, 1)
table.setItem(0, 0, QTableWidgetItem("Game"))
table.setCurrentCell(0, 0)
emitted = []
table.customContextMenuRequested.connect(emitted.append)
manager = InputManager.__new__(InputManager)
context_button = next(iter(input_manager.BUTTONS["context_menu"]))
assert manager._open_focused_context_menu(table, context_button)
item = table.item(0, 0)
assert item is not None
assert emitted == [table.visualItemRect(item).center()]
assert app is not None
def test_context_menu_button_ignores_widget_without_custom_menu() -> None:
app = QApplication.instance() or QApplication([])
widget = QWidget()
manager = InputManager.__new__(InputManager)
context_button = next(iter(input_manager.BUTTONS["context_menu"]))
assert not manager._open_focused_context_menu(widget, context_button)
assert app is not None
def test_confirm_button_opens_combo_popup() -> None:
app = QApplication.instance() or QApplication([])
combo = QComboBox()
combo.addItems(["First", "Second"])
manager = InputManager.__new__(InputManager)
confirm_button = next(iter(input_manager.BUTTONS["confirm"]))
assert manager._handle_combo_button(combo, confirm_button)
assert combo.view().isVisible()
combo.hidePopup()
assert app is not None
def test_back_button_closes_visible_combo_popup() -> None:
app = QApplication.instance() or QApplication([])
combo = QComboBox()
combo.addItems(["First", "Second"])
combo.showPopup()
manager = InputManager.__new__(InputManager)
back_button = next(iter(input_manager.BUTTONS["back"]))
assert manager._handle_combo_button(combo, back_button)
assert not combo.view().isVisible()
assert app is not None
def test_list_confirm_emits_selection_actions() -> None:
app = QApplication.instance() or QApplication([])
view = QListView()
model = QStringListModel(["First", "Second"])
view.setModel(model)
view.setCurrentIndex(model.index(1, 0))
activated = []
clicked = []
view.activated.connect(activated.append)
view.clicked.connect(clicked.append)
manager = InputManager.__new__(InputManager)
manager._activate_list_view_selection(view)
assert [index.row() for index in activated] == [1]
assert [index.row() for index in clicked] == [1]
assert app is not None
def test_list_back_preserves_dialog_fallthrough() -> None:
app = QApplication.instance() or QApplication([])
view = QListView()
model = QStringListModel(["First"])
view.setModel(model)
view.setCurrentIndex(model.index(0, 0))
manager = InputManager.__new__(InputManager)
back_button = next(iter(input_manager.BUTTONS["back"]))
assert not manager._handle_list_view_button(view, back_button)
assert not view.selectionModel().hasSelection()
assert app is not None
def test_system_table_button_routes_action_and_sound(monkeypatch: MonkeyPatch) -> None:
app = QApplication.instance() or QApplication([])
actions = []
sounds = []
manager = InputManager.__new__(InputManager)
manager._parent = cast(
MainWindowProtocol,
SimpleNamespace(
handleSystemTableGamepadAction=lambda _table, action: actions.append(action) or True,
),
)
monkeypatch.setattr(input_manager, "SoundManager", lambda: SimpleNamespace(play=sounds.append))
monkeypatch.setattr(input_buttons, "SoundManager", lambda: SimpleNamespace(play=sounds.append))
table = QTableWidget()
back_button = next(iter(input_manager.BUTTONS["back"]))
assert manager._handle_system_table_button(table, back_button)
assert actions == ["back"]
assert sounds == ["back"]
assert app is not None
def test_confirm_button_routes_line_edit_to_keyboard() -> None:
app = QApplication.instance() or QApplication([])
shown_for = []
manager = InputManager.__new__(InputManager)
manager._parent = cast(
MainWindowProtocol,
SimpleNamespace(keyboard=SimpleNamespace(show_for_widget=shown_for.append)),
)
line_edit = QLineEdit()
confirm_button = next(iter(input_manager.BUTTONS["confirm"]))
assert manager._route_button_to_text_input(QWidget(), line_edit, confirm_button)
assert shown_for == [line_edit]
assert app is not None
def test_prev_dir_button_focuses_search_for_current_tab() -> None:
app = QApplication.instance() or QApplication([])
window = QWidget()
search = QLineEdit(window)
window.show()
search.show()
app.processEvents()
manager = InputManager.__new__(InputManager)
manager._parent = cast(
MainWindowProtocol,
SimpleNamespace(
keyboard=None,
stackedWidget=SimpleNamespace(currentIndex=lambda: 0),
searchEdit=search,
),
)
prev_dir_button = next(iter(input_manager.BUTTONS["prev_dir"]))
assert manager._route_button_to_text_input(window, None, prev_dir_button)
assert search.hasFocus()
def test_system_quick_button_routes_add_game_and_sound(monkeypatch: MonkeyPatch) -> None:
actions = []
sounds = []
manager = InputManager.__new__(InputManager)
manager._parent = cast(
MainWindowProtocol,
SimpleNamespace(handleSystemGamepadAction=lambda action: actions.append(action) or True),
)
monkeypatch.setattr(input_manager, "SoundManager", lambda: SimpleNamespace(play=sounds.append))
monkeypatch.setattr(input_buttons, "SoundManager", lambda: SimpleNamespace(play=sounds.append))
add_button = next(iter(input_manager.BUTTONS["add_game"]))
assert manager._handle_system_quick_button(add_button)
assert actions == ["add_game"]
assert sounds == ["open"]
def test_standard_confirm_activates_focused_widget(monkeypatch: MonkeyPatch) -> None:
app = QApplication.instance() or QApplication([])
activations = []
sounds = []
focused = QWidget()
manager = InputManager.__new__(InputManager)
manager._parent = cast(
MainWindowProtocol,
SimpleNamespace(activateFocusedWidget=lambda: activations.append(True)),
)
monkeypatch.setattr(
input_manager,
"SoundManager",
lambda: SimpleNamespace(play_widget_sound=sounds.append),
)
monkeypatch.setattr(
input_buttons,
"SoundManager",
lambda: SimpleNamespace(play_widget_sound=sounds.append),
)
confirm_button = next(iter(input_manager.BUTTONS["confirm"]))
manager._handle_standard_button(focused, confirm_button, 1)
assert activations == []
app.processEvents()
assert activations == [True]
assert sounds == [focused]
def test_guide_select_combination_refreshes_games() -> None:
timer_events: list[str | int] = []
refreshes: list[bool] = []
manager: Any = InputManager.__new__(InputManager)
manager._parent = cast(
MainWindowProtocol,
SimpleNamespace(refreshGames=lambda: refreshes.append(True)),
)
manager.guide_timer = SimpleNamespace(
start=lambda timeout: timer_events.append(timeout),
stop=lambda: timer_events.append("stop"),
)
manager.guide_combination_timeout = 0.3
manager.guide_held = False
manager.guide_pressed_time = 0
manager.select_pressed_time = 0
manager.in_guide_combination_attempt = False
guide_button = next(iter(input_manager.BUTTONS["guide"]))
menu_button = next(iter(input_manager.BUTTONS["menu"]))
assert manager._handle_guide_combination(guide_button, 1.0)
assert manager._handle_guide_combination(menu_button, 1.2)
assert timer_events == [300, "stop"]
assert refreshes == [True]
assert manager.guide_held is False
def test_dialog_surface_receives_connected_input_signals() -> None:
app = QApplication.instance() or QApplication([])
button_events: list[tuple[int, int]] = []
dpad_events: list[tuple[int, int, float]] = []
default_events: list[tuple[int, int]] = []
manager: Any = InputManager.__new__(InputManager)
QObject.__init__(manager)
manager._input_surfaces = []
manager._input_surface_base_state = None
manager._gamepad_handling_enabled = False
manager.dpad_timer = SimpleNamespace(stop=lambda: None)
manager.current_dpad_code = None
manager.current_dpad_value = 0
manager._handle_default_button = lambda code, value: default_events.append((code, value))
manager.button_event.connect(manager.handle_button_slot)
manager.dpad_moved.connect(manager.handle_dpad_slot)
manager._setup_mode_handlers(
object(),
lambda code, value: button_events.append((code, value)),
lambda code, value, now: dpad_events.append((code, value, now)),
"settings_dialog",
)
manager.button_event.emit(1, 1)
manager.dpad_moved.emit(PAD_DPAD_Y, 1, 2.0)
assert button_events == [(1, 1)]
assert dpad_events == [(PAD_DPAD_Y, 1, 2.0)]
assert default_events == []
manager._restore_original_handlers("settings_dialog")
manager.button_event.emit(2, 1)
assert default_events == [(2, 1)]
assert app is not None
def test_dialog_surface_disconnects_owned_callbacks() -> None:
callback_events: list[tuple[int, ...]] = []
manager: Any = InputManager.__new__(InputManager)
QObject.__init__(manager)
manager._input_surfaces = []
manager._input_surface_base_state = None
manager._gamepad_handling_enabled = False
manager.dpad_timer = SimpleNamespace(stop=lambda: None)
manager.current_dpad_code = None
manager.current_dpad_value = 0
manager._setup_mode_handlers(object(), lambda *_args: None, lambda *_args: None, "dialog")
manager.connect_surface_updates("dialog", lambda *args: callback_events.append(args))
manager.button_event.emit(1, 1)
manager._restore_original_handlers("dialog")
manager.button_event.emit(2, 1)
manager.dpad_moved.emit(PAD_DPAD_Y, 1, 2.0)
assert callback_events == [(1, 1)]
def test_nested_input_surfaces_restore_base_state() -> None:
manager: Any = InputManager.__new__(InputManager)
manager._input_surfaces = []
manager._input_surface_base_state = None
manager._gamepad_handling_enabled = False
manager.dpad_timer = SimpleNamespace(stop=lambda: None)
manager.current_dpad_code = None
manager.current_dpad_value = 0
first_events: list[int] = []
second_events: list[int] = []
manager._setup_mode_handlers(
object(), lambda code, _value: first_events.append(code), lambda *_args: None,
"settings_dialog",
)
manager._setup_mode_handlers(
object(), lambda code, _value: second_events.append(code), lambda *_args: None,
"file_explorer",
)
manager._restore_original_handlers("settings_dialog")
assert manager._route_surface_button(1, 1)
assert first_events == []
assert second_events == [1]
assert manager._gamepad_handling_enabled is True
manager._restore_original_handlers("file_explorer")
assert manager._gamepad_handling_enabled is False
assert not manager._route_surface_button(2, 1)
def test_gamepad_polling_uses_qt_event_loop() -> None:
app = QApplication.instance() or QApplication([])
checks: list[bool] = []
manager: Any = InputManager.__new__(InputManager)
QObject.__init__(manager)
manager.check_gamepad = lambda: checks.append(True)
manager.gamepad_hotplug.connect(lambda _action: None)
manager.init_gamepad()
assert checks == [True]
assert manager.gamepad_poll_timer.isActive()
assert manager.gamepad_poll_timer.interval() == 10
assert manager.gamepad_poll_timer.thread() is manager.thread()
manager.gamepad_poll_timer.stop()
assert app is not None
def test_gamepad_poll_reports_disconnected_device() -> None:
actions: list[str] = []
updates: list[bool] = []
manager: Any = InputManager.__new__(InputManager)
QObject.__init__(manager)
manager.running = True
manager._gamepad_polling_suspended = False
manager.gamepad = SimpleNamespace(
update=lambda: updates.append(True),
connected=lambda: False,
)
manager.gamepad_hotplug.connect(actions.append)
manager._poll_gamepad()
assert updates == [True]
assert actions == ["remove"]
def test_suspend_gamepad_polling_sets_runtime_guard() -> None:
manager: Any = InputManager.__new__(InputManager)
manager._gamepad_polling_suspended = False
manager._gamepad_handling_enabled = True
manager.dpad_timer = SimpleNamespace(stop=lambda: None)
manager.nav_timer = SimpleNamespace(stop=lambda: None)
manager.suspend_gamepad_polling()
assert manager._gamepad_polling_suspended is True
assert manager._gamepad_handling_enabled is False
def test_dialog_dpad_moves_focus_forward() -> None:
app = QApplication.instance() or QApplication([])
dialog = QDialog()
first_button = QPushButton(dialog)
second_button = QPushButton(dialog)
first_button.move(0, 0)
second_button.move(0, 30)
dialog.show()
first_button.show()
second_button.show()
app.processEvents()
first_button.setFocus(Qt.FocusReason.OtherFocusReason)
manager = InputManager.__new__(InputManager)
assert manager._handle_dialog_dpad(dialog, PAD_DPAD_Y, 1)
assert QApplication.focusWidget() is second_button
def test_list_dpad_moves_current_row() -> None:
app = QApplication.instance() or QApplication([])
view = QListView()
model = QStringListModel(["First", "Second"])
view.setModel(model)
view.setCurrentIndex(model.index(0, 0))
manager = InputManager.__new__(InputManager)
assert manager._handle_list_dpad(view, PAD_DPAD_Y, 1)
assert view.currentIndex().row() == 1
assert app is not None
def test_first_card_row_moves_focus_to_toolbar(monkeypatch: MonkeyPatch) -> None:
app = QApplication.instance() or QApplication([])
container = QWidget()
container.show()
card = DummyCard(container, FIRST_CARD_X)
toolbar_button = QWidget(container)
toolbar_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
toolbar_button.move(0, 30)
toolbar_button.show()
card.show()
app.processEvents()
card.setFocus(Qt.FocusReason.OtherFocusReason)
monkeypatch.setattr(input_manager, "GameCard", DummyCard)
monkeypatch.setattr(input_dpad, "GameCard", DummyCard)
parent = SimpleNamespace(
stackedWidget=SimpleNamespace(currentIndex=lambda: 0),
gamesListWidget=container,
)
manager: Any = InputManager.__new__(InputManager)
manager._parent = cast(MainWindowProtocol, parent)
manager._get_library_toolbar_widgets = lambda: [toolbar_button]
assert manager._focus_toolbar_from_first_card(PAD_DPAD_Y, -1)
assert QApplication.focusWidget() is toolbar_button
def test_system_section_horizontal_focuses_selected_button() -> None:
app = QApplication.instance() or QApplication([])
window = QWidget()
section_stack = QStackedWidget(window)
section_stack.addWidget(QWidget())
section_stack.addWidget(QWidget())
section_buttons = [QPushButton(window), QPushButton(window)]
section_buttons[1].move(0, 30)
window.show()
section_buttons[0].show()
section_buttons[1].show()
app.processEvents()
switched: list[int] = []
def switch_relative(step: int) -> bool:
switched.append(step)
section_stack.setCurrentIndex(1)
return True
parent = SimpleNamespace(
stackedWidget=SimpleNamespace(currentIndex=lambda: 3),
system_tab_index=3,
switchSystemSectionRelative=switch_relative,
systemSectionStack=section_stack,
systemSectionButtons=section_buttons,
)
manager = InputManager.__new__(InputManager)
manager._parent = cast(MainWindowProtocol, parent)
assert manager._handle_system_section_horizontal(PAD_DPAD_X, 1, True)
assert switched == [1]
assert section_buttons[1].hasFocus()
assert app is not None
def test_detail_page_dpad_uses_visual_button_rows(monkeypatch: MonkeyPatch) -> None:
app = QApplication.instance() or QApplication([])
page = QWidget()
buttons = [QPushButton(page) for _index in range(3)]
buttons[0].setGeometry(0, 0, 20, 20)
buttons[1].setGeometry(40, 0, 20, 20)
buttons[2].setGeometry(0, 40, 20, 20)
page.show()
for button in buttons:
button.show()
app.processEvents()
buttons[1].setFocus(Qt.FocusReason.OtherFocusReason)
monkeypatch.setattr(input_dpad, "AutoSizeButton", QPushButton)
manager = InputManager.__new__(InputManager)
manager._parent = cast(
MainWindowProtocol,
SimpleNamespace(
stackedWidget=SimpleNamespace(currentWidget=lambda: page),
currentDetailPage=page,
),
)
assert manager._handle_detail_page_dpad(PAD_DPAD_Y, 1)
assert QApplication.focusWidget() is buttons[2]
def test_page_vertical_dpad_moves_focus_forward() -> None:
app = QApplication.instance() or QApplication([])
page = QWidget()
first = QPushButton(page)
second = QPushButton(page)
second.move(0, 30)
page.show()
first.show()
second.show()
app.processEvents()
first.setFocus(Qt.FocusReason.OtherFocusReason)
manager = InputManager.__new__(InputManager)
manager._parent = cast(
MainWindowProtocol,
SimpleNamespace(stackedWidget=SimpleNamespace(currentWidget=lambda: page)),
)
assert manager._handle_page_vertical_dpad(PAD_DPAD_Y, 1)
assert QApplication.focusWidget() is second
+16
View File
@@ -13,6 +13,9 @@ from PySide6.QtWidgets import QApplication, QComboBox, QMenu, QPushButton, QTabB
from pytest import MonkeyPatch
import portprotonqt.input_manager as input_manager
import portprotonqt.input_manager.dialog_modes as input_dialog_modes
import portprotonqt.input_manager.keyboard as input_keyboard
import portprotonqt.input_manager.runtime as input_runtime
import portprotonqt.main_window as main_window
import portprotonqt.sound_manager as sound_manager
import portprotonqt.tabs.system_tab as system_tab
@@ -214,6 +217,8 @@ def test_gamepad_connection_sound_skips_initial_device(monkeypatch: MonkeyPatch)
manager._refresh_gamepad_ui = lambda: None
monkeypatch.setattr(input_manager, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
monkeypatch.setattr(input_manager.display_config, "get_auto_fullscreen_gamepad", lambda: False)
monkeypatch.setattr(input_runtime, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
monkeypatch.setattr(input_runtime.display_config, "get_auto_fullscreen_gamepad", lambda: False)
manager.check_gamepad()
manager._initial_gamepad_check = False
@@ -236,6 +241,8 @@ def test_gamepad_connection_sound_plays_after_startup(monkeypatch: MonkeyPatch)
manager._refresh_gamepad_ui = lambda: None
monkeypatch.setattr(input_manager, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
monkeypatch.setattr(input_manager.display_config, "get_auto_fullscreen_gamepad", lambda: False)
monkeypatch.setattr(input_runtime, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
monkeypatch.setattr(input_runtime.display_config, "get_auto_fullscreen_gamepad", lambda: False)
manager.check_gamepad()
@@ -252,6 +259,8 @@ def test_gamepad_disconnection_plays_sound(monkeypatch: MonkeyPatch) -> None:
manager._refresh_gamepad_ui = lambda: None
monkeypatch.setattr(input_manager, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
monkeypatch.setattr(input_manager.display_config, "get_auto_fullscreen_gamepad", lambda: False)
monkeypatch.setattr(input_runtime, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
monkeypatch.setattr(input_runtime.display_config, "get_auto_fullscreen_gamepad", lambda: False)
manager.check_gamepad()
@@ -272,6 +281,7 @@ def test_gamepad_dialog_tab_switch_plays_sound(monkeypatch: MonkeyPatch) -> None
manager._handle_common_ui_elements = lambda _button: False
manager._focus_first_row_in_current_table = lambda: None
monkeypatch.setattr(input_manager, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
monkeypatch.setattr(input_dialog_modes, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
button = next(iter(input_manager.BUTTONS["next_tab"]))
manager.handle_winetricks_button(button, 1)
@@ -290,6 +300,7 @@ def test_menu_navigation_plays_sound(monkeypatch: MonkeyPatch) -> None:
played_events: list[str] = []
manager: Any = InputManager.__new__(InputManager)
monkeypatch.setattr(input_manager, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
monkeypatch.setattr(input_dialog_modes, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
manager._navigate_menu_actions(menu, direction_down=True)
@@ -304,6 +315,7 @@ def test_menu_show_plays_open_sound(monkeypatch: MonkeyPatch) -> None:
manager = InputManager.__new__(InputManager)
QObject.__init__(manager)
monkeypatch.setattr(input_manager, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
monkeypatch.setattr(input_keyboard, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
manager.eventFilter(QMenu(), QEvent(QEvent.Type.Show))
@@ -319,6 +331,7 @@ def test_combo_popup_show_does_not_play_toggle_sound(monkeypatch: MonkeyPatch) -
combo = QComboBox()
popup = QWidget(combo, Qt.WindowType.Popup)
monkeypatch.setattr(input_manager, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
monkeypatch.setattr(input_keyboard, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
manager.eventFilter(popup, QEvent(QEvent.Type.Show))
@@ -335,6 +348,7 @@ def test_combo_item_highlight_plays_toggle_sound(monkeypatch: MonkeyPatch) -> No
combo.addItems(["First", "Second"])
popup = QWidget(combo, Qt.WindowType.Popup)
monkeypatch.setattr(input_manager, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
monkeypatch.setattr(input_keyboard, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
manager.eventFilter(popup, QEvent(QEvent.Type.Show))
combo.highlighted.emit(1)
@@ -349,6 +363,7 @@ def test_combo_show_does_not_play_open_sound(monkeypatch: MonkeyPatch) -> None:
manager = InputManager.__new__(InputManager)
QObject.__init__(manager)
monkeypatch.setattr(input_manager, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
monkeypatch.setattr(input_keyboard, "SoundManager", lambda: SimpleNamespace(play=played_events.append))
manager.eventFilter(QComboBox(), QEvent(QEvent.Type.Show))
@@ -363,6 +378,7 @@ def test_right_mouse_button_does_not_play_widget_sound(monkeypatch: MonkeyPatch)
QObject.__init__(manager)
sound_manager = SimpleNamespace(play_widget_sound=played_widgets.append)
monkeypatch.setattr(input_manager, "SoundManager", lambda: sound_manager)
monkeypatch.setattr(input_keyboard, "SoundManager", lambda: sound_manager)
event = QMouseEvent(
QEvent.Type.MouseButtonRelease,
QPointF(),