From 9e5fd9d1a731087b178e8705199b2f92a9b34fdc Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Thu, 13 Aug 2026 19:54:32 +0500 Subject: [PATCH] refactor: add native SDL3 gamepad input Assisted-by: Codex:gpt-5.6 Signed-off-by: Boris Yumankulov --- .gitea/workflows/build.yml | 8 +- .gitea/workflows/package-build.yml | 8 +- AGENTS.md | 122 +- build-aux/AppImage/portprotonqt-appimage.sh | 1 + build-aux/PKGBUILD | 2 +- build-aux/PKGBUILD-git | 2 +- build-aux/fedora-git.spec | 3 +- build-aux/fedora.spec | 3 +- build-aux/lib/libportprotonqt_gamepad.so | Bin 0 -> 17376 bytes debian/control | 1 + debian/portprotonqt.install | 1 + debian/rules | 1 + meson.build | 20 +- portprotonqt/_version.py.in | 2 - portprotonqt/app.py | 19 +- portprotonqt/dialogs/file_explorer.py | 12 +- portprotonqt/dialogs/proton_manager.py | 14 +- portprotonqt/dialogs/settings_dialog.py | 18 +- portprotonqt/dialogs/winetricks_dialog.py | 12 +- portprotonqt/gamepad_backend.c | 122 + portprotonqt/gamepad_backend.py | 244 - portprotonqt/input_manager.py | 4408 ----------------- portprotonqt/input_manager/__init__.py | 597 +++ portprotonqt/input_manager/buttons.py | 355 ++ portprotonqt/input_manager/constants.py | 131 + portprotonqt/input_manager/dialog_modes.py | 711 +++ portprotonqt/input_manager/dpad.py | 527 ++ portprotonqt/input_manager/file_explorer.py | 317 ++ portprotonqt/input_manager/keyboard.py | 606 +++ portprotonqt/input_manager/mixin.py | 6 + portprotonqt/input_manager/runtime.py | 398 ++ portprotonqt/input_manager/settings.py | 514 ++ portprotonqt/input_manager/settings_visual.py | 500 ++ portprotonqt/meson.build | 45 +- portprotonqt/native_gamepad.py | 99 + portprotonqt/tabs/settings_tab.py | 2 +- tests/test_input_manager.py | 627 ++- tests/test_sound_manager.py | 16 + 38 files changed, 5731 insertions(+), 4743 deletions(-) create mode 100755 build-aux/lib/libportprotonqt_gamepad.so delete mode 100644 portprotonqt/_version.py.in create mode 100644 portprotonqt/gamepad_backend.c delete mode 100644 portprotonqt/gamepad_backend.py delete mode 100644 portprotonqt/input_manager.py create mode 100644 portprotonqt/input_manager/__init__.py create mode 100644 portprotonqt/input_manager/buttons.py create mode 100644 portprotonqt/input_manager/constants.py create mode 100644 portprotonqt/input_manager/dialog_modes.py create mode 100644 portprotonqt/input_manager/dpad.py create mode 100644 portprotonqt/input_manager/file_explorer.py create mode 100644 portprotonqt/input_manager/keyboard.py create mode 100644 portprotonqt/input_manager/mixin.py create mode 100644 portprotonqt/input_manager/runtime.py create mode 100644 portprotonqt/input_manager/settings.py create mode 100644 portprotonqt/input_manager/settings_visual.py create mode 100644 portprotonqt/native_gamepad.py diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 109faeef..553e3b1f 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -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 diff --git a/.gitea/workflows/package-build.yml b/.gitea/workflows/package-build.yml index 0b244325..a49e1685 100644 --- a/.gitea/workflows/package-build.yml +++ b/.gitea/workflows/package-build.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index b098bf36..5a3b264c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ``` diff --git a/build-aux/AppImage/portprotonqt-appimage.sh b/build-aux/AppImage/portprotonqt-appimage.sh index 74cc97da..5e9bdfa8 100755 --- a/build-aux/AppImage/portprotonqt-appimage.sh +++ b/build-aux/AppImage/portprotonqt-appimage.sh @@ -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* \ diff --git a/build-aux/PKGBUILD b/build-aux/PKGBUILD index 0dcea43d..ec11e679 100644 --- a/build-aux/PKGBUILD +++ b/build-aux/PKGBUILD @@ -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' diff --git a/build-aux/PKGBUILD-git b/build-aux/PKGBUILD-git index 27f7ddc3..76ae13dc 100644 --- a/build-aux/PKGBUILD-git +++ b/build-aux/PKGBUILD-git @@ -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' diff --git a/build-aux/fedora-git.spec b/build-aux/fedora-git.spec index b34e1c11..bfdd5d1c 100644 --- a/build-aux/fedora-git.spec +++ b/build-aux/fedora-git.spec @@ -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 diff --git a/build-aux/fedora.spec b/build-aux/fedora.spec index c7ca4e91..4b52e2bc 100644 --- a/build-aux/fedora.spec +++ b/build-aux/fedora.spec @@ -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 diff --git a/build-aux/lib/libportprotonqt_gamepad.so b/build-aux/lib/libportprotonqt_gamepad.so new file mode 100755 index 0000000000000000000000000000000000000000..940282bea1eba064fe6caa44daa6dbddc8329732 GIT binary patch literal 17376 zcmeHPeQ;Y>mA`W0#BJi(q;47-nus(hNk4@1RoX(S5=XYOIKfS9rgSKZY{{`&ThdBT zY1|>*Or5rPy%_>ib~8hF2!WaA4=5cb!*t45(gI0KnHna*&UV=`ox(~afdtZyccBr# zbMHCtJ>TjzJ3Bl4gRVz9_x#SsJ@>q;t9SM8^*_YAI~zhFC1sZSu<|&iqAUo1NHhq$ zP0hpmGPP9e&h|uas~wzgAfc+&@YF^lRhhTxxj9BpsL07qw-+{YWzneygA4WBk;|!D zSbpbB`YhWWXDW8YlHD}hO|zZQNluo~CgWDeNO2xze>PpgdO~%dfDxtF;pIlbrcnbc zq0-)tUpuM)i(L=ftq2>(Hr>zd38glu+zmUvLfY6Vn)mEyc0awY|2MC6QwR(2T8tOjQG84Hh1*o&v<{WYBoMR_<_mFY0s&MxWdHgFAlp!G)!JcMqP+uURlNp!WTBb^!Xn* zwT`ymw(ysGdp`TMCHF0A`TH*~WH&X*yAV(vzQ8lsH?jfZ)RNL zREfl{kz6)Wbdm)pkx+>(y;~EhbRoSfQ*_dW-mM+Oxoo;O**}~%vf7fwz@B7cFq2IV zXC6wc9hHJLfQI3&m=>J$cBGwHp^z(RX;<1|S?_2*-I*(F z*{t%pf|D=g90a+`_@jnW>@A%vc;;KO8D~eSf5#|7AJGaurICDWcRK47HQSd@B^_zr zKwguEA1tKNQ_9D{1Brp52NHwH%y6wBcBP#}+8?=zqei#`)aP0*26EYKdcaAW&T!8= zT(E|#lI}n)UldM%$w8W|Ol~q@4QFJM+>^2Mu#unu^(?M{<$oJN0qU77@|Yb+Co-uZ z6$cl(maI5ba#FbmO>gcl`7i8nrXM3`U8IF-|+vP*Bs+%BrhFevJ+SHu^7UOijzHY< zZ$6x-1}&TN;n%p@s6-7j7X(v^TbiP#N;r2(1Fb%s@-Ag@)YM2{?sW(WN9kQD!u8FB z=9F3$H5J0?C8a%TYJ^Gd2)Qjv??xAic1tvBs-%CViz*fO;q!cWj}PZtY%RUVhts`~ zl$}viBmXS|f(}IuL%7V!GXuSSb&pSTQE`%N$CAgUW)0$g4B zGTvL3MHT4}k)-lkwOSoKsCk-jD?j!42I8Ohc$#1<-|%>vSSv?7p1u$&pYwQ{U@MP! zJWVQPzwQ^YIhEyDNTTdR9F2!}w1wIc;$8ORU$( zK~r_gv@7;Bp7-6k7q+T&Q(t`S&gW=l7a#w%Gas(Md!th2)wr!(_|XE@K&`t{yd@MJ@bjY z_<}#ERD5Fg%2m2^vFSC(%SWL8JrzbIpGIx$d}#l1=Stm5<;xUbKN>AxhqsflDe$-? zu_+4hC1}5`PtHVa>e1t+#b{Rd;teR3qj)o6_r*`f%H-r^>~$h%R!+oTj~|L3XnT}c zZ55iFP;h^Y`ad2kkHuaO!RhU>*Z0mvipt7)S)3oD2qt1>DO`n;I^j?E0Vy0?*d@pOQCaw5{9xp9YM`RmKv(uD>m4B{Q&x8&J zxwt{hFyyY4I?NWp3~8SGEX|;28g+8Urb3>Z@>ij8@IL>AYo*A>OBYQS^o2FS$#}Yk z8<^<3IR0}A?$DpA)s}A__q<|EUe*gfCA!zHm(Rh=3>RSpQZ|=@qCom7MWri{&Qq9? z&(ILoAVO$FH)`emi%|4Ab#s0NwLTGT;+*&rV5`i3n~E94vk)#PPJh3--)JZP86 zdIstlsAr&_fqDk&8Ti}HKqsCeq*Gxh7uL^0@Dw7tvtX9{9RzQ0}Z+@d%bwG=tPk@er z9s_+5^fc)FH(?K&1Z~6b2m3+k8QfE#y`b{jVd$alDzv9Hv}FF=ri0L5fi~$`?mdVr z3SKQ}?Od?X~+*i-r=`@ z$di-bXCUwL%OCdSZHV_5kn8@4|1M9y9`cir>t})@-|NYHAwTyH?K_aS;`;UZ+i&RB z4k$lQKpunqdcXW$&;K#VY!Jp+Fe8Ib)t_Wm5XU#7i6RD`F! zK~(U#$rN5JH8VTKn>A=3Efv`xM0;wf(4$!@*BYW^pAfAVsEGWHOVu3lliVKN-%~ln zn`>l`O%LDb(<5CfXIWqN!YnqhyB;X>{vcLUr?_KntVfTMsmR`;Q|wsqF!K`UQKPP8 zua2~br&1<<7T}4W=A~UMCpEvH<^RpRtXtUaUyaCqb-TfKzP?eWJxq5p%`@G{^dQs2 zOrK$ToarRfDW>O{D&9oY$~4ThooSS557VxWjyuAuu;N=CUb7*xHnJwXwtelb?d#X9 z3$NPP-5cJKDURlcN=265k{ou{u14qRy6Gly-Mcof2(3%5QHOb>(#LsYQY{@B*Fx<) z2~Wdmwz^OiUMPKD8nAy>+7f&L3Rfopg+|QGd zDs?|9)UJHa1>ddpuW~=1g41sqwc>vYIQfwr>EArGkJ|e5+eQc%_0V!utWwMW^U!I) z?tDAHr+|ml)k^jWimRV#e37!p?SE>8R<$B5nucBg31R2IMu?FwfeOr0FMOVodI}C<8R-O z|2X6Je)%slZtsi#R)GIk0`PO}$KEf0C4T&>)!!R5-mL8X{+oc;%G)Ob{Oo2w_IZLQ zfG>APnXfWXaEeZ8a4<5UyzTi3XC#5$<=J!*Th>###IE67e{wjHa&m=YB3as_267|$ z;WT!bV^efZ4cbeeNhAw}X?%w88n8bsbv-E zFBVkZ~(c5&D1Oqh`=0_PGE$ZGtSk*pV+P|BRQv|FV7(D!z6!$wi)MeF+6?SC8U z{`uqZa$_$vYFPK(wtoc@T7%2@mfx%7{JE_!ywF!++v2mA-?PfJLmWl)4CnEJ{}%+* zMQLBwx#urqFaBr`0DX4EUe?3%{|My&4oKdmT^UD@z}DZsoWncye$ztO5cgf|g_`xV z*S@Ty%WVIQYiTNCCv*-Pr@F*1>+?hHGxf!dH5}AD0EDr+s420()kRE2?1i?WLgPou z;hm;7$qwTpU@CbOnEc4t7mVoQHHKToKU`i1_G-MO-!gyFbS-^17Un(5fq&g>@|Nai6aie8mvFb0#-wY1` literal 0 HcmV?d00001 diff --git a/debian/control b/debian/control index 954ae269..b8ca1568 100644 --- a/debian/control +++ b/debian/control @@ -5,6 +5,7 @@ Maintainer: Ivan Mazhukin Build-Depends: debhelper-compat (= 13), gettext, + libsdl3-dev, libvulkan-dev, meson, ninja-build, diff --git a/debian/portprotonqt.install b/debian/portprotonqt.install index 7e9897b2..b5bbbaa6 100644 --- a/debian/portprotonqt.install +++ b/debian/portprotonqt.install @@ -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 diff --git a/debian/rules b/debian/rules index 64867107..98096240 100755 --- a/debian/rules +++ b/debian/rules @@ -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 diff --git a/meson.build b/meson.build index 6290daac..d3439df7 100644 --- a/meson.build +++ b/meson.build @@ -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') diff --git a/portprotonqt/_version.py.in b/portprotonqt/_version.py.in deleted file mode 100644 index ab6dfa4b..00000000 --- a/portprotonqt/_version.py.in +++ /dev/null @@ -1,2 +0,0 @@ -APP_VERSION = @APP_VERSION@ -APP_COMMIT = @APP_COMMIT@ diff --git a/portprotonqt/app.py b/portprotonqt/app.py index 453b45b2..53fe9aae 100644 --- a/portprotonqt/app.py +++ b/portprotonqt/app.py @@ -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(): diff --git a/portprotonqt/dialogs/file_explorer.py b/portprotonqt/dialogs/file_explorer.py index 2b94303d..7660beaa 100644 --- a/portprotonqt/dialogs/file_explorer.py +++ b/portprotonqt/dialogs/file_explorer.py @@ -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): diff --git a/portprotonqt/dialogs/proton_manager.py b/portprotonqt/dialogs/proton_manager.py index 96cc5ec3..efd0d5e4 100644 --- a/portprotonqt/dialogs/proton_manager.py +++ b/portprotonqt/dialogs/proton_manager.py @@ -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 diff --git a/portprotonqt/dialogs/settings_dialog.py b/portprotonqt/dialogs/settings_dialog.py index 1d07bb68..25d1bc60 100644 --- a/portprotonqt/dialogs/settings_dialog.py +++ b/portprotonqt/dialogs/settings_dialog.py @@ -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() diff --git a/portprotonqt/dialogs/winetricks_dialog.py b/portprotonqt/dialogs/winetricks_dialog.py index f3ba62cc..1bcc619d 100644 --- a/portprotonqt/dialogs/winetricks_dialog.py +++ b/portprotonqt/dialogs/winetricks_dialog.py @@ -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): diff --git a/portprotonqt/gamepad_backend.c b/portprotonqt/gamepad_backend.c new file mode 100644 index 00000000..cf2a28eb --- /dev/null +++ b/portprotonqt/gamepad_backend.c @@ -0,0 +1,122 @@ +#include +#include +#include +#include + +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); +} diff --git a/portprotonqt/gamepad_backend.py b/portprotonqt/gamepad_backend.py deleted file mode 100644 index ead4053d..00000000 --- a/portprotonqt/gamepad_backend.py +++ /dev/null @@ -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) diff --git a/portprotonqt/input_manager.py b/portprotonqt/input_manager.py deleted file mode 100644 index ed0743f0..00000000 --- a/portprotonqt/input_manager.py +++ /dev/null @@ -1,4408 +0,0 @@ -import time -import threading -import os -import math -from typing import Protocol, cast, Any -from evdev import UInput, ecodes -from shiboken6 import isValid -from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QAbstractScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView, QSlider, QCheckBox, QPushButton -from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer, QThread -from PySide6.QtGui import QKeySequence -from portprotonqt.logger import get_logger -from portprotonqt.sound_manager import SoundManager -from portprotonqt.image_utils import FullscreenDialog -from portprotonqt.custom_widgets import NavLabel, AutoSizeButton -from portprotonqt.game_card import GameCard -from portprotonqt.config import display_config, gamepad_config, window_config -from portprotonqt.dialogs import AddGameDialog -from portprotonqt.gamepad_backend import ( - GamepadType, - SDLGamepad, - SDL_GAMEPAD_AXIS_LEFTX, - SDL_GAMEPAD_AXIS_LEFTY, - SDL_GAMEPAD_AXIS_RIGHTX, - SDL_GAMEPAD_AXIS_RIGHTY, - SDL_GAMEPAD_AXIS_LEFT_TRIGGER, - SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, - SDL_GAMEPAD_BUTTON_BACK, - SDL_GAMEPAD_BUTTON_DPAD_DOWN, - SDL_GAMEPAD_BUTTON_DPAD_LEFT, - SDL_GAMEPAD_BUTTON_DPAD_RIGHT, - SDL_GAMEPAD_BUTTON_DPAD_UP, - SDL_GAMEPAD_BUTTON_EAST, - SDL_GAMEPAD_BUTTON_GUIDE, - SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, - SDL_GAMEPAD_BUTTON_NORTH, - SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, - SDL_GAMEPAD_BUTTON_SOUTH, - SDL_GAMEPAD_BUTTON_START, - SDL_GAMEPAD_BUTTON_WEST, - detect_gamepad_type, - find_gamepad, - shutdown as shutdown_gamepad_backend, -) - -from portprotonqt.virtual_keyboard import VirtualKeyboard - -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 - -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 - -BUTTONS = { - 'confirm': {PAD_BUTTON_SOUTH}, # A (Xbox) / Cross (PS) / B (Switch) - 'back': {PAD_BUTTON_EAST}, # B (Xbox) / Circle (PS) / A (Switch) - 'add_game': {PAD_BUTTON_WEST}, # X (Xbox) / Square (PS) / Y (Switch) - 'prev_dir': {PAD_BUTTON_NORTH}, # Y (Xbox) / Triangle (PS) / X (Switch) - '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, -} - -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(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.gamepad_thread: threading.Thread | 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 = None - self.settings_dialog = None - self.file_explorer = None - self.proton_manager_dialog = None - self.original_button_handler = None - self.original_dpad_handler = None - self.original_gamepad_state = None - self._original_handlers_saved = False - 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 - - # 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.original_button_handler = None - self.original_dpad_handler = None - self.original_gamepad_state = 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 - - # 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: - if self.original_button_handler: - self.original_button_handler(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 - elif self.original_dpad_handler: - self.original_dpad_handler(code, value, current_time) - - except Exception as e: - logger.error(f"Error in FileExplorer dpad handler: {e}") - - # 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(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): - """Common method to setup mode handlers""" - # Save original handlers if not already saved - if not hasattr(self, '_original_handlers_saved') or not self._original_handlers_saved: - self.original_button_handler = self.handle_button_slot - self.original_dpad_handler = self.handle_dpad_slot - self.original_gamepad_state = self._gamepad_handling_enabled - self._original_handlers_saved = True - - # Set the dialog instance - if dialog_attr_name == 'winetricks_dialog': - self.winetricks_dialog = dialog_instance - elif dialog_attr_name == 'settings_dialog': - self.settings_dialog = dialog_instance - elif dialog_attr_name == 'file_explorer': - self.file_explorer = dialog_instance - elif dialog_attr_name == 'proton_manager_dialog': - self.proton_manager_dialog = dialog_instance - elif dialog_attr_name == 'appimage_update_dialog': - self.appimage_update_dialog = dialog_instance - - # Set new handlers - self.handle_button_slot = button_handler - self.handle_dpad_slot = dpad_handler - self._gamepad_handling_enabled = True - - # Reset dpad timer - self.dpad_timer.stop() - self.current_dpad_code = None - self.current_dpad_value = 0 - - def _restore_original_handlers(self, dialog_attr_name): - """Common method to restore original handlers""" - if self.original_button_handler is None or self.original_dpad_handler is None or self.original_gamepad_state is None: - logger.warning("Cannot restore original handlers: handlers not saved") - return - - # Restore original handlers - self.handle_button_slot = self.original_button_handler - self.handle_dpad_slot = self.original_dpad_handler - self._gamepad_handling_enabled = self.original_gamepad_state - - # Reset dpad timer - self.dpad_timer.stop() - self.current_dpad_code = None - self.current_dpad_value = 0 - - # Clear the dialog reference - if dialog_attr_name == 'winetricks_dialog': - self.winetricks_dialog = None - elif dialog_attr_name == 'settings_dialog': - self.settings_dialog = None - elif dialog_attr_name == 'file_explorer': - self.file_explorer = None - elif dialog_attr_name == 'proton_manager_dialog': - self.proton_manager_dialog = None - elif dialog_attr_name == 'appimage_update_dialog': - self.appimage_update_dialog = None - - # Reset the flag so original handlers can be saved again on next enable - if hasattr(self, '_original_handlers_saved'): - self._original_handlers_saved = False - - # 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}") - - # 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]) - - 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 - - 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_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.guide_held = False - self.in_guide_combination_attempt = False - self.guide_pressed_time = 0 - self.select_pressed_time = 0 - - - @Slot(int, int) - def handle_button_slot(self, button_code: int, value: int) -> None: - # Handle common UI elements like QMessageBox, QMenu, etc. FIRST - # This ensures that any active dialogs are handled before main window logic - # Only handle press events (value=1), ignore release events (value=0) - if value == 1 and self._handle_common_ui_elements(button_code): - return - - active_window = QApplication.activeWindow() - - # Handle virtual keyboard in AddGameDialog (handle both press and release) - if isinstance(active_window, AddGameDialog): - focused = QApplication.focusWidget() - if button_code in BUTTONS['confirm'] and value == 1 and isinstance(focused, QLineEdit): - # Show keyboard on A press on input field (only on press) - active_window.show_keyboard_for_widget(focused) - return - - # If keyboard visible, handle its buttons (including release) - if hasattr(active_window, 'keyboard') and active_window.keyboard.isVisible(): - self.handle_virtual_keyboard(button_code, value) - return - - # Active dialog keyboard handling (including release) - 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 - - # Main window keyboard handling (including release) - keyboard = getattr(self._parent, 'keyboard', None) - if keyboard and keyboard.isVisible(): - self.handle_virtual_keyboard(button_code, value) - return - - # Ignore releases for all other (non-keyboard) button handling - 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 - - current_tab_index = self._parent.stackedWidget.currentIndex() - - if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit): - if isinstance(active, AddGameDialog): - keyboard = getattr(active, 'keyboard', None) - else: - keyboard = getattr(self._parent, 'keyboard', None) - if keyboard: - keyboard.show_for_widget(focused) - return - - # Y on editable combo opens keyboard for combo input - if button_code in BUTTONS['prev_dir']: - focused_combo = 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(): - line_edit = focused_combo.lineEdit() - keyboard = getattr(self._parent, 'keyboard', None) - if line_edit and keyboard: - line_edit.setFocus() - keyboard.show_for_widget(line_edit) - return - - # Handle Y button to focus search - if button_code in BUTTONS['prev_dir']: # Y button - search_edit = None - if current_tab_index == 0: - search_edit = getattr(self._parent, 'searchEdit', None) - elif current_tab_index == 1: - search_edit = getattr(self._parent, 'autoInstallSearchLineEdit', None) - if search_edit: - search_edit.setFocus() - return - - # Guide + Select combination for refreshing game grid - if value == 1: - current_time = time.time() - - if button_code in BUTTONS['guide']: - self.guide_held = True - self.guide_pressed_time = current_time - self.in_guide_combination_attempt = True - if hasattr(self, 'guide_timer'): - self.guide_timer.start(int(self.guide_combination_timeout * 1000)) - return - elif button_code in BUTTONS['menu'] and hasattr(self, 'guide_held') and self.guide_held: - self.select_pressed_time = current_time - time_since_guide = current_time - self.guide_pressed_time - if time_since_guide <= self.guide_combination_timeout: - if hasattr(self, 'guide_timer'): - self.guide_timer.stop() - logger.debug("Guide + Select combination detected, refreshing game grid") - self._parent.refreshGames() - self.guide_held = False - self.in_guide_combination_attempt = False - self.guide_pressed_time = 0 - self.select_pressed_time = 0 - return - else: - self.in_guide_combination_attempt = False - self.guide_held = False - self.guide_pressed_time = 0 - self.select_pressed_time = 0 - - # Handle QComboBox - if isinstance(focused, QComboBox): - if button_code in BUTTONS['confirm']: - focused.showPopup() - return - if button_code in BUTTONS['back'] and focused.view().isVisible(): - focused.hidePopup() - return - - # Handle QListView - if isinstance(focused, QListView): - combo = None - parent = focused.parentWidget() - while parent: - if isinstance(parent, QComboBox): - combo = parent - break - parent = parent.parentWidget() - - if button_code in BUTTONS['confirm']: - idx = focused.currentIndex() - if idx.isValid(): - if combo: - combo.setCurrentIndex(idx.row()) - combo.hidePopup() - combo.setFocus(Qt.FocusReason.OtherFocusReason) - else: - focused.activated.emit(idx) - focused.clicked.emit(idx) - focused.hide() - return - - if button_code in BUTTONS['back']: - if combo: - combo.hidePopup() - combo.setFocus(Qt.FocusReason.OtherFocusReason) - else: - focused.clearSelection() - focused.hide() - - # Close AddGameDialog on B button - if button_code in BUTTONS['back'] and isinstance(active, QDialog): - active.reject() - return - - # FullscreenDialog - if isinstance(active, FullscreenDialog): - 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 - - # Context menu for GameCard - if button_code in BUTTONS['context_menu'] and isinstance(focused, QTableWidget): - current_row = focused.currentRow() - current_col = focused.currentColumn() - if current_col < 0: - current_col = 0 - if current_row >= 0: - item = focused.item(current_row, current_col) - if item is None: - item = focused.item(current_row, 0) - if item is not None: - point = focused.visualItemRect(item).center() - else: - point = focused.viewport().rect().center() - else: - point = focused.viewport().rect().center() - focused.customContextMenuRequested.emit(point) - return - - if button_code in BUTTONS['context_menu'] and isinstance(focused, QWidget): - if focused.contextMenuPolicy() == Qt.ContextMenuPolicy.CustomContextMenu: - focused.customContextMenuRequested.emit(focused.rect().center()) - return - - if isinstance(focused, GameCard): - if button_code in BUTTONS['context_menu']: - pos = QPoint(focused.width() // 2, focused.height() // 2) - menu = focused._show_context_menu(pos) - if menu: - menu.setFocus(Qt.FocusReason.OtherFocusReason) - return - - if isinstance(focused, QTableWidget): - system_action = getattr(self._parent, "handleSystemTableGamepadAction", None) - if callable(system_action): - if button_code in BUTTONS['confirm'] and system_action(focused, "confirm"): - SoundManager().play("click") - return - if button_code in BUTTONS['back'] and system_action(focused, "back"): - SoundManager().play("back") - return - if button_code in BUTTONS['prev_dir'] and system_action(focused, "prev_dir"): - SoundManager().play("click") - return - if button_code in BUTTONS['add_game'] and system_action(focused, "add_game"): - SoundManager().play("click") - return - - system_quick_action = getattr(self._parent, "handleSystemGamepadAction", None) - if ( - callable(system_quick_action) - and button_code in BUTTONS['add_game'] - and system_quick_action("add_game") - ): - SoundManager().play("open") - return - - # Standard navigation - if button_code in BUTTONS['confirm']: - if not isinstance(focused, GameCard): - SoundManager().play_widget_sound(focused) - self._parent.activateFocusedWidget() - elif button_code in BUTTONS['back']: - SoundManager().play("back") - if self._is_theme_store_visible(): - 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: - _show_list = getattr(self._parent, '_show_theme_store_list', None) - if callable(_show_list): - _show_list() - return - self._parent.goBackDetailPage(getattr(self._parent, 'currentDetailPage', None)) - 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']: - idx = self._parent.stackedWidget.currentIndex() - - # Get only visible tab indices - visible_tab_indices = [] - if hasattr(self._parent, 'tabButtons'): - for i, btn in self._parent.tabButtons.items(): - if btn.isVisible(): - visible_tab_indices.append(i) - visible_tab_indices.sort() # Ensure they're in order - - if visible_tab_indices: - # Find current position in the visible tabs list - try: - current_visible_pos = visible_tab_indices.index(idx) - except ValueError: - # Current index is not visible, default to first visible - current_visible_pos = 0 - - new_visible_pos = (current_visible_pos - 1) % len(visible_tab_indices) - idx = visible_tab_indices[new_visible_pos] - self._parent.switchTab(idx) - self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason) - elif button_code in BUTTONS['next_tab']: - idx = self._parent.stackedWidget.currentIndex() - - # Get only visible tab indices - visible_tab_indices = [] - if hasattr(self._parent, 'tabButtons'): - for i, btn in self._parent.tabButtons.items(): - if btn.isVisible(): - visible_tab_indices.append(i) - visible_tab_indices.sort() # Ensure they're in order - - if visible_tab_indices: - # Find current position in the visible tabs list - try: - current_visible_pos = visible_tab_indices.index(idx) - except ValueError: - # Current index is not visible, default to first visible - current_visible_pos = 0 - - new_visible_pos = (current_visible_pos + 1) % len(visible_tab_indices) - idx = visible_tab_indices[new_visible_pos] - self._parent.switchTab(idx) - self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason) - elif button_code in BUTTONS['increase_size'] and value > 0: - current_tab = self._parent.stackedWidget.currentIndex() - system_tab_index = getattr(self._parent, "system_tab_index", -1) - if current_tab == system_tab_index: - section_stack = getattr(self._parent, "systemSectionStack", None) - volume_slider = getattr(self._parent, "audioVolumeSlider", None) - apply_volume = getattr(self._parent, "_applySelectedAudioVolume", None) - if ( - isinstance(section_stack, QStackedWidget) - and section_stack.currentIndex() == 4 - and isinstance(volume_slider, QSlider) - ): - new_value = min(volume_slider.value() + 5, volume_slider.maximum()) - volume_slider.setValue(new_value) - if callable(apply_volume): - apply_volume() - return - if current_tab == 0: # Main games library - if hasattr(self._parent, 'game_library_manager') and self._parent.game_library_manager: - size_slider = getattr(self._parent.game_library_manager, 'sizeSlider', None) - if size_slider: - new_value = min(size_slider.value() + 10, size_slider.maximum()) - size_slider.setValue(new_value) - self._parent.on_slider_released() - elif current_tab == 1: # Auto-install tab - auto_size_slider = getattr(self._parent, 'auto_size_slider', None) - if auto_size_slider: - new_value = min(auto_size_slider.value() + 10, auto_size_slider.maximum()) - auto_size_slider.setValue(new_value) - if hasattr(self._parent, 'on_auto_slider_released'): - self._parent.on_auto_slider_released() - elif current_tab == getattr(self._parent, "theme_tab_index", -1): - theme_slider = getattr(self._parent, 'themeStoreSizeSlider', None) - if theme_slider: - new_value = min(theme_slider.value() + 10, theme_slider.maximum()) - theme_slider.setValue(new_value) - if hasattr(self._parent, '_on_theme_store_slider_released'): - self._parent._on_theme_store_slider_released() # type: ignore[union-attr] - elif button_code in BUTTONS['decrease_size'] and value > 0: - current_tab = self._parent.stackedWidget.currentIndex() - system_tab_index = getattr(self._parent, "system_tab_index", -1) - if current_tab == system_tab_index: - section_stack = getattr(self._parent, "systemSectionStack", None) - volume_slider = getattr(self._parent, "audioVolumeSlider", None) - apply_volume = getattr(self._parent, "_applySelectedAudioVolume", None) - if ( - isinstance(section_stack, QStackedWidget) - and section_stack.currentIndex() == 4 - and isinstance(volume_slider, QSlider) - ): - new_value = max(volume_slider.value() - 5, volume_slider.minimum()) - volume_slider.setValue(new_value) - if callable(apply_volume): - apply_volume() - return - if current_tab == 0: # Main games library - if hasattr(self._parent, 'game_library_manager') and self._parent.game_library_manager: - size_slider = getattr(self._parent.game_library_manager, 'sizeSlider', None) - if size_slider: - new_value = max(size_slider.value() - 10, size_slider.minimum()) - size_slider.setValue(new_value) - self._parent.on_slider_released() - elif current_tab == 1: # Auto-install tab - auto_size_slider = getattr(self._parent, 'auto_size_slider', None) - if auto_size_slider: - new_value = max(auto_size_slider.value() - 10, auto_size_slider.minimum()) - auto_size_slider.setValue(new_value) - if hasattr(self._parent, 'on_auto_slider_released'): - self._parent.on_auto_slider_released() - elif current_tab == getattr(self._parent, "theme_tab_index", -1): - theme_slider = getattr(self._parent, 'themeStoreSizeSlider', None) - if theme_slider: - new_value = max(theme_slider.value() - 10, theme_slider.minimum()) - theme_slider.setValue(new_value) - if hasattr(self._parent, '_on_theme_store_slider_released'): - self._parent._on_theme_store_slider_released() # type: ignore[union-attr] - except Exception as e: - logger.error(f"Error in handle_button_slot: {e}", exc_info=True) - - 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() - - @Slot(int, int, float) - def handle_dpad_slot(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) - - # Handle release early - if value == 0: - 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() - return - - # Update D-pad state for continuous movement - is_initial_press = not self.axis_moving - self.current_dpad_code = code - self.current_dpad_value = value - if not self.axis_moving: - 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)) - - if keyboard and keyboard.isVisible(): - # Handle horizontal movement (LEFT/RIGHT) - if code in (PAD_DPAD_X, PAD_AXIS_LEFT_X): - normalized_value = 0 - if code == PAD_AXIS_LEFT_X: # Left stick - # Apply deadzone - if abs(value) < self.dead_zone: - self.current_dpad_code = None - self.current_dpad_value = 0 - self.axis_moving = False - self.dpad_timer.stop() - return - normalized_value = 1 if value > self.dead_zone else -1 - else: # D-pad - normalized_value = value # D-pad already gives -1, 0, 1 - - if normalized_value != 0: - if normalized_value > 0: # Right - keyboard.move_focus_right() - elif normalized_value < 0: # Left - keyboard.move_focus_left() - return - - # Handle vertical movement (UP/DOWN) - elif code in (PAD_DPAD_Y, PAD_AXIS_LEFT_Y): - normalized_value = 0 - if code == PAD_AXIS_LEFT_Y: # Left stick - # Apply deadzone - if abs(value) < self.dead_zone: - self.current_dpad_code = None - self.current_dpad_value = 0 - self.axis_moving = False - self.dpad_timer.stop() - return - normalized_value = 1 if value > self.dead_zone else -1 - else: # D-pad - normalized_value = value # D-pad already gives -1, 0, 1 - - if normalized_value != 0: - if normalized_value > 0: # Down - keyboard.move_focus_down() - elif normalized_value < 0: # Up - keyboard.move_focus_up() - 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: - - 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") - - # Handle QMessageBox navigation with D-pad (for multiple buttons) - if isinstance(active, QMessageBox) and not isinstance(focused, QTableWidget): - if not focused or not active.focusWidget(): - # If no widget is focused, focus the first focusable widget - focusables = active.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: # Horizontal navigation - if value > 0: # Right - active.focusNextChild() - elif value < 0: # Left - active.focusPreviousChild() - elif code == PAD_DPAD_Y and value != 0: # Vertical navigation - if value > 0: # Down - active.focusNextChild() - elif value < 0: # Up - active.focusPreviousChild() - return - # Handle horizontal navigation between AddGameDialog shortcut checkboxes - if isinstance(active, AddGameDialog) and code in (PAD_DPAD_X, PAD_AXIS_LEFT_X) and value != 0: - 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 - checkbox_row = [ - active.add_to_steam_checkbox, - active.add_to_menu_checkbox, - active.add_to_desktop_checkbox, - ] - focused_checkbox = focused if isinstance(focused, QCheckBox) else None - if focused_checkbox and focused_checkbox in checkbox_row: - current_index = checkbox_row.index(focused_checkbox) - if normalized_value < 0 and current_index > 0: - checkbox_row[current_index - 1].setFocus(Qt.FocusReason.OtherFocusReason) - return - if normalized_value > 0 and current_index < len(checkbox_row) - 1: - checkbox_row[current_index + 1].setFocus(Qt.FocusReason.OtherFocusReason) - return - - # Handle AddGameDialog or other QDialog navigation with D-pad - elif isinstance(active, QDialog) and code == PAD_DPAD_X and value != 0: - if not focused or not active.focusWidget(): - # If no widget is focused, focus the first focusable widget - focusables = active.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 value > 0: # Right - active.focusNextChild() - elif value < 0: # Left - active.focusPreviousChild() - return - elif isinstance(active, QDialog) and code == PAD_DPAD_Y and value != 0 and not isinstance(focused, QTableWidget): # Keep up/down for other dialogs - if not focused or not active.focusWidget(): - # If no widget is focused, focus the first focusable widget - focusables = active.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 value > 0: # Down - active.focusNextChild() - elif value < 0: # Up - active.focusPreviousChild() - return - - # Handle QMenu navigation with D-pad - if isinstance(popup, QMenu): - if code == PAD_DPAD_Y and value != 0: - self._navigate_menu_actions(popup, direction_down=value > 0) - return - return - - # Handle QListView navigation with D-pad - if isinstance(focused, QListView) and code == PAD_DPAD_Y and value != 0: - model = focused.model() - current_index = focused.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) - focused.setCurrentIndex(model.index(next_row, current_index.column())) - elif value < 0: # Up - prev_row = max(current_row - 1, 0) - focused.setCurrentIndex(model.index(prev_row, current_index.column())) - focused.scrollTo(focused.currentIndex(), QListView.ScrollHint.PositionAtCenter) - return - - # Fullscreen horizontal navigation - if isinstance(active, FullscreenDialog) and code == PAD_DPAD_X: - if value < 0: - active.show_prev() - elif value > 0: - active.show_next() - return - - - # Table navigation using generalized methods - if code == PAD_DPAD_X and value != 0: - system_tab_index = getattr(self._parent, "system_tab_index", -1) - if self._parent.stackedWidget.currentIndex() == system_tab_index: - switch_relative = getattr(self._parent, "switchSystemSectionRelative", None) - if callable(switch_relative) and is_initial_press: - switched = switch_relative(1 if value > 0 else -1) - if switched: - section_stack = getattr(self._parent, "systemSectionStack", None) - section_buttons = getattr(self._parent, "systemSectionButtons", []) - if section_stack is not None and section_buttons: - current_index = section_stack.currentIndex() - if 0 <= current_index < len(section_buttons): - section_buttons[current_index].setFocus(Qt.FocusReason.OtherFocusReason) - return - - # Table navigation using generalized methods - if isinstance(focused, QTableWidget): - self.handle_table_navigation(focused, code, value) - return - - # Search focus logic for tabs 0 and 1 - if code == PAD_DPAD_Y and value < 0: - focused = QApplication.focusWidget() - current_index = self._parent.stackedWidget.currentIndex() - if current_index in (0, 1) and isinstance(focused, GameCard): - 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 and focus_target: - game_cards = [ - card for card in container.findChildren(GameCard) - if card.isVisible() and card.isEnabled() - ] - if game_cards: - current_card_pos = focused.pos() - current_row_y = current_card_pos.y() - is_first_row = True - for card in game_cards: - if card.pos().y() < current_row_y: - is_first_row = False - break - if is_first_row: - focus_target.setFocus(Qt.FocusReason.OtherFocusReason) - return - - # Game cards navigation for tabs 0 and 1 - if code in (PAD_DPAD_X, PAD_DPAD_Y): - current_index = self._parent.stackedWidget.currentIndex() - if current_index in (0, 1): - if self._handle_toolbar_navigation(code, value): - return - if code == PAD_DPAD_Y and value < 0 and self._focus_tab_from_search(current_index): - return - container = self._parent.gamesListWidget if current_index == 0 else self._parent.autoInstallContainer - if container is None: - return - self._navigate_game_cards(container, current_index, code, value) - return - - if code in (PAD_DPAD_X, PAD_DPAD_Y) and value != 0 and self._handle_theme_tab_navigation(code, value): - return - - # System tab section buttons: do not cycle tabs on Up/Down. - if code == PAD_DPAD_Y and value != 0: - system_tab_index = getattr(self._parent, "system_tab_index", -1) - if self._parent.stackedWidget.currentIndex() == system_tab_index: - focused = QApplication.focusWidget() - section_buttons = getattr(self._parent, "systemSectionButtons", []) - if focused in section_buttons: - if value > 0: - section_stack = getattr(self._parent, "systemSectionStack", None) - focus_targets = getattr(self._parent, "systemSectionFocusTargets", []) - if section_stack is not None: - 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 - - # Button navigation on detail pages (horizontal layout) - if code in (PAD_DPAD_X, PAD_DPAD_Y, PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y): - focused = QApplication.focusWidget() - page = self._parent.stackedWidget.currentWidget() - current_detail_page = getattr(self._parent, "currentDetailPage", None) - - normalized_code = code - 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 > self.dead_zone else -1 - normalized_code = PAD_DPAD_X if code == PAD_AXIS_LEFT_X else PAD_DPAD_Y - - # Check if we're on a detail page and focused widget is a button - if ( - isinstance(focused, AutoSizeButton) - and page is not None - and current_detail_page is not None - and page == current_detail_page - and normalized_value != 0 - ): - parent_widget = focused.parentWidget() - if parent_widget: - buttons = parent_widget.findChildren( - AutoSizeButton, - options=Qt.FindChildOption.FindDirectChildrenOnly, - ) - buttons = [btn for btn in buttons if btn.isVisible() and btn.isEnabled()] - if len(buttons) > 1 and focused in buttons: - centers = { - btn: ( - btn.geometry().x() + btn.geometry().width() // 2, - btn.geometry().y() + btn.geometry().height() // 2, - ) - for btn in buttons - } - sorted_by_yx = sorted( - buttons, - key=lambda btn: (centers[btn][1], centers[btn][0]), - ) - row_tolerance = 24 - rows = [] - for btn in sorted_by_yx: - if not rows: - rows.append([btn]) - continue - last_row = rows[-1] - last_row_y = centers[last_row[0]][1] - if abs(centers[btn][1] - last_row_y) <= row_tolerance: - last_row.append(btn) - else: - rows.append([btn]) - for row in rows: - row.sort(key=lambda btn: centers[btn][0]) - - current_row_idx = -1 - current_col_idx = -1 - for row_idx, row in enumerate(rows): - if focused in row: - current_row_idx = row_idx - current_col_idx = row.index(focused) - break - if current_row_idx == -1: - return - - target = None - if normalized_code == PAD_DPAD_X and normalized_value > 0: - if current_col_idx < len(rows[current_row_idx]) - 1: - target = rows[current_row_idx][current_col_idx + 1] - elif current_row_idx < len(rows) - 1: - target = rows[current_row_idx + 1][0] - elif normalized_code == PAD_DPAD_X and normalized_value < 0: - if current_col_idx > 0: - target = rows[current_row_idx][current_col_idx - 1] - elif current_row_idx > 0: - target = rows[current_row_idx - 1][-1] - elif normalized_code == PAD_DPAD_Y and normalized_value > 0: - if current_row_idx < len(rows) - 1: - next_row = rows[current_row_idx + 1] - current_x = centers[focused][0] - target = min(next_row, key=lambda btn: abs(centers[btn][0] - current_x)) - elif normalized_code == PAD_DPAD_Y and normalized_value < 0: - if current_row_idx > 0: - prev_row = rows[current_row_idx - 1] - current_x = centers[focused][0] - target = min(prev_row, key=lambda btn: abs(centers[btn][0] - current_x)) - - if target: - target.setFocus(Qt.FocusReason.OtherFocusReason) - scroll_area = target.parentWidget() - while scroll_area and not isinstance(scroll_area, QScrollArea): - scroll_area = scroll_area.parentWidget() - if isinstance(scroll_area, QScrollArea): - scroll_area.ensureWidgetVisible(target, 20, 20) - return - - # Vertical navigation in other tabs - if code == PAD_DPAD_Y and value != 0: - focused = QApplication.focusWidget() - page = self._parent.stackedWidget.currentWidget() - if value > 0: # Down - if isinstance(focused, NavLabel): - focusables = page.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively) - focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus] - if focusables: - focusables[0].setFocus() - return - elif focused: - focused.focusNextChild() - return - elif value < 0 and focused: # Up - focused.focusPreviousChild() - return - - except Exception as e: - logger.error(f"Error in handle_dpad_slot: {e}", exc_info=True) - - 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 super().eventFilter(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 super().eventFilter(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 - - def init_gamepad(self) -> None: - self.gamepad_hotplug.connect(self._on_gamepad_hotplug) - self.gamepad_check_timer = QTimer(self) - self.gamepad_check_timer.setSingleShot(True) - self.gamepad_check_timer.timeout.connect(self.check_gamepad) - self._initial_gamepad_check = True - self.check_gamepad() - self._initial_gamepad_check = False - if not self.gamepad_thread or not self.gamepad_thread.is_alive(): - self.gamepad_thread = threading.Thread(target=self.monitor_gamepad, daemon=True) - self.gamepad_thread.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.""" - return detect_gamepad_type(gamepad) - - 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: - return find_gamepad() - 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 monitor_gamepad(self) -> None: - try: - while self.running: - current_time = time.time() - if self._gamepad_polling_suspended: - time.sleep(0.1) - continue - 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 - time.sleep(0.1) - continue - try: - active_gamepad.update() - if not active_gamepad.connected(): - self.gamepad_hotplug.emit('remove') - time.sleep(0.1) - continue - 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 - time.sleep(0.01) - except Exception as ex: - logger.error(f"Unexpected error in gamepad monitoring: {ex}") - time.sleep(0.1) - except Exception as e: - logger.error(f"Error in gamepad monitoring thread: {e}", exc_info=True) - finally: - if self.gamepad: - try: - self.gamepad.close() - except Exception as e: - logger.debug("Failed to close gamepad: %s", e) - self.gamepad = None - self._reset_input_state() - - 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_check_timer'): - self.gamepad_check_timer.stop() - self.dpad_timer.stop() - self.nav_timer.stop() - - if self.gamepad_thread: - self.gamepad_thread.join(timeout=2.0) - - 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) - - 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() diff --git a/portprotonqt/input_manager/__init__.py b/portprotonqt/input_manager/__init__.py new file mode 100644 index 00000000..ea76588a --- /dev/null +++ b/portprotonqt/input_manager/__init__.py @@ -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() diff --git a/portprotonqt/input_manager/buttons.py b/portprotonqt/input_manager/buttons.py new file mode 100644 index 00000000..05b366a3 --- /dev/null +++ b/portprotonqt/input_manager/buttons.py @@ -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 diff --git a/portprotonqt/input_manager/constants.py b/portprotonqt/input_manager/constants.py new file mode 100644 index 00000000..d66dd5e5 --- /dev/null +++ b/portprotonqt/input_manager/constants.py @@ -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, +} diff --git a/portprotonqt/input_manager/dialog_modes.py b/portprotonqt/input_manager/dialog_modes.py new file mode 100644 index 00000000..7455acf6 --- /dev/null +++ b/portprotonqt/input_manager/dialog_modes.py @@ -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}") diff --git a/portprotonqt/input_manager/dpad.py b/portprotonqt/input_manager/dpad.py new file mode 100644 index 00000000..b8dff02c --- /dev/null +++ b/portprotonqt/input_manager/dpad.py @@ -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 diff --git a/portprotonqt/input_manager/file_explorer.py b/portprotonqt/input_manager/file_explorer.py new file mode 100644 index 00000000..e4213b6b --- /dev/null +++ b/portprotonqt/input_manager/file_explorer.py @@ -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}") diff --git a/portprotonqt/input_manager/keyboard.py b/portprotonqt/input_manager/keyboard.py new file mode 100644 index 00000000..c35e1f5d --- /dev/null +++ b/portprotonqt/input_manager/keyboard.py @@ -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 diff --git a/portprotonqt/input_manager/mixin.py b/portprotonqt/input_manager/mixin.py new file mode 100644 index 00000000..70cb4846 --- /dev/null +++ b/portprotonqt/input_manager/mixin.py @@ -0,0 +1,6 @@ +from typing import TYPE_CHECKING, Any + + +class InputMixin: + if TYPE_CHECKING: + def __getattr__(self, name: str) -> Any: ... diff --git a/portprotonqt/input_manager/runtime.py b/portprotonqt/input_manager/runtime.py new file mode 100644 index 00000000..fd2a79c5 --- /dev/null +++ b/portprotonqt/input_manager/runtime.py @@ -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) diff --git a/portprotonqt/input_manager/settings.py b/portprotonqt/input_manager/settings.py new file mode 100644 index 00000000..2919b48d --- /dev/null +++ b/portprotonqt/input_manager/settings.py @@ -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]) diff --git a/portprotonqt/input_manager/settings_visual.py b/portprotonqt/input_manager/settings_visual.py new file mode 100644 index 00000000..c0ca28ec --- /dev/null +++ b/portprotonqt/input_manager/settings_visual.py @@ -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 diff --git a/portprotonqt/meson.build b/portprotonqt/meson.build index 712ce731..3ba80ca6 100644 --- a/portprotonqt/meson.build +++ b/portprotonqt/meson.build @@ -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', diff --git a/portprotonqt/native_gamepad.py b/portprotonqt/native_gamepad.py new file mode 100644 index 00000000..e5957dc0 --- /dev/null +++ b/portprotonqt/native_gamepad.py @@ -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() diff --git a/portprotonqt/tabs/settings_tab.py b/portprotonqt/tabs/settings_tab.py index a5a2b531..47cb7ce7 100644 --- a/portprotonqt/tabs/settings_tab.py +++ b/portprotonqt/tabs/settings_tab.py @@ -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() diff --git a/tests/test_input_manager.py b/tests/test_input_manager.py index b7c238a8..27f25da1 100644 --- a/tests/test_input_manager.py +++ b/tests/test_input_manager.py @@ -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 diff --git a/tests/test_sound_manager.py b/tests/test_sound_manager.py index dd5e3c4d..a763cc6e 100644 --- a/tests/test_sound_manager.py +++ b/tests/test_sound_manager.py @@ -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(), -- 2.54.0