refactor: add native SDL3 gamepad backend #237
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
BIN
Binary file not shown.
Vendored
+1
@@ -5,6 +5,7 @@ Maintainer: Ivan Mazhukin <vanyamajukin@outlook.com>
|
||||
Build-Depends:
|
||||
debhelper-compat (= 13),
|
||||
gettext,
|
||||
libsdl3-dev,
|
||||
libvulkan-dev,
|
||||
meson,
|
||||
ninja-build,
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
Vendored
+1
@@ -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
@@ -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')
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
APP_VERSION = @APP_VERSION@
|
||||
APP_COMMIT = @APP_COMMIT@
|
||||
+8
-11
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
||||
class InputMixin:
|
||||
if TYPE_CHECKING:
|
||||
def __getattr__(self, name: str) -> Any: ...
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user