Compare commits

...
91 Commits
Author SHA1 Message Date
Boria138 f819e495f2 bump to 1.4.0
Code check / Check code (push) Successful in 7m7s
Build AppImage, Arch, Fedora and Debian Packages / Build AppImage (push) Successful in 8m2s
Build AppImage, Arch, Fedora and Debian Packages / Build Arch Package (push) Successful in 2m17s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (43) (push) Successful in 3m11s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (44) (push) Successful in 1m45s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (rawhide) (push) Successful in 2m5s
Build AppImage, Arch, Fedora and Debian Packages / Build Debian Package (push) Successful in 6m2s
Build AppImage, Arch, Fedora and Debian Packages / Create and Publish Release (push) Successful in 31s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-23 14:33:43 +05:00
Boria138 4e998edae8 chore: count downloads from both GitHub mirrors
Code check / Check code (push) Successful in 7m20s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-23 14:32:13 +05:00
Boria138 6ccfeffdd8 fix: locate vk_gpu_info in build auxiliary binaries
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-23 14:29:56 +05:00
Boria138 3ce2acca89 chore: update GitHub mirror repositories
Code check / Check code (push) Failing after 3m10s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-22 17:19:58 +05:00
Boria138 2092a08e82 chore: migrate legacy AppImage integration safely
Code check / Check code (push) Successful in 4m27s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-22 15:54:10 +05:00
Boria138 79a32d392e chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-22 15:33:59 +05:00
Boria138 f1a34d352b refactor: move notification icons to themes
Code check / Check code (pull_request) Failing after 2m34s
Package Build - AppImage, Arch, Fedora, Debian / changes (pull_request) Successful in 15s
Package Build - AppImage, Arch, Fedora, Debian / Build AppImage (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Arch Package (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Debian Package (pull_request) Skipped
Code check / Check code (push) Successful in 3m44s
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (43) (pull_request) Failing after 1m14s
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (44) (pull_request) Failing after 1m15s
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (rawhide) (pull_request) Failing after 1m35s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-22 15:31:18 +05:00
Boria138 7afd6d0baf fix: pass proxy settings to curl
Code check / Check code (push) Failing after 1m34s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-22 08:07:55 +00:00
Boria138 a0a602daf5 chore: respect selected download mirror
Code check / Check code (push) Successful in 1m57s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-22 08:07:39 +00:00
Boria138 9d797e8072 fix: track GOG launch statistics per game
Code check / Check code (push) Successful in 5m33s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-22 11:41:00 +05:00
Boria138 b695ec6a2a fix: show GOG dependency download status
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-22 11:29:55 +05:00
Boria138 d562bbf18d chore: remove log creation guide from debug log
Code check / Check code (push) Successful in 4m47s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-21 21:05:36 +05:00
Boria138 cf4185007a chore(locale): update
Code check / Check code (push) Successful in 4m14s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-21 12:28:54 +05:00
Boria138 e8dbaeb9f7 feat: add custom theme removal
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-21 12:27:11 +05:00
Boria138 2ec44c6a5b chore: update debug log help links
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-21 12:20:13 +05:00
Boria138 257f3c85cf fix 7z on appimage
Code check / Check code (push) Successful in 3m47s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-21 12:12:55 +05:00
Codeberg Translateandrogerio-mattos 53f1f5857c Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (754 of 754 strings)

Co-authored-by: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/
Translation: PortProtonQt/PortProtonQt
2026-08-20 21:25:51 +00:00
Boria138 ae1d991286 chore: update SLR
Code check / Check code (push) Successful in 2m27s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-20 15:46:00 +05:00
Boria138 693f0cb7f4 chore: drop ext from ppdb
Code check / Check code (push) Successful in 3m46s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-20 00:36:51 +05:00
Boria138 85af880000 build: bundle 7z and vk_gpu_info in AppImage
Code check / Check code (push) Successful in 6m2s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-19 23:27:36 +05:00
Boria138 ed1ba4dd28 refactor: remove unused dead code
Code check / Check code (push) Successful in 1m58s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-19 21:09:37 +05:00
Boria138 de29dd92a9 chore: keep settings gamepad navigation vertical
Code check / Check code (push) Successful in 4m54s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-19 20:33:51 +05:00
Codeberg Translateandrogerio-mattos 6fa6f1af2a Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.8% (753 of 754 strings)

Co-authored-by: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/
Translation: PortProtonQt/PortProtonQt
2026-08-19 00:27:31 +00:00
Boria138 16ef000ed2 fix: cache metadata indexes and preserve source ribbons
Code check / Check code (push) Successful in 4m21s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-18 00:09:08 +05:00
Codeberg TranslateandBoria138 299a3cf89e Translated using Weblate (Russian)
Currently translated at 100.0% (754 of 754 strings)

Co-authored-by: Boria138 <boria138@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-08-17 06:15:22 +00:00
Rubin13 056faafd82 Translated using Weblate (German)
Currently translated at 100.0% (753 of 753 strings)

Translation: PortProtonQt/PortProtonQt
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/
2026-08-17 11:11:24 +05:00
Boria138 4615b922c2 chore(locale): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-16 21:24:44 +05:00
Boria138 e842840101 feat: add option to hide control hints
Code check / Check code (push) Successful in 3m26s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-16 21:21:59 +05:00
Boria138 8e76a50b1d test: isolate asyncio event loops in D-Bus tests
Code check / Check code (push) Successful in 1m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-16 12:00:43 +05:00
Boria138 e7cf33cdc0 fix: queue gamepad events before opening modal dialogs
Code check / Check code (push) Failing after 3m51s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-16 11:54:10 +05:00
Boria138 3c47d636f2 fix: prevent skipped cards during fast D-pad input
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-16 11:42:55 +05:00
Boria138 0b253a4d6e fix: restore gamepad input after update dialog closes
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-16 11:39:36 +05:00
Boria138 ded84a6130 fix: correct library filter controls layout
Code check / Check code (push) Successful in 1m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-16 10:57:48 +05:00
Codeberg TranslateandBoria138 4a17f438fd Translated using Weblate (Russian)
Currently translated at 100.0% (753 of 753 strings)

Co-authored-by: Boria138 <boria138@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-08-15 13:04:09 +00:00
Boria138 3e6e138ff9 fix: show cached library while GOG refreshes
Code check / Check code (push) Successful in 3m20s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-15 11:28:18 +05:00
Boria138 2f7dc41c49 fix: refresh only the selected library source
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-15 11:22:05 +05:00
Boria138 ce1ca0d026 fix: prevent repeated card navigation sound
Code check / Check code (push) Successful in 2m32s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-15 10:58:35 +05:00
Boria138 6ffdd4988f fix: resize library filters when checkbox is hidden
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-15 10:54:13 +05:00
Boria138 debe88f2bc fix: prevent repeated combo box navigation sounds
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-15 10:48:22 +05:00
Boria138 efeac02ece fix: adapt settings actions to narrow windows
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-15 10:39:26 +05:00
Boria138 08d45ddc70 fix: restore Git version fallback
Code check / Check code (push) Successful in 3m16s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-14 15:13:58 +05:00
Boria138 7a62207063 fix: prevent repeated gamepad navigation sounds
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-14 15:08:08 +05:00
Boria138 1deaaf74d8 fix: preserve gamepad input in game card context menu
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-14 15:04:59 +05:00
Boria138 a45792307e fix: correct Meson configuration
Code check / Check code (push) Successful in 1m59s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-14 11:42:38 +05:00
Boria138 a52d4a1f49 fix: preserve gamepad input after mouse emulation toggle
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-14 11:36:37 +05:00
Boria138 a5ea3e220f chore: report native gamepad library path
Code check / Check code (push) Successful in 1m38s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-14 11:11:45 +05:00
Boria138 10b498acb7 refactor: load gamepad library beside Python module
Code check / Check code (push) Successful in 3m34s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-14 11:07:32 +05:00
Boria138 d9f3e7fbb8 build: organize native binaries and sources
Build vk_gpu_info on Debian 11 / Build vk_gpu_info on Debian 11 (push) Successful in 2m58s
Code check / Check code (push) Successful in 2m24s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-14 11:04:23 +05:00
Boria138 998a4234e5 fix: avoid duplicate sound in combo navigation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-14 10:53:28 +05:00
Boria138 97c1addedc fix: limit gogdl update checks to once a month
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-14 10:45:36 +05:00
Boria138 b9a8a07a04 chore: refine installed games filter controls
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-14 10:41:50 +05:00
Boria138 9e5fd9d1a7 refactor: add native SDL3 gamepad input
Code check / Check code (pull_request) Successful in 2m26s
Package Build - AppImage, Arch, Fedora, Debian / changes (pull_request) Successful in 17s
Package Build - AppImage, Arch, Fedora, Debian / Build AppImage (pull_request) Successful in 4m24s
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (43) (pull_request) Successful in 1m43s
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (rawhide) (pull_request) Canceled after 0s
Package Build - AppImage, Arch, Fedora, Debian / Build Arch Package (pull_request) Canceled after 0s
Package Build - AppImage, Arch, Fedora, Debian / Build Debian Package (pull_request) Canceled after 0s
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (44) (pull_request) Canceled after 6m6s
Code check / Check code (push) Canceled after 40s
Assisted-by: Codex:gpt-5.6
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-13 21:13:08 +05:00
Boria138 cea7b40b82 chore: drop Fedora 42 from builds
Code check / Check code (push) Successful in 4m56s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-13 15:01:23 +05:00
Boria138 2a89bd7573 fix: play game launch sound before RPM package check
Code check / Check code (push) Successful in 2m0s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-12 11:53:37 +05:00
Boria138 c66538e32f feat: add open folder button to Wine manager
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-12 11:47:26 +05:00
Codeberg Translateandgallegonovato d2e06fcbc3 Translated using Weblate (Spanish)
Currently translated at 45.9% (346 of 753 strings)

Co-authored-by: gallegonovato <gallegonovato@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/es/
Translation: PortProtonQt/PortProtonQt
2026-08-11 19:26:23 +00:00
Boria138 4b56725559 chore: set Windows 11 as the default version
Code check / Check code (push) Successful in 3m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-11 22:35:32 +05:00
Boria138 14cf5c73f8 feat: install Wine archives via drag and drop
Code check / Check code (push) Failing after 3m40s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-11 21:05:34 +05:00
Boria138 347685e4f0 bump to v1.3.2
Code check / Check code (push) Successful in 2m1s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-11 13:40:40 +05:00
Boria138 5e512279f3 chore(locale): update
Code check / Check code (push) Successful in 1m32s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-10 21:56:21 +05:00
Boria138 05e7366706 chore: document GOG support
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-10 21:56:21 +05:00
Boria138 862bfe4f8b feat: add installed-only library filter
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-10 21:56:21 +05:00
Dervart 12b0889566 theme: add color_text_accent_hover variable to other themes
Code check / Check code (push) Successful in 1m46s
2026-08-10 06:43:09 +00:00
Boria138 a62bfecfd3 fix: remove last launch date with game shortcut
Code check / Check code (push) Successful in 2m2s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-10 11:09:28 +05:00
Boria138 7ec1602be7 chore: extract launcher icons from batch target executables
Code check / Check code (push) Successful in 1m31s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-09 22:24:13 +05:00
Boria138 7ae1904bb6 feat: add search for MangoHud, vkBasalt and Gamescope settings
Code check / Check code (push) Successful in 1m34s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-07 21:06:45 +05:00
Boria138 527cceccc2 fix: prevent UI freeze during RPM package check
Code check / Check code (push) Successful in 2m18s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-07 16:49:43 +05:00
Boria138 42eff0d44b chore: clean dev-scripts
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-07 13:28:40 +05:00
Boria138 12f3d3b753 fix: prevent control hints from being clipped
Code check / Check code (push) Successful in 2m14s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-06 12:19:35 +05:00
Boria138 3afe35929f chore: suggest WINE_LG for detected installers
Code check / Check code (push) Successful in 2m3s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-06 12:10:04 +05:00
Boria138 32e363d328 chore: detect game launch with explicit marker
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-06 12:03:17 +05:00
Boria138 6443b71c01 fix: prevent WINE manager UI freezes
Code check / Check code (push) Successful in 1m32s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-05 15:51:00 +05:00
Boria138 d5db14db54 fix: use theme values for UI fallback styles
Code check / Check code (push) Successful in 2m32s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-05 15:16:31 +05:00
Boria138 db2cc0365d fix icon path
Code check / Check code (push) Successful in 2m35s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-04 21:30:15 +05:00
Boria138 ab073c5413 fix: allow icon themes to override AppImage icon
Code check / Check code (push) Successful in 1m32s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-04 21:18:15 +05:00
Boria138 2212c7c23a feat: add desktop AppImage shortcut
Code check / Check code (push) Successful in 1m33s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-04 14:48:08 +05:00
Boria138 3a3ae4be2e chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-03 11:47:14 +05:00
Dervart 3d9151df7b theme: click icons
Code check / Check code (push) Successful in 2m3s
2026-08-03 06:43:12 +00:00
Dervart 844fcff2da theme: add new variable color_text_accent_hover 2026-08-03 06:43:12 +00:00
Dervart cf542d5ce2 style: make navigation icons monochrome 2026-08-03 06:43:12 +00:00
Boria138 a6c25bad98 feat: add one-click AppImage installer
Code check / Check code (push) Successful in 1m54s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-03 11:38:09 +05:00
Boria138 0f0155cfb7 feat: update gogdl in background on startup
Code check / Check code (push) Successful in 1m53s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-03 11:21:01 +05:00
Boria138 8b7849b3fd chore(locale): update
Code check / Check code (push) Successful in 1m28s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-02 13:53:56 +05:00
Boria138 e0d8f587fa fix: apply theme colors to file explorer icons
Code check / Check code (push) Successful in 1m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-02 13:38:46 +05:00
Boria138 393d5cce98 fix: add mouse sounds to theme store cards
Code check / Check code (push) Successful in 1m59s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-02 13:15:38 +05:00
Boria138 cf7a14983d fix: measure crashes from game process start
Code check / Check code (push) Successful in 1m55s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-01 10:39:29 +05:00
Boria138 024a1670eb fix: use matching duration for detail cover reveal
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-01 10:18:06 +05:00
Codeberg TranslateandBoria138 6cc3487d59 Translated using Weblate (Russian)
Currently translated at 100.0% (751 of 751 strings)

Co-authored-by: Boria138 <boria138@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-08-01 00:01:37 +00:00
Codeberg Translateandrogerio-mattos 0ce8317763 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (751 of 751 strings)

Co-authored-by: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/
Translation: PortProtonQt/PortProtonQt
2026-08-01 00:01:37 +00:00
Codeberg TranslateandRubin13 c3e1222237 Translated using Weblate (German)
Currently translated at 100.0% (751 of 751 strings)

Co-authored-by: Rubin13 <rubin13@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/
Translation: PortProtonQt/PortProtonQt
2026-08-01 00:01:37 +00:00
Gitea Actions bdc52c8f02 chore: download stats 2026-08-01T00:01:32Z 2026-08-01 00:01:32 +00:00
199 changed files with 9228 additions and 5830 deletions

No files matched your search

@@ -7,7 +7,7 @@ on:
- main
- scripts
paths:
- 'build-aux/vk_gpu_info.c'
- 'build-aux/src/vk_gpu_info.c'
- '.gitea/workflows/build-vk-gpu-info-debian.yml'
jobs:
@@ -34,17 +34,16 @@ jobs:
- name: Compile vk_gpu_info
run: |
cd build-aux
gcc -o vk_gpu_info vk_gpu_info.c -lvulkan
mkdir -p build-aux/bin
gcc -o build-aux/bin/vk_gpu_info build-aux/src/vk_gpu_info.c -lvulkan
- name: Test vk_gpu_info execution
run: |
cd build-aux
# Check if the executable was created
if [ -f "./vk_gpu_info" ]; then
if [ -f "build-aux/bin/vk_gpu_info" ]; then
echo "vk_gpu_info executable created successfully"
# Try to run it (may fail if no Vulkan devices present, but that's OK for build test)
timeout 10s ./vk_gpu_info || echo "vk_gpu_info ran but may have failed due to lack of Vulkan hardware (expected in CI)"
timeout 10s build-aux/bin/vk_gpu_info || echo "vk_gpu_info ran but may have failed due to lack of Vulkan hardware (expected in CI)"
else
echo "ERROR: vk_gpu_info executable was not created"
exit 1
@@ -54,4 +53,4 @@ jobs:
uses: https://git.linux-gaming.ru/actions/gitea-upload-artifact@v4
with:
name: vk_gpu_info-debian11-binary
path: build-aux/vk_gpu_info
path: build-aux/bin/vk_gpu_info
+7 -5
View File
@@ -17,7 +17,7 @@ on:
env:
# Common version, will be used for tagging the release
VERSION: 1.3.1
VERSION: 1.4.0
PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -119,7 +119,7 @@ jobs:
strategy:
matrix:
fedora_version: [42, 43, 44, rawhide]
fedora_version: [43, 44, rawhide]
container:
image: fedora:${{ matrix.fedora_version }}
@@ -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
+6 -4
View File
@@ -126,7 +126,7 @@ jobs:
strategy:
matrix:
fedora_version: [42, 43, 44, rawhide]
fedora_version: [43, 44, rawhide]
container:
image: fedora:${{ matrix.fedora_version }}
@@ -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
+2
View File
@@ -43,3 +43,5 @@ portprotonqt/locales/*/LC_MESSAGES/*.mo
# AI
.codex
CODE_QUALITY_PLAN.md
.repowise
+115 -19
View File
@@ -394,6 +394,7 @@ tests/
├── test_portproton_api.py # PPDB API helpers, autoinstall localization fallback
├── test_autoinstall_status.py # Autoinstall installed-status matching regressions
├── test_migration.py # Desktop shortcut migration, prefix backup, squashfs
├── test_file_explorer.py # File explorer themed icon state regressions
├── test_icon_extractor.py # NE/PE icon extraction, DIB decoding, thumbnails
├── test_dbus_tools.py # D-Bus tools (notifications, idle inhibit, power profiles)
├── test_time_utils.py # Playtime parsing, last launch cache, formatting
@@ -401,7 +402,9 @@ tests/
├── test_theme_store.py # Theme store UI worker lifecycle and race regressions
├── test_theme_manager.py # Theme AST injection, parent resolution, ThemeWrapper, style integrity
├── test_theme_security.py # Theme security checker (allowlist AST, forbidden modules/methods, SVG/font/image safety)
└── test_detail_pages.py # Detail page gradient stops, wave background modes, palette handling
├── test_settings_search.py # MangoHud, vkBasalt, Gamescope settings search and layout regressions
├── test_detail_pages.py # Detail page gradient stops, wave background modes, palette handling
└── test_proton_manager.py # Local Wine/Proton drag-and-drop and safe archive extraction
```
### Running Tests
@@ -426,7 +429,7 @@ pre-commit run pytest
- Tests MUST mock external resources (network, system paths, env vars)
- Tests MUST NOT depend on system state (installed apps, real Steam dirs)
- Use `monkeypatch` for environment variables and function patching
- Keep tests locale-independent (assert numbers, not translated strings)
- Keep tests locale-independent: assert stable identifiers or numbers instead of translated strings; resolve values created through `_()` from the same application mapping using stable identifiers; use `monkeypatch` to provide deterministic text when testing translated search or matching
- Test regression scenarios from git fix commits
### Key Regression Areas
@@ -442,6 +445,7 @@ pre-commit run pytest
| `classic-light/styles.py` | NameError: border_none not defined (no styles/constants.py) | 519edd1 |
| `detail_pages` | DETAIL_PAGE_GRADIENT stops override, wave background modes | — |
| `detail_pages` | Edit/Add shortcut buttons shown together for non-Steam games | 7e38d9a |
| `wine_extractor` | Archive traversal and process-wide cwd changes during extraction | — |
---
@@ -874,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
```
@@ -946,6 +1042,6 @@ PortProtonQt/
---
**Last updated:** 2026-07-20
**Version:** 1.2
**Last updated:** 2026-08-11
**Version:** 1.3
**Status:** Release
+51
View File
@@ -5,21 +5,50 @@
## [Unreleased]
### Added
### Changed
### Fixed
### Contributors
---
## [1.4.0] - 2026-08-23
### Added
- Добавлен анализатор проблем в случае вылета Windows-программы
- Добавлены анимированные и статические фоны детальной страницы `aurora`, `diagnostics`, `metaballs`, `leaf` и `veins`, вдохновлённые проектом omikuji
- Добавлена интеграция GOG с авторизацией, библиотекой, установкой, импортом, восстановлением и удалением игр
- Добавлена отдельная галочка `Only Installed`, сочетаемая с фильтрами источника и скрывающая неустановленные игры
- Добавлено отображение иконок `.exe` вместо обложек на компактной детальной странице
- Добавлено редактирование ярлыков Steam по AppID
- Добавлены тихий и логируемый режимы запуска `.exe` через меню «Открыть с помощью»
- Добавлена пользовательская интеграция AppImage
- Добавлен однокнопочный установщик AppImage с загрузкой через Gitea или GitHub и автоматической интеграцией в систему
- В игровые ярлыки добавлены действия для запуска в тихом режиме и с записью лога
- Добавлен аргумент командной строки `--log` для запуска игры в режиме логирования
- Добавлены звуки интерфейса
- Добавлен выбор аккаунта Steam в настройках с автоматическим режимом по умолчанию и режимом `All` для добавления ярлыков всем пользователям
- Добавлено безопасное чтение вариантов dark/light палитры matugen из Dank Material Shell
- Добавлен поиск параметров MangoHud, vkBasalt и Gamescope по названиям и описаниям
- Добавлена установка локальных архивов WINE/Proton перетаскиванием в библиотеку или менеджер версий
- Добавлена кнопка открытия папки WINE в менеджере версий
- Добавлена настройка для скрытия подсказок по управлению
- Добавлена кнопка удаления пользовательских тем
### Changed
- GitHub-зеркала [PortProtonQt](https://github.com/linux-gaming-ru/PortProtonQt) и [метаданных WINE/Proton](https://github.com/linux-gaming-ru/PortProtonQt-Wine-Metadata) перенесены в организацию `linux-gaming-ru`
- Иконки системных уведомлений перенесены в темы и теперь соответствуют выбранной теме
- Выбранное зеркало теперь используется для загрузки метаданных WINE/Proton, проверки обновлений AppImage и получения списка изменений
- Обновлена ссылка на раздел помощи в логе ошибки
- В AppImage добавлены `7z` для извлечения содержимого ISO и MDF-образов и `vk_gpu_info` для определения видеокарт Vulkan
- Обновлен контейнер (SLR) до v4.0.20260805
- Вертикальная навигация геймпадом в сетках настроек больше не переходит между столбцами
- Удалена сборка для Fedora 42
- Версия Windows по умолчанию изменена с 10 на 11
- Обновлены все иконки подсказок управления
- API и ссылки PPDB переведены с `ppdb.linux-gaming.ru` на `linux-gaming.ru`
- Блок игровой статистики на полной детальной странице помещён в тематизируемый контейнер, как в компактном режиме
- Кэш EXE-иконок унифицирован для карточек и детальных страниц
@@ -33,6 +62,18 @@
- `sherrydmn` добавлен в список конфликтующих с GameMode процессов
### Fixed
- Настройки прокси теперь передаются `curl` при загрузке файлов скриптами PortProton
- Устранена повторная перестройка индексов Steam и PPDB для каждой карточки в экономном режиме
- Скрытие бейджей больше не скрывает ленту источника игры на карточках библиотеки
- Обновление сетки библиотеки теперь загружает только источник, выбранный одиночным фильтром
- Исправлено обрезание кнопок настроек PortProtonQt в узких окнах
- Исправлено удаление даты последнего запуска при удалении ярлыка игры
- Исправлено извлечение иконок для лаунчеров, запускающих целевой EXE через соседний `.bat` или `.cmd`
- Исправлено зависание интерфейса при проверке RPM-пакетов перед запуском игры
- Исправлено обрезание подсказок управления при переносе на вторую строку
- Исправлены подлагивания интерфейса при загрузке списка WINE/Proton и подсчёте размеров установленных версий
- Исправлена перекраска иконок встроенного файлового менеджера через `ICON_COLORS`
- Исправлено применение длительности fade-анимации к появлению обложки при анимациях `slide` и `bounce` детальной страницы
- Исправлено игнорирование параметров темы для размеров, отступов, скруглений и тени карточек в сеточном и списочном режимах
- Исправлены медленная загрузка превью и повторные сетевые запросы при прокрутке магазина тем
- Исправлен непоследовательный порядок навигации по карточкам игр с помощью D-pad
@@ -43,11 +84,21 @@
- Исправлено состояние кнопки остановки при переключении детальной страницы на компактный вид
- Исправлено зависание интерфейса после завершения игры
- Исправлено определение основного аккаунта Steam по самому новому `Timestamp` в `loginusers.vdf` с резервным поиском по `AutoLogin` или единственному профилю `userdata` и поддержкой `MostRecent` для совместимости со старыми версиями Steam
- Распаковка WINE/Proton больше не меняет рабочий каталог приложения и отклоняет пути из архива, выходящие за каталог установки
### Contributors
- @Dervart
- @Rubin13
- @rogerio-mattos
- @Vekt0R_null
- @CastroFidel
---
## [1.3.2] - 2026-08-10
### Fixed
- Исправлено определение SteamID по `AutoLogin` или единственному профилю `userdata`, если в `loginusers.vdf` отсутствует `MostRecent`
---
+12
View File
@@ -0,0 +1,12 @@
[Desktop Entry]
Name=Install PortProtonQt
Name[ru]=Установить PortProtonQt
Name[es]=Instalar PortProtonQt
Comment=Download and install the latest PortProtonQt AppImage
Comment[ru]=Скачать и установить последнюю версию PortProtonQt AppImage
Exec=sh -c "(curl -fsSL https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/branch/main/install.sh || curl -fsSL https://raw.githubusercontent.com/linux-gaming-ru/PortProtonQt/main/install.sh) | sh"
Icon=system-software-install
StartupNotify=false
Terminal=true
Type=Application
Categories=Game;
+2 -2
View File
@@ -5,8 +5,8 @@
<div align="center">
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
<h1 align="center">PortProtonQt</h1>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-5471-green?style=flat-square" alt="Total Downloads"></a>
<p align="center">A modern and convenient interface for managing and launching games from PortProton and Steam. It brings libraries together in one place and simplifies launching Windows games on Linux.</p>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-6382-green?style=flat-square" alt="Total Downloads"></a>
<p align="center">A modern and convenient interface for managing and launching games from PortProton, Steam, and GOG. It brings libraries together in one place and simplifies launching Windows games on Linux.</p>
</div>
### Installation (devel)
+2 -2
View File
@@ -5,8 +5,8 @@
<div align="center">
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
<h1 align="center">PortProtonQt</h1>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/%D0%97%D0%B0%D0%B3%D1%80%D1%83%D0%B7%D0%BA%D0%B8-5471-green?style=flat-square" alt="Всего загрузок"></a>
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton и Steam. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.</p>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/%D0%97%D0%B0%D0%B3%D1%80%D1%83%D0%B7%D0%BA%D0%B8-6382-green?style=flat-square" alt="Всего загрузок"></a>
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam и GOG. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.</p>
</div>
### Установка (devel)
+7 -1
View File
@@ -39,15 +39,21 @@ pacman -Rdd --noconfirm libpipewire qt6-multimedia-ffmpeg
# Deploy dependencies
# Qt libs have to be passed manually due to the app being a python script
GAMEPAD_LIBRARY=$(find /usr/lib -type f \
-path '*/site-packages/portprotonqt/libportprotonqt_gamepad.so' \
-print -quit)
test -n "$GAMEPAD_LIBRARY"
./quick-sharun \
/usr/bin/portprotonqt* \
/usr/bin/update-desktop-database \
/usr/bin/vk_gpu_info \
"$GAMEPAD_LIBRARY" \
/usr/lib/libQt6Core.so* \
/usr/lib/libQt6Gui.so* \
/usr/lib/libQt6Multimedia.so* \
/usr/lib/libQt6Network.so* \
/usr/lib/qt6/plugins/imageformats/libqwebp.so
/usr/lib/qt6/plugins/imageformats/libqwebp.so \
/usr/lib/7zip/7z*
# DEPLOY_PYTHON copies the distro's complete Python installation. Remove
# build/development content and PySide bindings not imported by PortProtonQt.
+2 -2
View File
@@ -1,8 +1,8 @@
pkgname=('portprotonqt')
pkgver=1.3.1
pkgver=1.4.0
pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam"
arch=('any')
arch=('x86_64')
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
license=('GPL-3.0')
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
+1 -1
View File
@@ -2,7 +2,7 @@ pkgname=('portprotonqt-git')
pkgver=.
pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam (development build)"
arch=('any')
arch=('x86_64')
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
license=('GPL-3.0')
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
File renamed without changes.
+2 -3
View File
@@ -1,5 +1,5 @@
%global pypi_name portprotonqt
%global pypi_version 1.3.1
%global pypi_version 1.4.0
%global oname PortProtonQt
%global build_timestamp %(date +"%Y%m%d")
%global _python_no_extras_requires 1
@@ -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
@@ -120,7 +120,6 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
%{_datadir}/mime/packages/ru.linux_gaming.PortProtonQt.xml
%{_datadir}/portproton/scripts/
%{_datadir}/portproton/conf/
%{_datadir}/portproton/img/
%{bash_completions_dir}/portprotonqt
%{fish_completions_dir}/portprotonqt.fish
%{zsh_completions_dir}/_portprotonqt
+2 -3
View File
@@ -1,5 +1,5 @@
%global pypi_name portprotonqt
%global pypi_version 1.3.1
%global pypi_version 1.4.0
%global oname PortProtonQt
%global _python_no_extras_requires 1
@@ -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
@@ -118,7 +118,6 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
%{_datadir}/mime/packages/ru.linux_gaming.PortProtonQt.xml
%{_datadir}/portproton/scripts/
%{_datadir}/portproton/conf/
%{_datadir}/portproton/img/
%{bash_completions_dir}/portprotonqt
%{fish_completions_dir}/portprotonqt.fish
%{zsh_completions_dir}/_portprotonqt
Binary file not shown.
@@ -5,15 +5,15 @@
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-or-later</project_license>
<summary>Modern GUI for managing and launching games from PortProton and Steam</summary>
<summary xml:lang="ru">Современный интерфейс для управления и запуска игр из PortProton и Steam</summary>
<summary>Modern GUI for managing and launching games from PortProton, Steam, and GOG</summary>
<summary xml:lang="ru">Современный интерфейс для управления и запуска игр из PortProton, Steam и GOG</summary>
<description>
<p>
A modern and intuitive interface for managing and launching games from PortProton and Steam. Combines libraries in one place and simplifies running Windows games on Linux.
A modern and intuitive interface for managing and launching games from PortProton, Steam, and GOG. Combines libraries in one place and simplifies running Windows games on Linux.
</p>
<p xml:lang="ru">
Современный и удобный интерфейс для управления и запуска игр из PortProton и Steam. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.
Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam и GOG. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.
</p>
</description>
@@ -24,9 +24,15 @@
</developer>
<releases>
<release version="1.3.1" date="2026-07-17">
<release version="1.4.0" date="2026-08-23">
<description></description>
</release>
<release version="1.3.2" date="2026-08-10">
<description/>
</release>
<release version="1.3.1" date="2026-07-17">
<description/>
</release>
<release version="1.3.0" date="2026-07-13">
<description/>
</release>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

@@ -135,8 +135,10 @@ rm_from_array () {
}
fatal () {
#TODO
pw_notify_send -i error "$@"
if [[ -z "$*" ]] ; then
set -- "Fatal error"
fi
python_module dbus_tools notify -i error "$@" &>/dev/null
print_error "$@"
stop_portproton
exit 1
@@ -459,6 +461,26 @@ try_download () {
no_mirror=true
esac
local curl_proxy_args=()
local _pp_conf=""
if [[ -n "${XDG_CONFIG_HOME:-}" ]] \
&& [[ -f "$XDG_CONFIG_HOME/PortProtonQt.conf" ]] ; then
_pp_conf="$XDG_CONFIG_HOME/PortProtonQt.conf"
elif [[ -f "$HOME/.config/PortProtonQt.conf" ]] ; then
_pp_conf="$HOME/.config/PortProtonQt.conf"
fi
local _pp_url="" _pp_user="" _pp_pass=""
if [[ -n "$_pp_conf" ]] ; then
_pp_url="$(grep "proxy_url" "$_pp_conf" | awk -F"= " '{print $2}')"
if [[ -n "$_pp_url" ]] ; then
curl_proxy_args=(-x "$_pp_url")
_pp_user="$(grep "proxy_user" "$_pp_conf" | awk -F"= " '{print $2}')"
_pp_pass="$(grep "proxy_password" "$_pp_conf" | awk -F"= " '{print $2}')"
[[ -n "$_pp_user" && -n "$_pp_pass" ]] \
&& curl_proxy_args+=(-u "$_pp_user:$_pp_pass")
fi
fi
local filename
if [[ -n "${PW_AUTOINSTALL_EXE}" ]] \
&& [[ "$no_mirror" == "true" ]]
@@ -482,7 +504,7 @@ try_download () {
if check_gamescope_session ; then
$PW_TERM "echo ; echo ; echo \"Downloading: $filename. Please wait...\" \
; curl -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
-H 'Pragma: no-cache' -L ${FIRST_URL[*]} -o \"$dest\""
-H 'Pragma: no-cache' -L ${FIRST_URL[*]} -o \"$dest\" ${curl_proxy_args[*]}"
[[ "$?" != 0 ]] && return 1 || return 0
@@ -491,10 +513,10 @@ try_download () {
# Normal download
print_info "Download $filename from ${FIRST_URL[0]}..."
if [[ "$silent" == "true" ]] ; then
curl -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
curl "${curl_proxy_args[@]}" -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
-H 'Pragma: no-cache' -L "${FIRST_URL[@]}" -o "$dest" 2>&1
else
curl -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
curl "${curl_proxy_args[@]}" -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
-H 'Pragma: no-cache' -L "${FIRST_URL[@]}" -o "$dest" 2>&1 \
| tr '\r' '\n' | sed -ur 's|[# ]+||g;s|100||g;s|0-||g;s|.*=.*||g'
@@ -543,48 +565,6 @@ var_ld_library_path_update () {
fi
}
# GUI NOTIFY SEND
pw_notify_send () {
local icon title body app timeout OPTIND
app="ru.linux_gaming.PortProtonQt"
timeout="5000"
OPTIND="1"
while getopts a:i:t: opt ; do
case "$opt" in
a)
app=$OPTARG
;;
i)
case "$OPTARG" in
info) local OPTARG="${PORT_IMG_PATH}/notify/info.svg";;
warning) local OPTARG="${PORT_IMG_PATH}/notify/warning.svg";;
error) local OPTARG="${PORT_IMG_PATH}/notify/error.svg";;
esac
icon=$OPTARG
;;
t)
timeout=$OPTARG
;;
*)
echo "usage: $0
[-a] name application (PortProtonQt default)
[-i] info, warning, error (none default)
[-t] timeout in ms (5000 default)" >&2
return 1
;;
esac
done
shift $(( OPTIND - 1 ))
[[ "$1" == "--" ]] && shift
title="$1" ; shift
body="$*"
python_module dbus_tools notify \
-a "$app" -i "$icon" -t "$timeout" -- "$title" "$body" &>/dev/null
return 0
}
export -f pw_notify_send
check_gamescope_session () {
if [[ -n "$GAMESCOPE_IN_USE" ]]
then [[ "$GAMESCOPE_IN_USE" == 1 ]] && return 0 || return 1
@@ -2001,7 +1981,7 @@ pw_check_vulkan_extensions () {
local vk_gpu_info_cmd
ext_key="supports_$(echo "${ext_name}" | tr '[:upper:]' '[:lower:]')"
vk_gpu_info_from_repo="$(dirname "${BASH_SOURCE[0]}")/../../../../dev-scripts/vk_gpu_info"
vk_gpu_info_from_repo="$(dirname "${BASH_SOURCE[0]}")/../../../bin/vk_gpu_info"
if [[ -x "${vk_gpu_info_from_repo}" ]] ; then
vk_gpu_info_cmd="${vk_gpu_info_from_repo}"
else
@@ -2311,7 +2291,7 @@ pw_create_gui_png () {
pw_create_unique_exe () {
BASEDIR_GAME="$(dirname "$PW_EXE_FILE")"
if [[ -d "$BASEDIR_GAME" ]] ; then
pushd "$BASEDIR_GAME" || fatal
pushd "$BASEDIR_GAME" || fatal "Failed to enter game directory: $BASEDIR_GAME"
BASENAME_GAME_EXE="$(basename "$PW_EXE_FILE")"
if [[ -n "$1" ]] ; then
BASENAME_GAME="$(basename "$1" .exe).exe"
@@ -2324,7 +2304,7 @@ pw_create_unique_exe () {
print_warning "There are no arguments for creating a symbolic link! Skip it..."
fi
try_remove_file "${PW_EXE_FILE}.ppdb"
popd || fatal
popd || fatal "Failed to return from game directory: $BASEDIR_GAME"
fi
}
@@ -4089,6 +4069,7 @@ steamplay_launch () {
fi
print_info "$proton_runner in use."
printf '%s\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'
pw_launch_wrapper \
${PW_RUN_GAMESCOPE} \
env \
@@ -4114,6 +4095,7 @@ portwine_launch () {
fi
[[ $PW_LOG != 1 ]] && debug_timer --start -s "PW_TIME_IN_GAME"
printf '%s\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'
if [[ -n "${LAUNCH_URI}" ]] ; then
export WINEFSYNC="1"
pw_run start /high /b "${LAUNCH_URI}" &
@@ -4213,8 +4195,13 @@ gui_proton_downloader () {
return 0
fi
METADATA_URL="https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
METADATA=$(curl -A 'PortProton' -s "$METADATA_URL")
METADATA_CLOUD_URL="https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
METADATA_GITHUB_URL="https://raw.githubusercontent.com/linux-gaming-ru/PortProtonQt-Wine-Metadata/main/wine_metadata_all.json"
if [[ "${MIRROR}" == CLOUD ]] ; then
METADATA=$(curl -fL -A 'PortProton' -s "$METADATA_CLOUD_URL")
else
METADATA=$(curl -fL -s "$METADATA_GITHUB_URL")
fi
if [[ -z "$METADATA" ]] ; then
fatal "failed to fetch metadata"
fi
View File
File mode changed.
View File
File mode changed.
+6 -7
View File
@@ -19,7 +19,6 @@ else
PORT_SCRIPTS_PATH="$(dirname "$(realpath "$0")")"
fi
PORT_IMG_PATH="$(dirname "$PORT_SCRIPTS_PATH")/img"
PORT_CONF_PATH="$(dirname "$PORT_SCRIPTS_PATH")/conf"
if [[ -z "$PORT_DATA_PATH" ]] ; then
@@ -55,7 +54,7 @@ else
export PW_FIND_PPDB=1
fi
export PORT_SCRIPTS_PATH PORT_IMG_PATH PORT_DATA_PATH PORT_CONF_PATH
export PORT_SCRIPTS_PATH PORT_DATA_PATH PORT_CONF_PATH
export PW_LOG_FILE="${PORT_DATA_PATH}/PortProton.log"
# shellcheck source=/dev/null
@@ -148,7 +147,7 @@ create_new_dir "${PORT_DATA_PATH}/data/prefixes/DEFAULT"
create_new_dir "${PORT_DATA_PATH}/data/prefixes/DOTNET"
try_force_link_dir "${PORT_DATA_PATH}/data/prefixes" "${PORT_DATA_PATH}"
pushd "${PORT_DATA_PATH}/data/prefixes/" 1>/dev/null || fatal
pushd "${PORT_DATA_PATH}/data/prefixes/" 1>/dev/null || fatal "Failed to enter prefixes directory"
for pfx_dir in ./* ; do
[[ -d "$pfx_dir" ]] || continue
pfx_dir_new="${pfx_dir//[[:blank:]]/_}"
@@ -156,7 +155,7 @@ for pfx_dir in ./* ; do
mv -- "${PORT_DATA_PATH}/data/prefixes/$pfx_dir" "${PORT_DATA_PATH}/data/prefixes/${pfx_dir_new^^}"
fi
done
popd 1>/dev/null || fatal
popd 1>/dev/null || fatal "Failed to return from prefixes directory"
create_new_dir "${PORT_WINE_TMP_PATH}"/gecko
create_new_dir "${PORT_WINE_TMP_PATH}"/mono
@@ -164,12 +163,12 @@ create_new_dir "${PORT_WINE_TMP_PATH}"/mono
export PW_VULKAN_DIR="${PORT_WINE_TMP_PATH}/VULKAN"
create_new_dir "${PW_VULKAN_DIR}"
cd "${PORT_SCRIPTS_PATH}" || fatal
cd "${PORT_SCRIPTS_PATH}" || fatal "Scripts directory not found: ${PORT_SCRIPTS_PATH}"
# shellcheck source=/dev/null
source "${PORT_SCRIPTS_PATH}/var"
# HACK: Avoid inheriting a system scripts cwd in pressure-vessel.
cd "${XDG_DATA_HOME:-$HOME}" || fatal
cd "${XDG_DATA_HOME:-$HOME}" || fatal "Data directory not found: ${XDG_DATA_HOME:-$HOME}"
[[ ! -f "$VKBASALT_CONFIG_FILE" ]] && cp -f "${PORT_CONF_PATH}/vkBasalt.conf" "$VKBASALT_CONFIG_FILE"
[[ ! -f "$DXVK_CONFIG_FILE" ]] && cp -f "${PORT_CONF_PATH}/dxvk.conf" "$DXVK_CONFIG_FILE"
@@ -472,7 +471,7 @@ Usage examples:
--winecmd)
get_wine_and_pfx "$2" "$3"
start_portproton
cd "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c" || fatal
cd "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c" || fatal "Prefix drive_c directory not found"
PW_USE_TERMINAL=1 pw_run cmd
stop_portproton
;;
+2 -2
View File
@@ -4,7 +4,7 @@
###BASE VARIABLES###
export PW_PROTON_LG_VER="PROTON_LG_10-30"
export PW_WINE_LG_VER="WINE_LG_11-10"
export PW_LIBS_VER="_v3.0.20260218"
export PW_LIBS_VER="_v4.0.20260805"
export PW_PLUGINS_VER="_v20"
export D7VK_VAR_VER="v1.10"
export DXVK_NEW_VER="2.7.1-509"
@@ -39,7 +39,7 @@ check_variables PW_USE_NTSYNC "0"
check_variables PW_USE_GAMEMODE "0"
check_variables PW_USE_INHIBIT_SLEEP "1"
check_variables PW_USE_TERMINAL "0"
check_variables PW_WINDOWS_VER "10"
check_variables PW_WINDOWS_VER "11"
check_variables PW_USE_GSTREAMER "1"
check_variables PW_USE_D3D_EXTRAS "1"
check_variables PW_PREFIX_NAME "DEFAULT"
+122
View File
@@ -0,0 +1,122 @@
#include <SDL3/SDL.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
typedef struct {
SDL_Gamepad *gamepad;
SDL_JoystickID instance_id;
int sdl_type;
} PortProtonGamepad;
static PortProtonGamepad *open_gamepad(SDL_JoystickID instance_id)
{
SDL_Gamepad *gamepad = SDL_OpenGamepad(instance_id);
if (gamepad == NULL) {
return NULL;
}
PortProtonGamepad *result = malloc(sizeof(*result));
if (result == NULL) {
SDL_CloseGamepad(gamepad);
SDL_SetError("Failed to allocate gamepad handle");
return NULL;
}
result->gamepad = gamepad;
result->instance_id = instance_id;
result->sdl_type = SDL_GetGamepadTypeForID(instance_id);
return result;
}
PortProtonGamepad *portproton_gamepad_find(void)
{
SDL_ClearError();
if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) {
return NULL;
}
SDL_PumpEvents();
SDL_UpdateGamepads();
int count = 0;
SDL_JoystickID *gamepads = SDL_GetGamepads(&count);
if (gamepads == NULL) {
return NULL;
}
if (count == 0) {
SDL_free(gamepads);
SDL_ClearError();
return NULL;
}
PortProtonGamepad *result = NULL;
for (int index = 0; index < count && result == NULL; index++) {
result = open_gamepad(gamepads[index]);
}
SDL_free(gamepads);
return result;
}
const char *portproton_gamepad_get_error(void)
{
const char *error = SDL_GetError();
return error != NULL ? error : "";
}
void portproton_gamepad_close(PortProtonGamepad *gamepad)
{
if (gamepad == NULL) {
return;
}
if (gamepad->gamepad != NULL) {
SDL_CloseGamepad(gamepad->gamepad);
}
free(gamepad);
}
bool portproton_gamepad_connected(const PortProtonGamepad *gamepad)
{
return gamepad != NULL && gamepad->gamepad != NULL &&
SDL_GamepadConnected(gamepad->gamepad);
}
void portproton_gamepad_update(void)
{
SDL_UpdateGamepads();
}
int portproton_gamepad_get_button(const PortProtonGamepad *gamepad, int button)
{
if (gamepad == NULL || gamepad->gamepad == NULL) {
return 0;
}
return SDL_GetGamepadButton(gamepad->gamepad, button);
}
int16_t portproton_gamepad_get_axis(const PortProtonGamepad *gamepad, int axis)
{
if (gamepad == NULL || gamepad->gamepad == NULL) {
return 0;
}
return SDL_GetGamepadAxis(gamepad->gamepad, axis);
}
const char *portproton_gamepad_get_name(const PortProtonGamepad *gamepad)
{
if (gamepad == NULL || gamepad->gamepad == NULL) {
return "";
}
const char *name = SDL_GetGamepadName(gamepad->gamepad);
return name != NULL ? name : "";
}
uint32_t portproton_gamepad_get_instance_id(const PortProtonGamepad *gamepad)
{
return gamepad != NULL ? gamepad->instance_id : 0;
}
int portproton_gamepad_get_type(const PortProtonGamepad *gamepad)
{
return gamepad != NULL ? gamepad->sdl_type : 0;
}
void portproton_gamepad_shutdown(void)
{
SDL_QuitSubSystem(SDL_INIT_GAMEPAD);
}
File renamed without changes.
+12
View File
@@ -1,3 +1,15 @@
portprotonqt (1.4.0) unstable; urgency=medium
* New upstream release.
-- Ivan Mazhukin <vanyamajukin@outlook.com> Sun, 23 Aug 2026 14:32:36 +0500
portprotonqt (1.3.2) unstable; urgency=medium
* New upstream release.
-- Ivan Mazhukin <vanyamajukin@outlook.com> Mon, 10 Aug 2026 22:02:11 +0500
portprotonqt (1.3.1) unstable; urgency=medium
* New upstream release.
+1
View File
@@ -5,6 +5,7 @@ Maintainer: Ivan Mazhukin <vanyamajukin@outlook.com>
Build-Depends:
debhelper-compat (= 13),
gettext,
libsdl3-dev,
libvulkan-dev,
meson,
ninja-build,
+5 -3
View File
@@ -1,7 +1,7 @@
#!/bin/bash
set -euo pipefail
GITHUB_REPO="Boria138/PortProtonQt"
GITHUB_REPOS=("Boria138/PortProtonQt" "linux-gaming-ru/PortProtonQt")
GITEA_REPO="Linux-Gaming/PortProtonQt"
GITEA_URL="https://git.linux-gaming.ru"
@@ -10,8 +10,10 @@ jq_total='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat")
echo "=== PortProtonQt Download Stats ==="
echo
echo "--- GitHub (${GITHUB_REPO}) ---"
GH_RAW=$(curl -sf "https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=100")
echo "--- GitHub (${GITHUB_REPOS[*]}) ---"
GH_RAW=$(for repo in "${GITHUB_REPOS[@]}"; do
curl -sf "https://api.github.com/repos/${repo}/releases?per_page=100"
done | jq -s 'add')
GH_COUNT=$(echo "$GH_RAW" | jq "$jq_total")
GH_RELEASES=$(echo "$GH_RAW" | jq 'length')
echo "Releases: $GH_RELEASES"
-250
View File
@@ -1,250 +0,0 @@
#!/usr/bin/env python3
"""Run PortProtonQt against a fake AppImage update server."""
import argparse
import http.server
import os
import shutil
import subprocess
import sys
import tempfile
import threading
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
APPIMAGE_MAGIC_OFFSET = 8
APPIMAGE_TYPE_2_MAGIC = b"AI\x02"
TEST_APPIMAGE_SIZE_BYTES = 128 * 1024 * 1024
HTTP_COPY_BUFFER_SIZE = 1024 * 1024
OLD_APPIMAGE_FILLER = b"OLD-APPIMAGE-BLOCK\n"
UPDATED_APPIMAGE_FILLER = b"UPDATED-APPIMAGE-BLOCK\n"
class UpdateHandler(http.server.BaseHTTPRequestHandler):
"""Serve fake update metadata and the new AppImage."""
test_dir = Path()
server_url = ""
old_version = "0.1.12"
new_version = "1.2.0"
new_appimage_name = "PortProtonQt-1.2.0-x86_64.AppImage"
def log_message(self, fmt, *args):
print(f"[server] {fmt % args}")
def do_GET(self):
if self.path.endswith(".zsync"):
self._send_zsync()
elif self.path.startswith("/download/"):
self._send_download()
else:
self.send_error(404)
def _send_download(self) -> None:
filename = self.path.split("/download/", 1)[1]
path = self.test_dir / filename
if not path.exists():
self.send_error(404, f"Not found: {filename}")
return
self._send_file(path)
def _send_zsync(self) -> None:
filename = self.path.rsplit("/", 1)[-1]
path = self.test_dir / filename
if not path.exists():
self.send_error(404, f"Not found: {filename}")
return
self._send_file(path)
def _send_file(self, path: Path) -> None:
file_size = path.stat().st_size
byte_range = self._parse_range(file_size)
start = 0
end = file_size - 1
if byte_range is not None:
start, end = byte_range
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{end}/{file_size}")
else:
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(end - start + 1))
self.end_headers()
with open(path, "rb") as appimage_file:
appimage_file.seek(start)
remaining = end - start + 1
while remaining > 0:
chunk = appimage_file.read(min(HTTP_COPY_BUFFER_SIZE, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
def _parse_range(self, file_size: int) -> tuple[int, int] | None:
range_header = self.headers.get("Range", "")
if not range_header.startswith("bytes="):
return None
range_spec = range_header.removeprefix("bytes=").split(",", 1)[0].strip()
start_text, separator, end_text = range_spec.partition("-")
if not separator:
return None
try:
if not start_text:
length = int(end_text)
return max(file_size - length, 0), file_size - 1
start = int(start_text)
end = int(end_text) if end_text else file_size - 1
except ValueError:
return None
if start < 0 or end < start or start >= file_size:
return None
return start, min(end, file_size - 1)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("old_version", nargs="?", default="0.1.12")
parser.add_argument("new_version", nargs="?", default="1.2.0")
parser.add_argument("--port", type=int, default=0)
parser.add_argument("--keep", action="store_true")
parser.add_argument("--version", default=None,
help="Simulate current app version (e.g. --version 0.1.8)")
return parser.parse_args()
def write_appimage_base(path: Path) -> None:
true_path = shutil.which("true") or "/usr/bin/true"
shutil.copyfile(true_path, path)
path.chmod(0o755)
def update_info(server_url: str, new_appimage_name: str) -> str:
return f"zsync|{server_url}/{new_appimage_name}.zsync"
def finalize_appimage(path: Path, test_dir: Path, update_info_text: str, filler: bytes) -> None:
update_info_file = test_dir / f"{path.name}.upd_info"
update_info_file.write_text(update_info_text, encoding="utf-8")
subprocess.run(
["objcopy", "--add-section", f".upd_info={update_info_file}", str(path)],
check=True,
)
with open(path, "r+b") as appimage_file:
appimage_file.seek(APPIMAGE_MAGIC_OFFSET)
appimage_file.write(APPIMAGE_TYPE_2_MAGIC)
appimage_file.seek(0, os.SEEK_END)
while appimage_file.tell() < TEST_APPIMAGE_SIZE_BYTES:
appimage_file.write(
filler[:TEST_APPIMAGE_SIZE_BYTES - appimage_file.tell()]
)
path.chmod(0o755)
def write_zsync(test_dir: Path, server_url: str, new_appimage_name: str) -> None:
new_appimage = test_dir / new_appimage_name
zsync_file = test_dir / f"{new_appimage_name}.zsync"
download_url = f"{server_url}/download/{new_appimage_name}"
subprocess.run(
[
"zsyncmake",
"-e",
"-u",
download_url,
"-o",
str(zsync_file),
"-f",
new_appimage_name,
str(new_appimage),
],
check=True,
)
def start_server(args: argparse.Namespace, test_dir: Path, new_appimage_name: str):
server = http.server.HTTPServer(("127.0.0.1", args.port), UpdateHandler)
server_url = f"http://127.0.0.1:{server.server_port}"
UpdateHandler.test_dir = test_dir
UpdateHandler.server_url = server_url
UpdateHandler.old_version = args.old_version
UpdateHandler.new_version = args.new_version
UpdateHandler.new_appimage_name = new_appimage_name
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, server_url
def python_command() -> str:
venv_python = REPO_ROOT / ".venv" / "bin" / "python"
if venv_python.exists():
return str(venv_python)
return sys.executable
def run_gui(env: dict[str, str]) -> int:
proc = subprocess.Popen(
[python_command(), "-m", "portprotonqt.app"],
cwd=str(REPO_ROOT),
env=env,
)
return proc.wait()
def main() -> None:
args = parse_args()
if args.version:
args.old_version = args.version
test_dir = Path(tempfile.mkdtemp(prefix="ppqt-appimage-update-"))
old_appimage = test_dir / f"PortProtonQt-{args.old_version}-x86_64.AppImage"
new_appimage_name = f"PortProtonQt-{args.new_version}-x86_64.AppImage"
new_appimage = test_dir / new_appimage_name
server = None
try:
write_appimage_base(old_appimage)
write_appimage_base(new_appimage)
server, server_url = start_server(args, test_dir, new_appimage_name)
update_info_text = update_info(server_url, new_appimage_name)
finalize_appimage(
old_appimage,
test_dir,
update_info_text,
OLD_APPIMAGE_FILLER,
)
finalize_appimage(
new_appimage,
test_dir,
update_info_text,
UPDATED_APPIMAGE_FILLER,
)
write_zsync(test_dir, server_url, new_appimage_name)
env = os.environ.copy()
env["APPIMAGE"] = str(old_appimage)
env["PORTPROTONQT_VERSION"] = args.old_version
print("=== AppImage update GUI test ===")
print(f"Test dir : {test_dir}")
print(f"Old AppImage : {old_appimage}")
print(f"New AppImage : {new_appimage}")
print(f"AppImage size : {TEST_APPIMAGE_SIZE_BYTES // 1024 // 1024} MiB")
print(f"HTTP server : {server_url}")
print(f"Update info : {update_info_text}")
print("")
print("In GUI: wait for the update dialog, press Update, watch progress.")
print("After success, old AppImage should be replaced by the new file.")
print("")
raise SystemExit(run_gui(env))
finally:
if server is not None:
server.shutdown()
if args.keep:
print(f"Keeping test dir: {test_dir}")
else:
shutil.rmtree(test_dir, ignore_errors=True)
if __name__ == "__main__":
main()
Executable
+59
View File
@@ -0,0 +1,59 @@
#!/bin/sh
set -eu
REPOSITORY="Linux-Gaming/PortProtonQt"
SERVER="https://git.linux-gaming.ru"
GITHUB_REPOSITORY="linux-gaming-ru/PortProtonQt"
command -v curl >/dev/null 2>&1 || {
echo "PortProtonQt installer requires curl." >&2
exit 1
}
command -v mktemp >/dev/null 2>&1 || {
echo "PortProtonQt installer requires mktemp." >&2
exit 1
}
ARCH=$(uname -m)
case "$ARCH" in
amd64) ARCH="x86_64" ;;
arm64) ARCH="aarch64" ;;
esac
GITEA_API="${SERVER}/api/v1/repos/${REPOSITORY}/releases/latest"
GITHUB_API="https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/latest"
APPIMAGE_PATH=$(mktemp "${TMPDIR:-/tmp}/portprotonqt.XXXXXX.AppImage")
DOWNLOADED=false
trap 'rm -f "$APPIMAGE_PATH"' EXIT HUP INT TERM
for RELEASE_API in "$GITEA_API" "$GITHUB_API"; do
APPIMAGE_URL=$(curl -fsSL "$RELEASE_API" |
tr ',' '\n' |
sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\.AppImage\)".*/\1/p' |
grep -- "-${ARCH}\.AppImage$" |
head -n 1 || true)
if [ -z "$APPIMAGE_URL" ]; then
continue
fi
echo "Downloading PortProtonQt AppImage from ${APPIMAGE_URL}..."
if curl -fL --progress-bar "$APPIMAGE_URL" -o "$APPIMAGE_PATH"; then
DOWNLOADED=true
break
fi
done
if [ "$DOWNLOADED" != true ]; then
echo "Failed to download PortProtonQt AppImage for ${ARCH}." >&2
exit 1
fi
chmod 755 "$APPIMAGE_PATH"
if ! PORTPROTONQT_INTEGRATE_APPIMAGE=1 "$APPIMAGE_PATH"; then
echo "Failed to integrate PortProtonQt AppImage." >&2
exit 1
fi
echo "PortProtonQt was installed and integrated."
+8 -22
View File
@@ -1,5 +1,5 @@
project('portprotonqt', 'c',
version: '1.3.1',
version: '1.4.0',
meson_version: '>= 0.61.2',
license: 'GPL-3.0',
)
@@ -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
@@ -36,19 +36,10 @@ purelibdir_opt = get_option('python_purelibdir')
pythondir = purelibdir_opt != '' ? purelibdir_opt: python3.get_install_dir(pure: true)
pkgdatadir = pythondir / meson.project_name()
# C compiler for vk_gpu_info
# Native C build dependencies and sources
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)
gamepad_sources = files('build-aux/src/gamepad_backend.c')
# Install Python package
subdir('portprotonqt')
@@ -67,7 +58,7 @@ install_data(configured_portprotonqt,
)
# Build and install vk_gpu_info executable
vk_gpu_info_sources = files('build-aux/vk_gpu_info.c')
vk_gpu_info_sources = files('build-aux/src/vk_gpu_info.c')
vk_gpu_info_deps = [dependency('vulkan', required: true)]
executable('vk_gpu_info',
@@ -120,11 +111,6 @@ install_subdir(
install_dir: datadir / 'portproton',
)
install_subdir(
'build-aux/share/portproton/img',
install_dir: datadir / 'portproton',
)
install_subdir(
'build-aux/share/portproton/scripts/portwine_db',
install_dir: datadir / 'portproton' / 'scripts',
-2
View File
@@ -1,2 +0,0 @@
APP_VERSION = @APP_VERSION@
APP_COMMIT = @APP_COMMIT@
+55 -11
View File
@@ -1,7 +1,6 @@
import argparse
import sys
import os
import importlib
import subprocess
import threading
import urllib.error
@@ -12,16 +11,6 @@ from logging import Logger
__app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt"
try:
version_module = importlib.import_module("portprotonqt._version")
APP_COMMIT = version_module.APP_COMMIT
APP_VERSION = version_module.APP_VERSION
except ImportError:
APP_COMMIT = ""
APP_VERSION = "1.3.1"
__app_version__ = os.environ.get("PORTPROTONQT_VERSION", APP_VERSION)
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import (
QApplication,
@@ -60,6 +49,20 @@ 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_VERSION = "1.4.0"
try:
APP_COMMIT = subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"],
stderr=subprocess.DEVNULL,
).decode("utf-8").strip()
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
APP_COMMIT = ""
__app_version__ = APP_VERSION
COUNTER_DOWNLOAD_URL = "http://cloud.linux-gaming.ru:8081/api/download/{version}"
def get_version():
@@ -198,6 +201,24 @@ def is_restore_prefix_request(args: argparse.Namespace) -> bool:
def main():
parsed_args = parse_args()
if os.environ.get("PORTPROTONQT_INTEGRATE_APPIMAGE") == "1":
from portprotonqt.appimage_integration import integrate_appimage
setup_logger(parsed_args.debug_level)
try:
destination = integrate_appimage()
restart_env = os.environ.copy()
restart_env.pop("PORTPROTONQT_INTEGRATE_APPIMAGE", None)
subprocess.Popen(
[str(destination), *sys.argv[1:]],
env=restart_env,
start_new_session=True,
)
except (OSError, subprocess.SubprocessError, KeyError) as error:
get_logger(__name__).error("Failed to integrate AppImage: %s", error)
return 1
return 0
# Handle --reinstall-steam-compat-tool flag
if parsed_args.reinstall_steam_compat_tool:
success = reinstall_steam_compat_tool()
@@ -328,11 +349,13 @@ def main():
from PySide6.QtCore import QThread, Signal
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from requests import RequestException
from portprotonqt.appimage_updater import (
APPIMAGE_UPDATE_START_DELAY_MS,
AppImageUpdateWorker,
)
from portprotonqt.main_window import MainWindow
from portprotonqt.gog_api import GOGAPI
from portprotonqt.port_data_path_selector import ask_portdata_path, is_portdata_path_read_write
from portprotonqt.portproton_api import (
PortProtonAPI,
@@ -675,6 +698,27 @@ def main():
window.appimageUpdateWorker.finished.connect(window.appimageUpdateWorker.deleteLater)
QTimer.singleShot(APPIMAGE_UPDATE_START_DELAY_MS, window.appimageUpdateWorker.start)
class GOGDLUpdateWorker(QThread):
"""Update gogdl without blocking the UI thread."""
def __init__(self, api: GOGAPI) -> None:
super().__init__()
self.api = api
def run(self) -> None:
try:
self.api.update_gogdl()
except (OSError, RequestException):
logger.exception("Failed to update gogdl")
gogdl_update_worker = GOGDLUpdateWorker(window.gog_api)
window.gogdlUpdateWorker = gogdl_update_worker
gogdl_update_worker.finished.connect(
lambda: setattr(window, "gogdlUpdateWorker", None)
)
gogdl_update_worker.finished.connect(gogdl_update_worker.deleteLater)
gogdl_update_worker.start()
# Execute the initial PortProton command after the UI is set up
class InitialCommandWorker(QThread):
"""Worker thread to run initial PortProton command without blocking UI."""
+30 -25
View File
@@ -6,8 +6,9 @@ import shutil
import subprocess
from pathlib import Path
from PySide6.QtCore import QThread, Signal
from PySide6.QtCore import QStandardPaths, QThread, Signal
from portprotonqt.config import get_portproton_location, migrate_legacy_shortcut
from portprotonqt.localization import _
from portprotonqt.logger import get_logger
@@ -53,10 +54,10 @@ def _remove_existing_integration(applications_dir: Path, icon_dir: Path) -> None
(icon_dir / f"{APP_ID}{suffix}").unlink(missing_ok=True)
def _rewrite_main_desktop(content: str, appimage: Path, icon: Path) -> str:
def _rewrite_main_desktop(content: str, appimage: Path) -> str:
exec_line = f"Exec={shlex.join([str(appimage), '%u'])}"
content = re.sub(r"^Exec=.*$", exec_line, content, count=1, flags=re.MULTILINE)
content = re.sub(r"^Icon=.*$", f"Icon={icon}", content, flags=re.MULTILINE)
content = re.sub(r"^Icon=.*$", f"Icon={APP_ID}", content, flags=re.MULTILINE)
if re.search(r"^TryExec=", content, flags=re.MULTILINE):
return re.sub(
r"^TryExec=.*$",
@@ -68,7 +69,7 @@ def _rewrite_main_desktop(content: str, appimage: Path, icon: Path) -> str:
return content.replace("Type=Application\n", f"Type=Application\nTryExec={appimage}\n", 1)
def _mode_desktop(mode: str, appimage: Path, icon: Path) -> str:
def _mode_desktop(mode: str, appimage: Path) -> str:
action = _("Run in logging mode") if mode == "log" else _("Run in silent mode")
exec_line = shlex.join([str(appimage), f"--{mode}", "%u"])
return (
@@ -79,7 +80,7 @@ def _mode_desktop(mode: str, appimage: Path, icon: Path) -> str:
"Type=Application\n"
f"Comment={action}\n"
"Terminal=false\n"
f"Icon={icon}\n"
f"Icon={APP_ID}\n"
f"StartupWMClass={APP_ID}\n"
"Categories=Game;\n"
"NoDisplay=true\n"
@@ -92,17 +93,21 @@ def _install_desktop_files(
source: Path,
applications_dir: Path,
appimage: Path,
icon: Path,
) -> None:
content = source.read_text(encoding="utf-8")
rewritten_content = _rewrite_main_desktop(content, appimage)
main_path = applications_dir / f"{APP_ID}.desktop"
main_path.write_text(
_rewrite_main_desktop(content, appimage, icon),
encoding="utf-8",
main_path.write_text(rewritten_content, encoding="utf-8")
desktop_dir = Path(
QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
)
desktop_dir.mkdir(parents=True, exist_ok=True)
desktop_path = desktop_dir / f"{APP_ID}.desktop"
desktop_path.write_text(rewritten_content, encoding="utf-8")
desktop_path.chmod(EXECUTABLE_MODE)
for mode in INTEGRATION_MODES:
path = applications_dir / f"{APP_ID}.{mode}.desktop"
path.write_text(_mode_desktop(mode, appimage, icon), encoding="utf-8")
path.write_text(_mode_desktop(mode, appimage), encoding="utf-8")
def _set_default_mime_handlers() -> None:
@@ -130,17 +135,8 @@ def _get_appimage_metadata(appdir: Path) -> tuple[Path, Path]:
f"share/applications/{APP_ID}.desktop",
),
)
icon = _find_appimage_asset(
appdir,
(
".DirIcon",
f"{APP_ID}.svg",
f"{APP_ID}.png",
f"usr/share/icons/hicolor/scalable/apps/{APP_ID}.svg",
f"share/icons/hicolor/scalable/apps/{APP_ID}.svg",
),
)
if not desktop or not icon:
icon = appdir / f"{APP_ID}.svg"
if not desktop or not icon.is_file():
raise FileNotFoundError("AppImage desktop metadata is incomplete")
return desktop, icon
@@ -156,18 +152,25 @@ def integrate_appimage() -> Path:
appimages_dir = Path.home() / "AppImages"
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
applications_dir = data_home / "applications"
icon_dir = appimages_dir / ".icons"
icon_dir = data_home / "icons"
appimages_dir.mkdir(parents=True, exist_ok=True)
applications_dir.mkdir(parents=True, exist_ok=True)
icon_dir.mkdir(parents=True, exist_ok=True)
destination = appimages_dir / "portprotonqt.appimage"
resolved_icon = icon_source.resolve()
icon = icon_dir / f"{APP_ID}{resolved_icon.suffix or '.svg'}"
_remove_existing_integration(applications_dir, icon_dir)
icon = icon_dir / f"{APP_ID}.svg"
_copy_appimage(source, destination)
legacy_appimage = Path.home() / "Applications/PortProtonQt.AppImage"
if legacy_appimage.resolve() != destination.resolve():
portproton_location = get_portproton_location()
migrate_legacy_shortcut(
portproton_location or "",
launcher_command=[str(destination), "--silent"],
)
_remove_existing_integration(applications_dir, icon_dir)
shutil.copy2(resolved_icon, icon)
_install_desktop_files(desktop_source, applications_dir, destination, icon)
_install_desktop_files(desktop_source, applications_dir, destination)
_set_default_mime_handlers()
try:
subprocess.run(
@@ -177,6 +180,8 @@ def integrate_appimage() -> Path:
)
except (FileNotFoundError, subprocess.SubprocessError) as error:
logger.warning("Failed to update desktop database: %s", error)
if legacy_appimage.resolve() != destination.resolve():
legacy_appimage.unlink(missing_ok=True)
return destination
+25 -29
View File
@@ -28,15 +28,15 @@ APPIMAGE_UPDATE_CHECK_TIMEOUT = 120
APPIMAGE_UPDATE_TIMEOUT = 900
APPIMAGE_UPDATE_START_DELAY_MS = 5000
EXECUTABLE_MODE = 0o755
FALLBACK_UPDATE_INFO = (
"gh-releases-zsync|Boria138|PortProtonQt|latest|*x86_64.AppImage.zsync"
GITHUB_UPDATE_INFO = (
"gh-releases-zsync|linux-gaming-ru|PortProtonQt|latest|*x86_64.AppImage.zsync"
)
CHANGELOG_URL = (
"https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/branch/main/"
"CHANGELOG.md"
)
CHANGELOG_FALLBACK_URL = (
"https://raw.githubusercontent.com/Boria138/PortProtonQt/refs/heads/main/"
CHANGELOG_GITHUB_URL = (
"https://raw.githubusercontent.com/linux-gaming-ru/PortProtonQt/refs/heads/main/"
"CHANGELOG.md"
)
CHANGELOG_TIMEOUT = 10
@@ -129,16 +129,22 @@ def _run_appimageupdatetool(
def _download_changelog() -> str:
from portprotonqt.portproton_api import get_user_conf_setting
session = get_requests_session()
for url in (CHANGELOG_URL, CHANGELOG_FALLBACK_URL):
try:
with session.get(url, timeout=CHANGELOG_TIMEOUT) as response:
response.raise_for_status()
data = response.content[:CHANGELOG_MAX_CHARS]
return data.decode("utf-8", errors="replace")
except (OSError, requests.RequestException) as error:
logger.debug("Failed to download changelog from %s: %s", url, error)
return ""
url = (
CHANGELOG_URL
if get_user_conf_setting("MIRROR") == "CLOUD"
else CHANGELOG_GITHUB_URL
)
try:
with session.get(url, timeout=CHANGELOG_TIMEOUT) as response:
response.raise_for_status()
data = response.content[:CHANGELOG_MAX_CHARS]
return data.decode("utf-8", errors="replace")
except (OSError, requests.RequestException) as error:
logger.debug("Failed to download changelog from %s: %s", url, error)
return ""
def _extract_latest_version_changelog(changelog: str, current_version: str = "") -> str:
@@ -306,15 +312,9 @@ class AppImageUpdateWorker(QThread):
self.update_info = previous_info
return result
def _is_check_failure(self, result: subprocess.CompletedProcess[str] | None) -> bool:
if result is None:
return True
if result.returncode not in (0, 1):
return True
output = f"{result.stdout}\n{result.stderr}"
return " Error:" in output or "Failed to fetch" in output
def run(self) -> None:
from portprotonqt.portproton_api import get_user_conf_setting
appimage_path = _appimage_path()
if not ui_config.get_auto_appimage_updates() or not appimage_path:
return
@@ -330,14 +330,10 @@ class AppImageUpdateWorker(QThread):
self._run_update(tool_path, appimage_path)
return
update_infos = ["", FALLBACK_UPDATE_INFO]
check_code = None
selected_info = ""
for update_info in update_infos:
check_code = self._check_for_update(tool_path, appimage_path, update_info)
if not self._is_check_failure(check_code):
selected_info = update_info
break
selected_info = (
"" if get_user_conf_setting("MIRROR") == "CLOUD" else GITHUB_UPDATE_INFO
)
check_code = self._check_for_update(tool_path, appimage_path, selected_info)
if check_code is None or check_code.returncode != 1:
return
+4 -2
View File
@@ -428,6 +428,10 @@ def _compatibility_suggestions(
needs_dotnet = needs_dotnet or "WPF" in findings.get("Frameworks", [])
if needs_dotnet and prefix_name != "DOTNET":
suggestions.append("Use the DOTNET prefix.")
installers = findings.get("Installer", [])
wine_version = environment.get("PW_WINE_USE", "")
if installers and not wine_version.startswith("WINE_LG"):
suggestions.append("Use WINE_LG for installers.")
winetricks_log = environment.get("PW_WINETRICKS_LOG", "").lower()
missing_vcrun = [
components[-1]
@@ -500,8 +504,6 @@ def analyze_launch(launch: CompatibilityLaunch, portproton_path: str) -> str:
glibc_32_compatibility, glibc_32_suggestions = _glibc_32_compatibility()
suggestions.extend(glibc_32_suggestions)
lines = [
"Compatibility report",
"",
f"Executable: {launch.executable}",
f"Exit code: {launch.exit_code if launch.exit_code is not None else 'unknown'}",
f"Runtime before exit: {launch.duration:.1f} s",
+10 -1
View File
@@ -1,6 +1,6 @@
"""Game-related configuration settings."""
from portprotonqt.config.base import BaseConfig
from portprotonqt.config.validators import validate_string
from portprotonqt.config.validators import validate_bool, validate_string
class GameConfig(BaseConfig):
@@ -26,6 +26,15 @@ class GameConfig(BaseConfig):
validate_string(filter_value, "display_filter", min_len=1, max_len=50)
self._save_value("display_filter", filter_value, "str")
def get_only_installed(self) -> bool:
"""Get whether only installed games are displayed."""
return self._get_bool("only_installed", False)
def set_only_installed(self, enabled: bool) -> None:
"""Set whether only installed games are displayed."""
validate_bool(enabled, "only_installed")
self._save_value("only_installed", enabled, "bool")
def get_steam_account_id(self) -> str:
"""Get selected Steam account ID or automatic selection."""
return self._get_str("steam_account_id", "auto")
+17 -3
View File
@@ -223,7 +223,16 @@ def _extract_launcher_tail(parts: list[str]) -> list[str] | None:
return None
command_name = os.path.basename(parts[0])
if parts[0].lower().endswith(".appimage") or command_name == "portprotonqt" or command_name == "start.sh":
appimage_name = command_name.casefold()
is_portproton_appimage = re.fullmatch(
r"portproton(?:qt)?(?:[-_.].*)?\.appimage",
appimage_name,
) is not None
if (
is_portproton_appimage
or command_name == "portprotonqt"
or command_name == "start.sh"
):
tail = parts[1:]
if tail[:1] == ["--silent"]:
tail = tail[1:]
@@ -274,7 +283,11 @@ def _get_desktop_paths(desktop_dir: str | None) -> tuple[str, ...]:
return (os.path.join(os.path.expanduser("~"), "Desktop"),)
def migrate_legacy_shortcut(portproton_path: str, desktop_dir: str | None = None) -> int:
def migrate_legacy_shortcut(
portproton_path: str,
desktop_dir: str | None = None,
launcher_command: list[str] | None = None,
) -> int:
"""Migrate legacy PortProton shortcuts in known desktop directories."""
flatpak_id = os.getenv("FLATPAK_ID", "").strip()
user_home = os.path.expanduser("~")
@@ -293,7 +306,8 @@ def migrate_legacy_shortcut(portproton_path: str, desktop_dir: str | None = None
except OSError as error:
logger.warning("Failed to read legacy PortProton config %s: %s", PORTPROTON_CONFIG_FILE, error)
launcher_command = _get_current_launcher_command()
if launcher_command is None:
launcher_command = _get_current_launcher_command()
desktop_paths = (
portproton_path,
os.path.join(user_home, ".local", "share", "applications"),
+9
View File
@@ -348,6 +348,15 @@ class UIConfig(BaseConfig):
validate_bool(hide, "hide_autoinstall_tab")
self._save_value("hide_autoinstall_tab", hide, "bool")
def get_hide_control_hints(self) -> bool:
"""Get hide control hints setting."""
return self._get_bool("hide_control_hints", False)
def set_hide_control_hints(self, hide: bool) -> None:
"""Set hide control hints setting."""
validate_bool(hide, "hide_control_hints")
self._save_value("hide_control_hints", hide, "bool")
def get_badge_view_mode(self) -> str:
"""Get badge view mode ('detailed' or 'compact')."""
mode = self._get_str("badge_view_mode", "detailed")
+7 -4
View File
@@ -456,6 +456,7 @@ class ContextMenuManager:
menu = QMenu(self.parent)
menu.setWindowFlags(menu.windowFlags() | Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint)
menu.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
# For non-Steam games, check if exe exists
@@ -466,7 +467,7 @@ class ContextMenuManager:
# Show only "Delete from PortProton" if no valid exe
delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
menu.exec(game_card.mapToGlobal(pos))
menu.popup(game_card.mapToGlobal(pos))
return
# Normal menu for games with valid exe or from Steam
@@ -632,7 +633,7 @@ class ContextMenuManager:
if actions:
menu.setActiveAction(actions[0])
menu.exec(game_card.mapToGlobal(pos))
menu.popup(game_card.mapToGlobal(pos))
def _launch_game(self, game_card):
"""
@@ -897,12 +898,14 @@ class ContextMenuManager:
if not exe_path:
return
from portprotonqt.time_utils import get_statistics_path
from portprotonqt.time_utils import get_statistics_path, remove_last_launch
statistics_file = get_statistics_path()
target_path = os.path.normpath(exe_path)
exe_name = os.path.splitext(os.path.basename(target_path))[0]
remove_last_launch(exe_name)
if not os.path.exists(statistics_file):
return
target_path = os.path.normpath(exe_path)
kept_lines = []
removed = False
+9 -4
View File
@@ -135,7 +135,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
y += int(row_max_height * global_scale) + spacing
i = j
return result, y
return result, y - spacing
class FlowLayout(QLayout):
@@ -160,7 +160,7 @@ class FlowLayout(QLayout):
for i, item in enumerate(self.itemList):
widget = item.widget()
if widget and widget.isVisible():
if widget and not widget.isHidden():
visible_items.append(item)
visible_indices.append(i)
s = item.sizeHint()
@@ -191,6 +191,11 @@ class FlowLayout(QLayout):
self._invalidate_cache()
super().invalidate()
def setSpacing(self, spacing: int) -> None:
self._spacing = spacing
self._invalidate_cache()
super().setSpacing(spacing)
def count(self) -> int:
return len(self.itemList)
@@ -671,7 +676,7 @@ class AutoSizeButton(QPushButton):
if icon_color and self._icon_name:
colored_path = self.theme_manager.get_colored_icon_path(
self._icon_name,
icon_path,
icon_color,
self.current_theme_name
)
@@ -837,7 +842,7 @@ class AutoHideScrollArea(QScrollArea):
self.fade_duration_ms = fade_duration_ms
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
self.setStyleSheet("background: transparent;")
self.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
self._v_scrollbar = self.verticalScrollBar()
self._v_scrollbar.installEventFilter(self)
+5 -4
View File
@@ -19,15 +19,16 @@ def get_cached_vk_gpu_info() -> str:
if _vk_gpu_info_output is None:
try:
dev_scripts_path = os.path.join(
vk_gpu_info_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
"dev-scripts",
"build-aux",
"bin",
"vk_gpu_info"
)
if os.path.exists(dev_scripts_path):
if os.path.exists(vk_gpu_info_path):
result = subprocess.run(
[dev_scripts_path],
[vk_gpu_info_path],
capture_output=True,
text=True,
timeout=10,
+1 -1
View File
@@ -322,7 +322,7 @@ def generate_system_info(
lines.append(_("Debug log mode was launched"))
lines.append(
_("To diagnose the problem, copy the ENTIRE log to the site:") +
" https://linux-gaming.ru/t/opisanie-kategorii-portproton-pomoshh/1642"
" https://linux-gaming.ru/forum/help/portprotonqt-pomosch"
)
lines.append("-" * 61)
+10 -6
View File
@@ -118,7 +118,16 @@ def _reveal_cover(image_label: QLabel, theme) -> None:
effect = QGraphicsOpacityEffect(image_label)
image_label.setGraphicsEffect(effect)
duration = theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
animation_type = theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
duration_key = "detail_page_fade_duration"
default_duration = 350
if animation_type.startswith("slide_"):
duration_key = "detail_page_slide_duration"
default_duration = 500
elif animation_type == "bounce":
duration_key = "detail_page_bounce_duration"
default_duration = 400
duration = theme.GAME_CARD_ANIMATION.get(duration_key, default_duration)
animation = QPropertyAnimation(effect, QByteArray(b"opacity"), image_label)
animation.setDuration(duration)
animation.setStartValue(effect.opacity())
@@ -644,11 +653,6 @@ def _check_desktop_file(
return autoinstall_exe.lower() in (exec_name, real_exec_name)
def _get_autoinstall_exe_name(script_name: str) -> str:
autoinstall_exe, _autoinstall_path = _get_autoinstall_exe_target(script_name)
return autoinstall_exe
def _get_autoinstall_exe_target(script_name: str) -> tuple[str, str]:
script_path = script_name
if not os.path.isfile(script_path):
-22
View File
@@ -323,28 +323,6 @@ def create_protondb_badge(
return badge, True
def create_steam_badge(
parent: QWidget,
appid: str,
main_window,
) -> ClickableLabel:
"""Create Steam badge."""
steam_icon = main_window.theme_manager.get_icon("badge_steam", as_path=True)
badge = ClickableLabel(
"Steam",
icon=steam_icon,
parent=parent,
icon_size=getattr(main_window.theme, "BADGE", {}).get("icon_size", 16),
icon_space=5,
)
badge.setStyleSheet(main_window.theme.STEAM_BADGE_STYLE)
_apply_badge_view_mode(badge, main_window.theme)
badge.clicked.connect(
lambda: QDesktopServices.openUrl(QUrl(f"https://steamcommunity.com/app/{appid}"))
)
return badge
def create_portproton_badge(
parent: QWidget,
main_window,
+1
View File
@@ -79,6 +79,7 @@ class AppImageUpdateDialog(DraggableDialog):
if self.input_manager:
self.input_manager.enable_appimage_update_mode(self)
self.finished.connect(self.input_manager.disable_appimage_update_mode)
def _make_button(self, text: str) -> AutoSizeButton:
button = AutoSizeButton(text)
+68 -13
View File
@@ -8,9 +8,13 @@ from PIL import Image, ImageQt
from PySide6.QtGui import QPixmap, QIcon, QImage, QImageReader
from PySide6.QtWidgets import (
QVBoxLayout, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QScrollArea, QWidget, QScroller, QApplication, QSizePolicy
QScrollArea, QWidget, QScroller, QApplication, QSizePolicy, QStyle,
QStyledItemDelegate, QStyleOptionViewItem
)
from PySide6.QtCore import (
Qt, QObject, Signal, QMimeDatabase, QModelIndex, QPersistentModelIndex,
QThreadPool, QRunnable, Slot, QSize, QTimer
)
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QThreadPool, QRunnable, Slot, QSize, QTimer
if TYPE_CHECKING:
from portprotonqt.main_window import MainWindow
@@ -32,6 +36,25 @@ DISC_IMAGE_EXTENSIONS = (".iso", ".mdf", ".nrg")
THUMBNAIL_PRELOAD_ROWS = 5
THUMBNAIL_QUEUE_LIMIT = 24
THUMBNAIL_SCROLL_DELAY_MS = 80
THEMED_ICON_ROLE = Qt.ItemDataRole.UserRole.value + 1
class FileIconDelegate(QStyledItemDelegate):
"""Draw themed list icons in their active mode on hover."""
def initStyleOption(
self,
option: QStyleOptionViewItem,
index: QModelIndex | QPersistentModelIndex,
) -> None:
super().initStyleOption(option, index)
if not index.data(THEMED_ICON_ROLE):
return
if not option.state & QStyle.StateFlag.State_MouseOver:
return
pixmap = option.icon.pixmap(option.decorationSize, QIcon.Mode.Active)
if not pixmap.isNull():
option.icon = QIcon(pixmap)
class FileExplorer(DraggableDialog):
@@ -170,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):
@@ -225,15 +248,41 @@ class FileExplorer(DraggableDialog):
self.thumbnail_cache[file_path] = icon
item = self.file_items.get(file_path)
if item:
item.setData(THEMED_ICON_ROLE, None)
item.setIcon(icon)
def _get_file_type_icon(self, file_path: str) -> QIcon | None:
icon_name = THEMED_LAUNCH_ICON_NAMES.get(os.path.splitext(file_path)[1].lower())
if not icon_name:
return None
icon = theme_manager.get_icon(icon_name)
icon = self._get_list_icon(icon_name)
return icon if isinstance(icon, QIcon) and not icon.isNull() else None
def _get_list_icon(self, icon_name: str) -> QIcon:
icon_path = theme_manager.get_icon(icon_name, as_path=True)
if not isinstance(icon_path, str) or not os.path.exists(icon_path):
return QIcon()
icon = QIcon()
icon.addFile(icon_path, QSize(), QIcon.Mode.Normal)
colors = getattr(self.theme, "ICON_COLORS", {})
if not isinstance(colors, dict):
return icon
modes = {
"hover": QIcon.Mode.Active,
"focused": QIcon.Mode.Selected,
"disabled": QIcon.Mode.Disabled,
}
for state, mode in modes.items():
color = colors.get(f"{icon_name}_{state}", colors.get(f"*_{state}"))
if not isinstance(color, str):
continue
colored_path = theme_manager.get_colored_icon_path(icon_name, color)
if colored_path:
icon.addFile(colored_path, QSize(), mode)
return icon
def schedule_thumbnail_loading(self) -> None:
"""Schedule thumbnail loading after scroll events settle."""
self.thumbnail_timer.start(THUMBNAIL_SCROLL_DELAY_MS)
@@ -309,6 +358,7 @@ class FileExplorer(DraggableDialog):
self.main_layout.addWidget(self.path_label)
self.file_list = QListWidget()
self.file_list.setItemDelegate(FileIconDelegate(self.file_list))
self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE + self.theme.SCROLL_STYLE)
self.file_list.itemClicked.connect(self.handle_item_click)
self.file_list.itemDoubleClicked.connect(self.handle_item_double_click)
@@ -530,15 +580,17 @@ class FileExplorer(DraggableDialog):
try:
if self.directory_only:
item = QListWidgetItem("./")
folder_icon = theme_manager.get_icon("folder")
folder_icon = self._get_list_icon("folder")
if isinstance(folder_icon, QIcon):
item.setIcon(folder_icon)
item.setData(THEMED_ICON_ROLE, True)
self.file_list.addItem(item)
if self.current_path != "/":
item = QListWidgetItem("../")
folder_icon = theme_manager.get_icon("folder")
folder_icon = self._get_list_icon("folder")
if isinstance(folder_icon, QIcon):
item.setIcon(folder_icon)
item.setData(THEMED_ICON_ROLE, True)
self.file_list.addItem(item)
items = os.listdir(self.current_path)
@@ -546,9 +598,10 @@ class FileExplorer(DraggableDialog):
for d in sorted(dirs):
item = QListWidgetItem(f"{d}/")
folder_icon = theme_manager.get_icon("folder")
folder_icon = self._get_list_icon("folder")
if isinstance(folder_icon, QIcon):
item.setIcon(folder_icon)
item.setData(THEMED_ICON_ROLE, True)
self.file_list.addItem(item)
if not self.directory_only:
@@ -566,6 +619,7 @@ class FileExplorer(DraggableDialog):
file_type_icon = self._get_file_type_icon(file_path)
if file_type_icon:
item.setIcon(file_type_icon)
item.setData(THEMED_ICON_ROLE, True)
if (
f.lower().endswith(".ppack")
or mime_type.name() == "application/x-portproton-prefix-backup"
@@ -573,9 +627,10 @@ class FileExplorer(DraggableDialog):
or mime_type.name() in ("application/vnd.squashfs", "application/x-squashfs")
or mime_type.inherits("application/vnd.squashfs")
):
ppack_icon = theme_manager.get_icon("ppack")
ppack_icon = self._get_list_icon("ppack")
if isinstance(ppack_icon, QIcon):
item.setIcon(ppack_icon)
item.setData(THEMED_ICON_ROLE, True)
self.file_items[file_path] = item
self.file_list.addItem(item)
+190 -77
View File
@@ -11,8 +11,9 @@ from PySide6.QtWidgets import (
QHBoxLayout, QProgressBar, QFrame, QSizePolicy, QAbstractItemView,
QStackedWidget, QPushButton
)
from PySide6.QtCore import Qt, QTimer, QEvent, QObject
from PySide6.QtGui import QGuiApplication
from PySide6.QtCore import QEvent, QMimeData, QObject, Qt, QTimer, QUrl
from PySide6.QtGui import QDesktopServices, QDragEnterEvent, QDropEvent, QGuiApplication
from shiboken6 import isValid
from portprotonqt.config import get_portproton_start_command, ui_config
from portprotonqt.dialogs.base import DraggableDialog
@@ -21,7 +22,9 @@ from portprotonqt.theme_manager import ThemeManager
from portprotonqt.localization import _
from portprotonqt.version_utils import version_sort_key
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
from portprotonqt.dialogs.wine_loader import WineLoadingThread, get_cpu_level
from portprotonqt.dialogs.wine_loader import (
InstalledWineSizeThread, WineLoadingThread, get_cpu_level,
)
from portprotonqt.dialogs.wine_downloader import DownloadThread
from portprotonqt.dialogs.wine_extractor import ExtractionThread
from portprotonqt.preloader import Preloader
@@ -29,6 +32,8 @@ from portprotonqt.steam_api import get_steam_compatibilitytools_dir, get_steam_h
logger = get_logger(__name__)
theme_manager = ThemeManager()
WINE_TABLE_ROW_BATCH_SIZE = 8
WINE_ARCHIVE_EXTENSIONS = ('.tar.xz', '.tar.gz')
class ProtonManager(DraggableDialog):
@@ -47,6 +52,8 @@ class ProtonManager(DraggableDialog):
self.input_manager = input_manager
self.initial_command_executed = False
self.wine_loading_thread = None
self._installed_size_tables = {}
self.setAcceptDrops(True)
self.main_window = None
parent_widget = self.parent()
@@ -62,6 +69,48 @@ class ProtonManager(DraggableDialog):
if self.input_manager:
self.enable_proton_manager_mode()
@staticmethod
def _get_dropped_wine_archives(mime_data: QMimeData) -> list[str]:
if not mime_data.hasUrls():
return []
archive_paths = []
for url in mime_data.urls():
if not url.isLocalFile():
continue
archive_path = url.toLocalFile()
if os.path.isfile(archive_path) and archive_path.lower().endswith(
WINE_ARCHIVE_EXTENSIONS
):
archive_paths.append(archive_path)
return archive_paths
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
if self._get_dropped_wine_archives(event.mimeData()):
event.acceptProposedAction()
return
event.ignore()
def dropEvent(self, event: QDropEvent) -> None:
archive_paths = self._get_dropped_wine_archives(event.mimeData())
if not archive_paths or self.is_downloading:
event.ignore()
return
self.install_local_archives(archive_paths)
event.acceptProposedAction()
def install_local_archives(self, archive_paths: list[str]) -> None:
self.assets_to_download = [
{
'source_name': 'proton_lg',
'asset_name': os.path.basename(path),
'local_path': path,
}
for path in archive_paths
]
self.current_download_index = 0
self.is_downloading = True
self.start_next_download()
def _init_gamepad_tooltip(self) -> None:
self._gamepad_tooltip_map = {}
self.gamepad_tooltip = QLabel()
@@ -213,34 +262,45 @@ class ProtonManager(DraggableDialog):
self.clear_btn = QPushButton(_('Clear All'))
self.clear_btn.clicked.connect(self.clear_selection)
self.clear_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.open_folder_btn = QPushButton(_('Open Folder'))
self.open_folder_btn.clicked.connect(self._open_wine_folder)
self.open_folder_btn.setEnabled(bool(self.portproton_location))
self.open_folder_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_btn_dialog = QPushButton(_('Cancel'))
self.cancel_btn_dialog.clicked.connect(self.reject)
self.cancel_btn_dialog.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button_layout.addWidget(self.download_btn)
button_layout.addWidget(self.clear_btn)
button_layout.addWidget(self.open_folder_btn)
button_layout.addWidget(self.cancel_btn_dialog)
layout.addLayout(button_layout)
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
)
def _open_wine_folder(self) -> None:
if not self.portproton_location:
return
wine_folder = os.path.join(self.portproton_location, "data", "dist")
try:
os.makedirs(wine_folder, exist_ok=True)
except OSError as error:
logger.warning("Failed to create Wine folder %s: %s", wine_folder, error)
return
if not QDesktopServices.openUrl(QUrl.fromLocalFile(wine_folder)):
logger.warning("Failed to open Wine folder %s", wine_folder)
def start_loading_wine_data(self):
self.wine_loading_thread = WineLoadingThread()
self.wine_loading_thread.loading_complete.connect(self.on_wine_data_loaded)
@@ -249,9 +309,47 @@ class ProtonManager(DraggableDialog):
def on_wine_data_loaded(self, metadata):
self.wine_metadata = metadata
self.refresh_wine_tabs()
if hasattr(self, 'content_stack'):
self.content_stack.setCurrentIndex(1)
self._start_incremental_wine_tabs(metadata)
def _start_incremental_wine_tabs(self, metadata: dict) -> None:
self.cpu_level = get_cpu_level()
logger.info("Detected CPU level: %s", self.cpu_level)
tabs = []
for source_name, entries in metadata.items():
filtered_entries = self.filter_entries_by_cpu_level(entries, source_name)
tabs.append((source_name, sorted(filtered_entries, key=version_sort_key)))
tabs.sort(key=lambda item: (item[0] != 'proton_lg', item[0]))
self.selected_assets.clear()
self.tab_widget.clear()
self._wine_tab_queue = tabs
self._wine_current_tab = None
QTimer.singleShot(0, self._add_next_wine_row_batch)
def _add_next_wine_row_batch(self) -> None:
if self._wine_current_tab is None:
if not self._wine_tab_queue:
self.create_installed_tab()
self.update_selection_display()
self.content_stack.setCurrentIndex(1)
return
source_name, entries = self._wine_tab_queue.pop(0)
table = self._create_empty_wine_tab(source_name, len(entries))
self._wine_current_tab = (source_name, entries, table, 0)
source_name, entries, table, row_index = self._wine_current_tab
batch_end = min(row_index + WINE_TABLE_ROW_BATCH_SIZE, len(entries))
table.setUpdatesEnabled(False)
try:
for index in range(row_index, batch_end):
self.add_asset_row_from_json(table, index, entries[index], source_name)
finally:
table.setUpdatesEnabled(True)
if batch_end == len(entries):
logger.info("Successfully created tab for %s", source_name)
self._wine_current_tab = None
else:
self._wine_current_tab = (source_name, entries, table, batch_end)
QTimer.singleShot(0, self._add_next_wine_row_batch)
def on_wine_data_load_error(self, error_msg):
logger.error(f"Wine data loading failed: {error_msg}")
@@ -341,12 +439,6 @@ class ProtonManager(DraggableDialog):
def create_tab_from_entries(self, source_name, entries):
try:
tab = QWidget()
tab.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
layout = QVBoxLayout(tab)
layout.setContentsMargins(5, 5, 5, 5)
layout.setSpacing(5)
table = self.create_table_widget()
all_entries = []
for entry in entries:
url = entry.get('url', '')
@@ -357,18 +449,28 @@ class ProtonManager(DraggableDialog):
entry['filename'] = url_filename
all_entries.append(entry)
all_entries.sort(key=version_sort_key)
table.setRowCount(len(all_entries))
table = self._create_empty_wine_tab(source_name, len(all_entries))
for row_index, entry in enumerate(all_entries):
self.add_asset_row_from_json(table, row_index, entry, source_name)
layout.addWidget(table, 1)
tab_name = (self.get_short_source_name(source_name) or "UNKNOWN").upper()
self.tab_widget.addTab(tab, tab_name)
logger.info(f"Successfully created tab for {source_name}")
return True
except Exception as e:
logger.error(f"Error creating tab for {source_name}: {e}")
return False
def _create_empty_wine_tab(self, source_name: str, row_count: int) -> QTableWidget:
tab = QWidget()
tab.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
layout = QVBoxLayout(tab)
layout.setContentsMargins(5, 5, 5, 5)
layout.setSpacing(5)
table = self.create_table_widget()
table.setRowCount(row_count)
layout.addWidget(table, 1)
tab_name = (self.get_short_source_name(source_name) or "UNKNOWN").upper()
self.tab_widget.addTab(tab, tab_name)
return table
def get_short_source_name(self, full_name):
if full_name is None:
return "UNKNOWN"
@@ -506,6 +608,24 @@ class ProtonManager(DraggableDialog):
self.add_installed_row(table, row_index, version_name, version_path)
layout.addWidget(table, 1)
self.tab_widget.addTab(tab, _("Installed"))
size_thread = InstalledWineSizeThread(installed_versions, self)
self._installed_size_tables[size_thread] = table
size_thread.sizes_loaded.connect(self._on_installed_sizes_loaded)
size_thread.finished.connect(self._on_installed_size_thread_finished)
size_thread.finished.connect(size_thread.deleteLater)
size_thread.start()
def _on_installed_sizes_loaded(self, sizes: list[str]) -> None:
table = self._installed_size_tables.get(self.sender())
if table is None or not isValid(table):
return
for row_index, size in enumerate(sizes):
size_item = table.item(row_index, 2)
if size_item is not None and size:
size_item.setText(size)
def _on_installed_size_thread_finished(self) -> None:
self._installed_size_tables.pop(self.sender(), None)
def get_installed_versions(self) -> list[tuple[str, str]]:
portproton_location = self.portproton_location
@@ -562,8 +682,7 @@ class ProtonManager(DraggableDialog):
version_item = QTableWidgetItem(version_name)
version_item.setFlags(version_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
table.setItem(row_index, 1, version_item)
size_str = self.get_directory_size(version_path)
size_item = QTableWidgetItem(size_str)
size_item = QTableWidgetItem(_("Unknown"))
size_item.setFlags(size_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
table.setItem(row_index, 2, size_item)
for col in range(table.columnCount()):
@@ -577,30 +696,6 @@ class ProtonManager(DraggableDialog):
def on_installed_version_toggled(self, state):
self.update_selection_display()
def get_directory_size(self, path):
try:
total_size = 0
for dirpath, _dirnames, filenames in os.walk(path):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
if os.path.exists(filepath):
total_size += os.path.getsize(filepath)
if total_size == 0:
return "0 B"
elif total_size < 1024:
return f"{total_size}.0 B"
elif total_size < 1024 * 1024:
return f"{int(total_size / 1024)}.{int((total_size / 1024 * 10) % 10)} KiB"
elif total_size < 1024 * 1024 * 1024:
return f"{int(total_size / (1024 * 1024))}.{int((total_size / (1024 * 1024) * 10) % 10)} MiB"
elif total_size < 1024 * 1024 * 1024 * 1024:
return f"{int(total_size / (1024 * 1024 * 1024))}.{int((total_size / (1024 * 1024 * 1024) * 10) % 10)} GiB"
else:
return f"{int(total_size / (1024 * 1024 * 1024 * 1024))}.{int((total_size / (1024 * 1024 * 1024 * 1024) * 10) % 10)} TiB"
except Exception as e:
logger.debug("Failed to format size: %s", e)
return _("Unknown")
def convert_size_to_bytes(self, size_str):
if not size_str or size_str == _("Unknown"):
return 0
@@ -952,10 +1047,20 @@ class ProtonManager(DraggableDialog):
QMessageBox.information(self, _("Downloading Complete"), _("All selected WINE/Proton have been downloaded!"))
return
asset_data = self.assets_to_download[self.current_download_index]
local_path = asset_data.get('local_path')
if local_path:
self.download_progress.setValue(0)
self.download_frame.setVisible(True)
self.download_frame.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
self.download_btn.setEnabled(False)
self.clear_btn.setEnabled(False)
self.start_extraction_for_asset(asset_data, local_path)
return
self.download_asset(asset_data)
def download_asset(self, asset_data):
temp_dir = tempfile.mkdtemp(prefix="portproton_wine_")
asset_data['cleanup_dir'] = temp_dir
filename = os.path.join(temp_dir, asset_data['asset_name'])
download_url = asset_data['download_url']
download_info = f"{asset_data['source_name'].upper()} - {asset_data['asset_name']}"
@@ -1026,12 +1131,13 @@ class ProtonManager(DraggableDialog):
else:
logger.error(f"Failed to extract: {archive_path}")
QMessageBox.critical(self, _("Extraction Error"), _("Failed to extract archive: {0}").format(archive_path))
temp_dir = os.path.dirname(filepath)
try:
shutil.rmtree(temp_dir)
logger.debug(f"Cleaned up temporary directory: {temp_dir}")
except Exception as e:
logger.warning(f"Could not clean up temporary directory {temp_dir}: {e}")
cleanup_dir = asset_data.get('cleanup_dir')
if cleanup_dir:
try:
shutil.rmtree(cleanup_dir)
logger.debug(f"Cleaned up temporary directory: {cleanup_dir}")
except Exception as e:
logger.warning(f"Could not clean up temporary directory {cleanup_dir}: {e}")
if self.current_extraction_thread and self.current_extraction_thread.isRunning():
logger.debug("Waiting for extraction thread to finish...")
if not self.current_extraction_thread.wait(500):
@@ -1041,12 +1147,13 @@ class ProtonManager(DraggableDialog):
def extraction_error(error_msg):
logger.error(f"Extraction error: {error_msg}")
QMessageBox.critical(self, "Extraction Error", f"Failed to extract archive: {error_msg}")
temp_dir = os.path.dirname(filepath)
try:
shutil.rmtree(temp_dir)
logger.debug(f"Cleaned up temporary directory after error: {temp_dir}")
except Exception as e:
logger.warning(f"Could not clean up temporary directory {temp_dir}: {e}")
cleanup_dir = asset_data.get('cleanup_dir')
if cleanup_dir:
try:
shutil.rmtree(cleanup_dir)
logger.debug(f"Cleaned up temporary directory after error: {cleanup_dir}")
except Exception as e:
logger.warning(f"Could not clean up temporary directory {cleanup_dir}: {e}")
if self.current_extraction_thread and self.current_extraction_thread.isRunning():
logger.debug("Waiting for extraction thread to finish after error...")
if not self.current_extraction_thread.wait(500):
@@ -1060,23 +1167,25 @@ class ProtonManager(DraggableDialog):
self.current_extraction_thread.error.connect(extraction_error)
self.current_extraction_thread.start()
except Exception as e:
temp_dir = os.path.dirname(filepath)
try:
shutil.rmtree(temp_dir)
logger.debug(f"Cleaned up temporary directory after exception: {temp_dir}")
except Exception as cleanup_error:
logger.warning(f"Could not clean up temporary directory {temp_dir}: {cleanup_error}")
cleanup_dir = asset_data.get('cleanup_dir')
if cleanup_dir:
try:
shutil.rmtree(cleanup_dir)
logger.debug(f"Cleaned up temporary directory after exception: {cleanup_dir}")
except Exception as cleanup_error:
logger.warning(f"Could not clean up temporary directory {cleanup_dir}: {cleanup_error}")
logger.error(f"Error starting extraction thread for {filepath}: {e}")
QMessageBox.critical(self, "Extraction Error", f"Failed to start extraction: {e}")
self.current_download_index += 1
QTimer.singleShot(100, self.start_next_download)
else:
temp_dir = os.path.dirname(filepath)
try:
shutil.rmtree(temp_dir)
logger.debug(f"Cleaned up temporary directory: {temp_dir}")
except Exception as e:
logger.warning(f"Could not clean up temporary directory {temp_dir}: {e}")
cleanup_dir = asset_data.get('cleanup_dir')
if cleanup_dir:
try:
shutil.rmtree(cleanup_dir)
logger.debug(f"Cleaned up temporary directory: {cleanup_dir}")
except Exception as e:
logger.warning(f"Could not clean up temporary directory {cleanup_dir}: {e}")
logger.warning("PortProton location not provided, skipping extraction")
self.current_download_index += 1
QTimer.singleShot(100, self.start_next_download)
@@ -1154,7 +1263,11 @@ class ProtonManager(DraggableDialog):
super().reject()
def show_proton_manager(parent=None, portproton_location=None, input_manager=None):
def show_proton_manager(
parent=None, portproton_location=None, input_manager=None, local_archives=None
):
dialog = ProtonManager(parent, portproton_location, input_manager=input_manager)
dialog.show()
if local_archives:
dialog.install_local_archives(local_archives)
return dialog
+11 -7
View File
@@ -211,7 +211,11 @@ class ExeSettingsDialog(
if self.input_manager:
self.input_manager.enable_settings_mode(self)
self.input_manager.gamepad_hotplug.connect(self._update_vkbasalt_toggle_key_visibility)
self.input_manager.connect_surface_signal(
'settings_dialog',
self.input_manager.gamepad_hotplug,
self._update_vkbasalt_toggle_key_visibility,
)
self.hints_widget, self.hints_labels = create_dialog_hints_widget(
self.theme, self.main_window, self.input_manager, context='settings'
@@ -219,12 +223,12 @@ class ExeSettingsDialog(
self.main_layout.addWidget(self.hints_widget)
if self.input_manager:
self.input_manager.button_event.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
self.input_manager.dpad_moved.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
def update_hints(*_args: object) -> None:
update_dialog_hints(
self.hints_labels, self.main_window, self.input_manager,
theme_manager, self.current_theme_name,
)
self.input_manager.connect_surface_updates('settings_dialog', update_hints)
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
self.init_virtual_keyboard()
+62 -6
View File
@@ -267,6 +267,7 @@ class GamescopeSettingsMixin:
self.gamescope_toggle_widgets = {}
self.gamescope_toggle_widget_keys = {}
self.gamescope_category_groups = {}
self.gamescope_search_widget = None
self.gamescope_resolution_widgets = {}
self.gamescope_actions_group = None
self.gamescope_toggle_group = None
@@ -612,7 +613,7 @@ class GamescopeSettingsMixin:
selector_layout.addWidget(self.gamescope_category_combo)
self.gamescope_category_stack = QStackedWidget()
self.gamescope_category_stack.setStyleSheet("background: transparent;")
self.gamescope_category_stack.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
selector_layout.addWidget(self.gamescope_category_stack)
toggle_lookup = dict(GAMESCOPE_TOGGLE_SPECS)
@@ -1027,6 +1028,35 @@ class GamescopeSettingsMixin:
def _filter_gamescope_settings(self, search_text):
"""Filter Gamescope groups based on search text."""
gamescope_enabled = self.current_settings.get('PW_GAMESCOPE') == '1'
for checkbox in self.gamescope_toggle_widgets.values():
layout = checkbox.parentWidget().layout()
if layout is not None:
layout.removeWidget(checkbox)
self._remove_gamescope_search_page()
matching = []
for key, checkbox in self.gamescope_toggle_widgets.items():
description = GAMESCOPE_TOGGLE_DESCRIPTIONS.get(key, '')
matches = not search_text or search_text in f"{checkbox.text()} {description}".lower()
checkbox.setVisible(matches)
if matches:
matching.append((key, checkbox))
if search_text:
result_widget, result_layout = self._create_gamescope_search_page()
for index, (_key, checkbox) in enumerate(matching):
result_layout.addWidget(checkbox, index // 2, (index % 2) * 2 + 1)
self.gamescope_category_combo.setVisible(False)
self.gamescope_category_stack.setCurrentWidget(result_widget)
else:
category_positions = {}
for key, checkbox in matching:
category = next((name for name, keys in GAMESCOPE_TOGGLE_CATEGORIES.items() if key in keys), _("Other"))
widget = self.gamescope_category_groups[category]
index = category_positions.get(category, 0)
cast(QGridLayout, widget.layout()).addWidget(checkbox, index // 2, (index % 2) * 2 + 1)
category_positions[category] = index + 1
self.gamescope_category_combo.setVisible(True)
self.on_gamescope_category_changed(self.gamescope_category_combo.currentText())
self._update_gamescope_category_stack_height()
for group_box in self.gamescope_tab.findChildren(QGroupBox):
if not gamescope_enabled:
group_box.setVisible(group_box is self.gamescope_actions_group)
@@ -1034,8 +1064,34 @@ class GamescopeSettingsMixin:
if not search_text:
group_box.setVisible(True)
continue
group_text = group_box.title().lower()
label_text = ' '.join(widget.text().lower() for widget in group_box.findChildren(QLabel))
checkbox_text = ' '.join(widget.text().lower() for widget in group_box.findChildren(QCheckBox))
content_text = f"{label_text} {checkbox_text}"
group_box.setVisible(search_text in group_text or search_text in content_text)
content_widgets = group_box.findChildren(QLabel) + group_box.findChildren(QCheckBox)
content_match = any(search_text in widget.text().lower() for widget in content_widgets)
group_box.setVisible(
search_text in group_box.title().lower() or content_match
or group_box is self.gamescope_toggle_group and bool(matching)
)
def _create_gamescope_search_page(self) -> tuple[QWidget, QGridLayout]:
source_widget = next(iter(self.gamescope_category_groups.values()))
source_layout = cast(QGridLayout, source_widget.layout())
widget = QWidget()
layout = QGridLayout(widget)
margins = source_layout.contentsMargins()
layout.setContentsMargins(margins.left(), margins.top(), margins.right(), margins.bottom())
layout.setHorizontalSpacing(source_layout.horizontalSpacing())
layout.setVerticalSpacing(source_layout.verticalSpacing())
for column in range(source_layout.columnCount()):
layout.setColumnStretch(column, source_layout.columnStretch(column))
self.gamescope_category_stack.addWidget(widget)
self.gamescope_search_widget = widget
return widget, layout
def _remove_gamescope_search_page(self) -> None:
widget = getattr(self, 'gamescope_search_widget', None)
if widget is None:
return
for checkbox in widget.findChildren(QCheckBox):
checkbox.setParent(self.gamescope_toggle_group)
self.gamescope_category_stack.removeWidget(widget)
widget.deleteLater()
self.gamescope_search_widget = None
+68 -9
View File
@@ -385,6 +385,7 @@ class MangoHudSettingsMixin:
self.mangohud_toggle_widget_keys = {}
self.mangohud_fps_widgets = {}
self.mangohud_category_groups = {}
self.mangohud_search_widget = None
self.mangohud_gpu_options = []
self.mangohud_actions_group = None
self.mangohud_presets_group = None
@@ -567,7 +568,7 @@ class MangoHudSettingsMixin:
selector_layout.addWidget(self.mangohud_category_combo)
self.mangohud_category_stack = QStackedWidget()
self.mangohud_category_stack.setStyleSheet("background: transparent;")
self.mangohud_category_stack.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
selector_layout.addWidget(self.mangohud_category_stack)
toggle_lookup = dict(MANGOHUD_TOGGLE_SPECS)
@@ -1231,9 +1232,40 @@ class MangoHudSettingsMixin:
def _filter_mangohud_settings(self, search_text):
"""Filter MangoHud groups based on search text."""
mangohud_enabled = self.current_settings.get('PW_MANGOHUD') == '1'
mangohud_user_conf_enabled = self.current_settings.get('PW_MANGOHUD_USER_CONF') == '1'
config_visible = mangohud_enabled and not mangohud_user_conf_enabled
config_visible = self.current_settings.get('PW_MANGOHUD') == '1' \
and self.current_settings.get('PW_MANGOHUD_USER_CONF') != '1'
for checkbox in self.mangohud_toggle_widgets.values():
layout = checkbox.parentWidget().layout()
if layout is not None:
layout.removeWidget(checkbox)
self._remove_mangohud_search_page()
matching = []
for key, checkbox in self.mangohud_toggle_widgets.items():
description = MANGOHUD_TOGGLE_DESCRIPTIONS.get(key, '')
matches = not search_text or search_text in f"{checkbox.text()} {description}".lower()
checkbox.setVisible(matches)
if matches:
matching.append((key, checkbox))
columns = self.theme.mangoHudSwitchesColumns
if search_text:
result_widget, result_layout = self._create_mangohud_search_page()
for index, (_key, checkbox) in enumerate(matching):
result_layout.addWidget(checkbox, index // columns, (index % columns) * 2 + 1)
self.mangohud_category_combo.setVisible(False)
self.mangohud_category_stack.setCurrentWidget(result_widget)
else:
category_positions = {}
for key, checkbox in matching:
category = next((name for name, keys in MANGOHUD_TOGGLE_CATEGORIES.items() if key in keys), _("Other"))
widget = self.mangohud_category_groups[category]
index = category_positions.get(category, 0)
cast(QGridLayout, widget.layout()).addWidget(
checkbox, index // columns, (index % columns) * 2 + 1
)
category_positions[category] = index + 1
self.mangohud_category_combo.setVisible(True)
self.on_mangohud_category_changed(self.mangohud_category_combo.currentText())
self._update_mangohud_category_stack_height()
for group_box in self.mangohud_tab.findChildren(QGroupBox):
if not config_visible:
group_box.setVisible(group_box is self.mangohud_actions_group)
@@ -1241,11 +1273,38 @@ class MangoHudSettingsMixin:
if not search_text:
group_box.setVisible(True)
continue
group_text = group_box.title().lower()
label_text = ' '.join(widget.text().lower() for widget in group_box.findChildren(QLabel))
checkbox_text = ' '.join(widget.text().lower() for widget in group_box.findChildren(QCheckBox))
content_text = f"{label_text} {checkbox_text}"
group_box.setVisible(search_text in group_text or search_text in content_text)
content_widgets = group_box.findChildren(QLabel) + group_box.findChildren(QCheckBox)
content_match = any(search_text in widget.text().lower() for widget in content_widgets)
group_box.setVisible(
search_text in group_box.title().lower() or content_match
or group_box is self.mangohud_toggle_group and bool(matching)
)
def _create_mangohud_search_page(self) -> tuple[QWidget, QGridLayout]:
source_widget = next(iter(self.mangohud_category_groups.values()))
source_layout = cast(QGridLayout, source_widget.layout())
widget = QWidget()
layout = QGridLayout(widget)
margins = source_layout.contentsMargins()
layout.setContentsMargins(margins.left(), margins.top(), margins.right(), margins.bottom())
layout.setHorizontalSpacing(source_layout.horizontalSpacing())
layout.setVerticalSpacing(source_layout.verticalSpacing())
columns = self.theme.mangoHudSwitchesColumns
for column in range(columns * 2 + 1):
layout.setColumnStretch(column, source_layout.columnStretch(column))
self.mangohud_category_stack.addWidget(widget)
self.mangohud_search_widget = widget
return widget, layout
def _remove_mangohud_search_page(self) -> None:
widget = getattr(self, 'mangohud_search_widget', None)
if widget is None:
return
for checkbox in widget.findChildren(QCheckBox):
checkbox.setParent(self.mangohud_toggle_group)
self.mangohud_category_stack.removeWidget(widget)
widget.deleteLater()
self.mangohud_search_widget = None
def _get_default_mangohud_config(self) -> str | None:
"""Read DEFAULT_MANGOHUD_CONFIG from scripts/var."""
+22
View File
@@ -356,6 +356,28 @@ class VkBasaltSettingsMixin:
vkbasalt_enabled = self.current_settings.get('PW_VKBASALT') == '1'
user_conf_enabled = self.current_settings.get('PW_VKBASALT_USER_CONF') == '1'
config_visible = vkbasalt_enabled and not user_conf_enabled
if self.vkbasalt_shaders_layout is not None:
shaders_group = self.vkbasalt_shaders_group
group_match = not search_text or (
shaders_group is not None and search_text in shaders_group.title().lower()
)
matching = [
(shader, checkbox)
for shader, checkbox in self.vkbasalt_shader_widgets.items()
if group_match or search_text in shader.lower()
]
matching_widgets = {checkbox for _, checkbox in matching}
for checkbox in self.vkbasalt_shader_widgets.values():
self.vkbasalt_shaders_layout.removeWidget(checkbox)
checkbox.setVisible(group_match or checkbox in matching_widgets)
columns = self.theme.mangoHudSwitchesColumns
for index, (_, checkbox) in enumerate(matching):
self.vkbasalt_shaders_layout.addWidget(
checkbox,
index // columns,
(index % columns) * 2 + 1,
)
self.vkbasalt_shaders_layout.activate()
for group_box in self.vkbasalt_tab.findChildren(QGroupBox):
if not config_visible:
group_box.setVisible(group_box is self.vkbasalt_actions_group)
+28 -18
View File
@@ -31,6 +31,20 @@ class ExtractionThread(QThread):
self._mutex.unlock()
return not running
def _get_entry_path(self, entry_path: str, link_path: str | None = None) -> str:
extract_root = os.path.realpath(self.extract_dir)
target_path = os.path.realpath(os.path.join(extract_root, entry_path))
if os.path.commonpath((extract_root, target_path)) != extract_root:
raise ValueError(f"Archive entry escapes extraction directory: {entry_path}")
if link_path is None:
return target_path
link_target = os.path.realpath(
os.path.join(os.path.dirname(target_path), link_path)
)
if os.path.commonpath((extract_root, link_target)) != extract_root:
raise ValueError(f"Archive link escapes extraction directory: {entry_path}")
return target_path
def run(self):
try:
self.progress.emit(0)
@@ -48,10 +62,6 @@ class ExtractionThread(QThread):
last_progress = -1
last_bytes_read = 0
original_dir = os.getcwd()
old_umask = os.umask(0)
os.chdir(self.extract_dir)
try:
deferred_times = []
@@ -63,23 +73,25 @@ class ExtractionThread(QThread):
entry_path = entry.pathname
if entry_path is None:
continue
link_path = os.fsdecode(entry.linkpath) if entry.issym else None
target_path = self._get_entry_path(entry_path, link_path)
if entry.isdir:
os.makedirs(entry_path, exist_ok=True)
os.makedirs(target_path, exist_ok=True)
if entry.mode:
try:
os.chmod(entry_path, entry.mode)
os.chmod(target_path, entry.mode)
except (OSError, PermissionError):
pass
if entry.mtime:
deferred_times.append((entry_path, entry.mtime))
deferred_times.append((target_path, entry.mtime))
elif entry.isfile:
parent_dir = os.path.dirname(entry_path)
parent_dir = os.path.dirname(target_path)
if parent_dir:
os.makedirs(parent_dir, exist_ok=True)
with open(entry_path, 'wb') as f:
with open(target_path, 'wb') as f:
for block in entry.get_blocks():
if self._should_stop():
return
@@ -87,28 +99,27 @@ class ExtractionThread(QThread):
if entry.mode:
try:
os.chmod(entry_path, entry.mode)
os.chmod(target_path, entry.mode)
except (OSError, PermissionError):
pass
if entry.mtime:
try:
os.utime(entry_path, (entry.mtime, entry.mtime))
os.utime(target_path, (entry.mtime, entry.mtime))
except (OSError, PermissionError):
pass
elif entry.issym:
parent_dir = os.path.dirname(entry_path)
parent_dir = os.path.dirname(target_path)
if parent_dir:
os.makedirs(parent_dir, exist_ok=True)
if os.path.lexists(entry_path):
os.remove(entry_path)
if os.path.lexists(target_path):
os.remove(target_path)
link_path = entry.linkpath
if link_path is not None:
try:
os.symlink(link_path, entry_path)
os.symlink(link_path, target_path)
except (OSError, NotImplementedError):
pass
@@ -152,8 +163,7 @@ class ExtractionThread(QThread):
self.finished.emit(self.archive_path, True)
finally:
os.chdir(original_dir)
os.umask(old_umask)
self.eta.emit(0)
except Exception as e:
if not self._should_stop():
+53 -1
View File
@@ -13,6 +13,33 @@ from portprotonqt.logger import get_logger
logger = get_logger(__name__)
def get_directory_size(path: str) -> str:
try:
total_size = 0
for dirpath, _dirnames, filenames in os.walk(path):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
if os.path.exists(filepath):
total_size += os.path.getsize(filepath)
if total_size == 0:
return "0 B"
if total_size < 1024:
return f"{total_size}.0 B"
if total_size < 1024 * 1024:
return f"{int(total_size / 1024)}.{int((total_size / 1024 * 10) % 10)} KiB"
if total_size < 1024 * 1024 * 1024:
value = total_size / (1024 * 1024)
return f"{int(value)}.{int((value * 10) % 10)} MiB"
if total_size < 1024 * 1024 * 1024 * 1024:
value = total_size / (1024 * 1024 * 1024)
return f"{int(value)}.{int((value * 10) % 10)} GiB"
value = total_size / (1024 * 1024 * 1024 * 1024)
return f"{int(value)}.{int((value * 10) % 10)} TiB"
except Exception as error:
logger.debug("Failed to format size: %s", error)
return ""
def get_cpu_level():
"""
Determine CPU level based on feature flags.
@@ -64,7 +91,18 @@ class WineLoadingThread(QThread):
def run(self):
try:
json_url = "https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
cloud_url = "https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
github_url = (
"https://raw.githubusercontent.com/linux-gaming-ru/"
"PortProtonQt-Wine-Metadata/main/wine_metadata_all.json"
)
from portprotonqt.portproton_api import get_user_conf_setting
json_url = (
cloud_url
if get_user_conf_setting("MIRROR") == "CLOUD"
else github_url
)
with tempfile.NamedTemporaryFile(delete=False, suffix='.json') as tmp:
temp_path = tmp.name
@@ -90,3 +128,17 @@ class WineLoadingThread(QThread):
except Exception as e:
logger.error(f"Error loading metadata: {e}")
self.loading_error.emit(str(e))
class InstalledWineSizeThread(QThread):
"""Load installed Wine directory sizes outside the UI thread."""
sizes_loaded = Signal(object)
def __init__(self, versions: list[tuple[str, str]], parent: QWidget | None = None):
super().__init__(parent)
self.versions = versions
def run(self) -> None:
sizes = [get_directory_size(path) for _name, path in self.versions]
self.sizes_loaded.emit(sizes)
+6 -6
View File
@@ -86,12 +86,12 @@ class WinetricksDialog(DraggableDialog):
self.main_layout.addWidget(self.hints_widget)
if self.input_manager:
self.input_manager.button_event.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
self.input_manager.dpad_moved.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
def update_hints(*_args: object) -> None:
update_dialog_hints(
self.hints_labels, self.main_window, self.input_manager,
theme_manager, self.current_theme_name,
)
self.input_manager.connect_surface_updates('winetricks_dialog', update_hints)
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
def setup_ui(self):
+10 -16
View File
@@ -262,9 +262,9 @@ class GameCard(QFrame):
self.economy_mode = ui_config.get_economy_mode()
self.missing_executable_path = self._get_missing_executable_path()
self.steam_visible = (str(game_source).lower() == "steam" and not self.economy_mode)
self.gog_visible = (str(game_source).lower() == "gog" and not self.economy_mode)
self.portproton_visible = (str(game_source).lower() == "portproton" and not self.economy_mode)
self.steam_visible = str(game_source).lower() == "steam"
self.gog_visible = str(game_source).lower() == "gog"
self.portproton_visible = str(game_source).lower() == "portproton"
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
config_name = "GAME_CARD_LIST" if self.list_layout else "GAME_CARD_GRID"
@@ -350,8 +350,6 @@ class GameCard(QFrame):
parent=self.coverWidget,
)
self.steamLabel.setVisible(self.steam_visible)
if self.economy_mode:
self.steamLabel.setVisible(False)
gog_icon = self.theme_manager.get_icon("badge_gog", as_path=True)
self.gogLabel = SourceCorner(
@@ -360,8 +358,6 @@ class GameCard(QFrame):
parent=self.coverWidget,
)
self.gogLabel.setVisible(self.gog_visible)
if self.economy_mode:
self.gogLabel.setVisible(False)
portproton_icon = self.theme_manager.get_icon("badge_portproton", as_path=True)
self.portprotonLabel = SourceCorner(
@@ -370,8 +366,6 @@ class GameCard(QFrame):
parent=self.coverWidget,
)
self.portprotonLabel.setVisible(self.portproton_visible)
if self.economy_mode:
self.portprotonLabel.setVisible(False)
if self.ppdb_visible:
self.ppdbLabel = ClickableLabel(
@@ -597,7 +591,7 @@ class GameCard(QFrame):
]
for is_visible, ribbon in source_ribbons:
if not is_visible or ribbon is None or hidden_badges:
if not is_visible or ribbon is None:
if ribbon is not None:
ribbon.setVisible(False)
continue
@@ -751,9 +745,9 @@ class GameCard(QFrame):
self.display_filter = display_filter
self.economy_mode = ui_config.get_economy_mode()
self.steam_visible = (str(self.game_source).lower() == "steam" and not self.economy_mode)
self.gog_visible = (str(self.game_source).lower() == "gog" and not self.economy_mode)
self.portproton_visible = (str(self.game_source).lower() == "portproton" and not self.economy_mode)
self.steam_visible = str(self.game_source).lower() == "steam"
self.gog_visible = str(self.game_source).lower() == "gog"
self.portproton_visible = str(self.game_source).lower() == "portproton"
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
protondb_visible = is_valid_protondb_tier(self.protondb_tier) and not self.economy_mode
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) and not self.economy_mode
@@ -761,9 +755,9 @@ class GameCard(QFrame):
hidden_badges = self.badge_view_mode == "hidden"
try:
self.steamLabel.setVisible(self.steam_visible and not hidden_badges)
self.gogLabel.setVisible(self.gog_visible and not hidden_badges)
self.portprotonLabel.setVisible(self.portproton_visible and not hidden_badges)
self.steamLabel.setVisible(self.steam_visible)
self.gogLabel.setVisible(self.gog_visible)
self.portprotonLabel.setVisible(self.portproton_visible)
self.ppdbLabel.setVisible(self.ppdb_visible and not hidden_badges)
self.protondbLabel.setVisible(protondb_visible and not hidden_badges)
self.anticheatLabel.setVisible(anticheat_visible and not hidden_badges)
-244
View File
@@ -1,244 +0,0 @@
import ctypes
import ctypes.util
from dataclasses import dataclass
from enum import Enum
from functools import lru_cache
from typing import Any
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
SDL_INIT_GAMEPAD = 0x00002000
SDL_GAMEPAD_TYPE_STANDARD = 1
SDL_GAMEPAD_TYPE_XBOX360 = 2
SDL_GAMEPAD_TYPE_XBOXONE = 3
SDL_GAMEPAD_TYPE_PS3 = 4
SDL_GAMEPAD_TYPE_PS4 = 5
SDL_GAMEPAD_TYPE_PS5 = 6
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO = 7
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT = 8
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT = 9
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR = 10
SDL_GAMEPAD_TYPE_GAMECUBE = 11
SDL_GAMEPAD_TYPE_STEAM = 12
SDL3_XBOX_LIKE_TYPES = {
SDL_GAMEPAD_TYPE_STANDARD,
SDL_GAMEPAD_TYPE_XBOX360,
SDL_GAMEPAD_TYPE_XBOXONE,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR,
SDL_GAMEPAD_TYPE_GAMECUBE,
SDL_GAMEPAD_TYPE_STEAM,
}
SDL3_PLAYSTATION_TYPES = {
SDL_GAMEPAD_TYPE_PS3,
SDL_GAMEPAD_TYPE_PS4,
SDL_GAMEPAD_TYPE_PS5,
}
SDL_GAMEPAD_BUTTON_SOUTH = 0
SDL_GAMEPAD_BUTTON_EAST = 1
SDL_GAMEPAD_BUTTON_WEST = 2
SDL_GAMEPAD_BUTTON_NORTH = 3
SDL_GAMEPAD_BUTTON_BACK = 4
SDL_GAMEPAD_BUTTON_GUIDE = 5
SDL_GAMEPAD_BUTTON_START = 6
SDL_GAMEPAD_BUTTON_LEFT_SHOULDER = 9
SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER = 10
SDL_GAMEPAD_BUTTON_DPAD_UP = 11
SDL_GAMEPAD_BUTTON_DPAD_DOWN = 12
SDL_GAMEPAD_BUTTON_DPAD_LEFT = 13
SDL_GAMEPAD_BUTTON_DPAD_RIGHT = 14
SDL_GAMEPAD_AXIS_LEFTX = 0
SDL_GAMEPAD_AXIS_LEFTY = 1
SDL_GAMEPAD_AXIS_RIGHTX = 2
SDL_GAMEPAD_AXIS_RIGHTY = 3
SDL_GAMEPAD_AXIS_LEFT_TRIGGER = 4
SDL_GAMEPAD_AXIS_RIGHT_TRIGGER = 5
class GamepadType(Enum):
XBOX = "Xbox"
PLAYSTATION = "PlayStation"
UNKNOWN = "Unknown"
def _get_sdl3_error(sdl: ctypes.CDLL) -> str:
error = sdl.SDL_GetError()
if not error:
return ""
return error.decode(errors="replace")
def _configure_sdl3_gamepad_api(sdl: ctypes.CDLL) -> None:
sdl.SDL_InitSubSystem.argtypes = [ctypes.c_uint32]
sdl.SDL_InitSubSystem.restype = ctypes.c_bool
sdl.SDL_GetGamepads.argtypes = [ctypes.POINTER(ctypes.c_int)]
sdl.SDL_GetGamepads.restype = ctypes.POINTER(ctypes.c_uint32)
sdl.SDL_GetGamepadTypeForID.argtypes = [ctypes.c_uint32]
sdl.SDL_GetGamepadTypeForID.restype = ctypes.c_int
sdl.SDL_GetGamepadNameForID.argtypes = [ctypes.c_uint32]
sdl.SDL_GetGamepadNameForID.restype = ctypes.c_char_p
sdl.SDL_OpenGamepad.argtypes = [ctypes.c_uint32]
sdl.SDL_OpenGamepad.restype = ctypes.c_void_p
sdl.SDL_CloseGamepad.argtypes = [ctypes.c_void_p]
sdl.SDL_CloseGamepad.restype = None
sdl.SDL_GamepadConnected.argtypes = [ctypes.c_void_p]
sdl.SDL_GamepadConnected.restype = ctypes.c_bool
sdl.SDL_GetGamepadName.argtypes = [ctypes.c_void_p]
sdl.SDL_GetGamepadName.restype = ctypes.c_char_p
sdl.SDL_GetGamepadButton.argtypes = [ctypes.c_void_p, ctypes.c_int]
sdl.SDL_GetGamepadButton.restype = ctypes.c_bool
sdl.SDL_GetGamepadAxis.argtypes = [ctypes.c_void_p, ctypes.c_int]
sdl.SDL_GetGamepadAxis.restype = ctypes.c_int16
sdl.SDL_UpdateGamepads.argtypes = []
sdl.SDL_UpdateGamepads.restype = None
sdl.SDL_PumpEvents.argtypes = []
sdl.SDL_PumpEvents.restype = None
sdl.SDL_QuitSubSystem.argtypes = [ctypes.c_uint32]
sdl.SDL_QuitSubSystem.restype = None
sdl.SDL_GetError.argtypes = []
sdl.SDL_GetError.restype = ctypes.c_char_p
sdl.SDL_free.argtypes = [ctypes.c_void_p]
sdl.SDL_free.restype = None
@lru_cache(maxsize=1)
def _load_sdl3() -> ctypes.CDLL | None:
library_names = (
ctypes.util.find_library("SDL3"),
"libSDL3.so.0",
"libSDL3.so",
)
for library_name in library_names:
if not library_name:
continue
try:
sdl = ctypes.CDLL(library_name)
_configure_sdl3_gamepad_api(sdl)
except (AttributeError, OSError) as e:
logger.debug("Failed to load SDL3 from %s: %s", library_name, e)
continue
if sdl.SDL_InitSubSystem(SDL_INIT_GAMEPAD):
return sdl
logger.debug("Failed to initialize SDL3 gamepad subsystem: %s", _get_sdl3_error(sdl))
return None
def _decode_sdl3_name(name: bytes | None) -> str:
if not name:
return ""
return name.decode(errors="replace")
@dataclass
class SDLGamepad:
sdl: ctypes.CDLL
controller: ctypes.c_void_p
name: str
path: str
instance_id: int
def close(self) -> None:
try:
self.sdl.SDL_CloseGamepad(self.controller)
except (AttributeError, OSError) as e:
logger.debug("Failed to close SDL gamepad: %s", e)
def connected(self) -> bool:
return bool(self.sdl.SDL_GamepadConnected(self.controller))
def update(self) -> None:
self.sdl.SDL_UpdateGamepads()
def get_button(self, button: int) -> int:
return int(self.sdl.SDL_GetGamepadButton(self.controller, button))
def get_axis(self, axis: int) -> int:
return int(self.sdl.SDL_GetGamepadAxis(self.controller, axis))
def find_gamepad() -> SDLGamepad | None:
sdl = _load_sdl3()
if sdl is None:
return None
sdl.SDL_PumpEvents()
sdl.SDL_UpdateGamepads()
count = ctypes.c_int()
gamepads = sdl.SDL_GetGamepads(ctypes.byref(count))
if not gamepads:
return None
try:
return _open_first_sdl3_gamepad(sdl, gamepads, count.value)
finally:
sdl.SDL_free(gamepads)
def _open_first_sdl3_gamepad(
sdl: ctypes.CDLL,
gamepads: Any,
count: int,
) -> SDLGamepad | None:
for index in range(count):
instance_id = int(gamepads[index])
gamepad = sdl.SDL_OpenGamepad(instance_id)
if not gamepad:
logger.debug("Skipping SDL3 gamepad %s: %s", instance_id, _get_sdl3_error(sdl))
continue
return SDLGamepad(
sdl=sdl,
controller=gamepad,
name=_decode_sdl3_name(sdl.SDL_GetGamepadName(gamepad)),
path=f"sdl3-gamepad:{instance_id}",
instance_id=instance_id,
)
return None
def detect_gamepad_type(gamepad: SDLGamepad) -> GamepadType:
sdl = _load_sdl3()
if sdl is None:
return GamepadType.XBOX
count = ctypes.c_int()
gamepads = sdl.SDL_GetGamepads(ctypes.byref(count))
if not gamepads:
return GamepadType.XBOX
try:
gamepad_type = _find_sdl3_gamepad_type(sdl, gamepads, count.value, gamepad.name)
finally:
sdl.SDL_free(gamepads)
return gamepad_type or GamepadType.XBOX
def _find_sdl3_gamepad_type(
sdl: ctypes.CDLL,
gamepads: Any,
count: int,
target_name: str,
) -> GamepadType | None:
for index in range(count):
gamepad_id = gamepads[index]
sdl_name = _decode_sdl3_name(sdl.SDL_GetGamepadNameForID(gamepad_id))
if count > 1 and sdl_name.casefold() != target_name.casefold():
continue
gamepad_type = _gamepad_type_from_sdl3_value(sdl.SDL_GetGamepadTypeForID(gamepad_id))
if gamepad_type is not None:
return gamepad_type
return None
def _gamepad_type_from_sdl3_value(sdl_type: int) -> GamepadType | None:
if sdl_type in SDL3_PLAYSTATION_TYPES:
return GamepadType.PLAYSTATION
if sdl_type in SDL3_XBOX_LIKE_TYPES:
return GamepadType.XBOX
return None
def shutdown() -> None:
sdl = _load_sdl3()
if sdl is not None:
sdl.SDL_QuitSubSystem(SDL_INIT_GAMEPAD)
+47 -2
View File
@@ -6,6 +6,7 @@ import platform
import re
import shutil
import subprocess
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
@@ -25,6 +26,7 @@ GOG_LOGIN_URL = (
)
GOGDL_RELEASE_URL = "https://api.github.com/repos/Heroic-Games-Launcher/heroic-gogdl/releases/latest"
GOGDL_AUTH_TIMEOUT = 60
GOGDL_UPDATE_INTERVAL = 30 * 24 * 60 * 60
GOG_USER_TIMEOUT = 15
GOG_METADATA_WORKERS = 4
GOG_SETUP_MARKER_VERSION = 1
@@ -49,6 +51,7 @@ class GOGAPI:
self.auth_path = self.data_dir / "auth.json"
self.account_path = self.data_dir / "account.json"
self.config_dir = self.data_dir / "gogdl"
self.gogdl_version_path = self.data_dir / "bin/gogdl.version"
self.library_path = self.data_dir / "library.json"
self.installed_path = self.data_dir / "installed.json"
self.sizes_path = self.data_dir / "sizes.json"
@@ -76,19 +79,59 @@ class GOGAPI:
existing = self.get_gogdl_path()
if existing:
return existing
asset, release_tag = self._get_latest_gogdl_release()
return self._install_gogdl_release(asset, release_tag)
def update_gogdl(self) -> str:
"""Update the bundled gogdl binary to the latest official release."""
bundled = self.data_dir / "bin/gogdl"
if bundled.is_file() and os.access(bundled, os.X_OK):
try:
last_check = self.gogdl_version_path.stat().st_mtime
except OSError:
last_check = 0
if time.time() - last_check < GOGDL_UPDATE_INTERVAL:
return str(bundled)
self.gogdl_version_path.touch()
asset, release_tag = self._get_latest_gogdl_release()
if (
bundled.is_file()
and os.access(bundled, os.X_OK)
and self._get_gogdl_release_tag() == release_tag
):
return str(bundled)
return self._install_gogdl_release(asset, release_tag)
def _get_latest_gogdl_release(self) -> tuple[dict, str]:
"""Return the matching asset and tag from the latest gogdl release."""
architecture = {"x86_64": "x86_64", "aarch64": "arm64"}.get(platform.machine())
if not architecture:
raise OSError(f"Unsupported gogdl architecture: {platform.machine()}")
response = requests.get(GOGDL_RELEASE_URL, timeout=15)
response.raise_for_status()
release = response.json()
asset_name = f"gogdl_linux_{architecture}"
asset = next(
(item for item in response.json().get("assets", []) if item.get("name") == asset_name),
(item for item in release.get("assets", []) if item.get("name") == asset_name),
None,
)
if not asset:
raise OSError(f"gogdl release asset not found: {asset_name}")
return self._download_gogdl_asset(asset)
release_tag = str(release.get("tag_name", ""))
if not release_tag:
raise OSError("gogdl release tag not found")
return asset, release_tag
def _get_gogdl_release_tag(self) -> str:
try:
return self.gogdl_version_path.read_text(encoding="utf-8").strip()
except OSError:
return ""
def _install_gogdl_release(self, asset: dict, release_tag: str) -> str:
path = self._download_gogdl_asset(asset)
self.gogdl_version_path.write_text(release_tag, encoding="utf-8")
return path
def _download_gogdl_asset(self, asset: dict) -> str:
target = self.data_dir / "bin/gogdl"
@@ -452,6 +495,8 @@ class GOGAPI:
"""Run GOG setup and expose its process for UI cancellation."""
process = subprocess.Popen(
command, cwd=install_path, start_new_session=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, errors="replace", bufsize=1,
)
if process_callback:
process_callback(process)
+49 -6
View File
@@ -2,6 +2,7 @@
import os
import re
import shlex
import struct
import io
import pefile
@@ -15,6 +16,36 @@ RT_GROUP_ICON = 14
THUMBNAIL_SIZE = 128
def _resolve_batch_icon_target(inputfile: str) -> str | None:
"""Return an executable launched by a neighboring command file."""
input_dir = os.path.dirname(inputfile)
try:
command_files = sorted(
filename for filename in os.listdir(input_dir)
if filename.lower().endswith((".bat", ".cmd"))
)
except (OSError, ValueError) as error:
logger.warning("Failed to inspect command files in %s: %s", input_dir, error)
return None
for filename in command_files:
batch_path = os.path.join(input_dir, filename)
try:
with open(batch_path, encoding="utf-8") as batch_file:
for line in batch_file:
for token in shlex.split(line, posix=False):
exe_name = token.strip('"')
exe_path = os.path.join(input_dir, exe_name)
if (
exe_name.lower().endswith(".exe")
and os.path.isfile(exe_path)
):
return exe_path
except (OSError, UnicodeError, ValueError) as error:
logger.warning("Failed to process command file %s: %s", batch_path, error)
return None
def get_exe_icon_cache_path(exe_path: str) -> str:
"""Return the shared cache path for an executable icon."""
cache_home = os.getenv(
@@ -44,6 +75,10 @@ def generate_thumbnail(inputfile: str, outfile: str, size: int = 128, force_resi
try:
extractor = IconExtractor(inputfile)
data = extractor.get_icon()
if data is None:
target_path = _resolve_batch_icon_target(inputfile)
if target_path:
data = IconExtractor(target_path).get_icon()
if data is None:
return False
@@ -55,14 +90,18 @@ def generate_thumbnail(inputfile: str, outfile: str, size: int = 128, force_resi
im.save(outfile, "PNG")
logger.info("Thumbnail successfully saved to %s", outfile)
return True
except Exception as e:
logger.error("Error generating thumbnail: %s", e)
except IconExtractorError as error:
logger.warning("Icon extraction failed: %s", error)
return False
except Exception as error:
logger.error("Error generating thumbnail: %s", error)
return False
class IconExtractorError(Exception):
"""Base exception for icon extraction errors."""
class IconExtractor:
"""Extracts icons from NE and PE files."""
@@ -72,7 +111,10 @@ class IconExtractor:
self._groups = [] # List of group icon data
def get_icon(self) -> io.BytesIO | None:
"""Extract the best quality icon and return it as image data."""
"""Extract the best quality icon and return it as image data.
Raises IconExtractorError when the source cannot be read or parsed.
"""
try:
with open(self.file_path, "rb") as f:
if not self._parse_file(f):
@@ -86,9 +128,10 @@ class IconExtractor:
if data is None:
return None
return self._icon_data_to_image(data)
except Exception as e:
logger.warning("Failed to extract icon from %s: %s", self.file_path, e)
return None
except Exception as error:
raise IconExtractorError(
f"Failed to extract icon from {self.file_path}: {error}"
) from error
def _parse_file(self, f) -> bool:
"""Identify file type and call appropriate parser."""
+8 -7
View File
@@ -9,7 +9,7 @@ from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraph
from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication
from PIL import Image, ImageQt, ImageSequence
from portprotonqt.config import ui_config
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.theme_manager import ThemeManager, load_theme
from portprotonqt.downloader import Downloader
from portprotonqt.icon_extractor import generate_thumbnail
from portprotonqt.logger import get_logger
@@ -396,15 +396,16 @@ def load_pixmap_async(
def process_image():
theme_manager = ThemeManager()
current_theme_name = ui_config.get_theme()
theme = theme_manager.current_theme_module or load_theme(current_theme_name)
def finish_with(pixmap: QPixmap):
# Check if pixmap is valid before attempting to scale it
if pixmap.isNull():
# Create a default placeholder pixmap instead of trying to scale a null pixmap
placeholder_pixmap = QPixmap(width, height)
placeholder_pixmap.fill(QColor("#333333"))
placeholder_pixmap.fill(QColor(theme.color_placeholder_bg))
painter = QPainter(placeholder_pixmap)
painter.setPen(QPen(QColor("white")))
painter.setPen(QPen(QColor(theme.color_white)))
painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
painter.end()
callback(placeholder_pixmap)
@@ -432,9 +433,9 @@ def load_pixmap_async(
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
return pixmap
pixmap = QPixmap(width, height)
pixmap.fill(QColor("#333333"))
pixmap.fill(QColor(theme.color_placeholder_bg))
painter = QPainter(pixmap)
painter.setPen(QPen(QColor("white")))
painter.setPen(QPen(QColor(theme.color_white)))
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
painter.end()
return pixmap
@@ -690,9 +691,9 @@ class FullscreenDialog(QDialog):
if pixmap.isNull():
# Create a default placeholder pixmap instead of trying to scale a null pixmap
placeholder_pixmap = QPixmap(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT)
placeholder_pixmap.fill(QColor("#333333"))
placeholder_pixmap.fill(QColor(self.theme.color_placeholder_bg))
painter = QPainter(placeholder_pixmap)
painter.setPen(QPen(QColor("white")))
painter.setPen(QPen(QColor(self.theme.color_white)))
painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
painter.end()
self.imageLabel.setPixmap(placeholder_pixmap)
File diff suppressed because it is too large. Load diff
+603
View File
@@ -0,0 +1,603 @@
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.5,
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,
Qt.ConnectionType.QueuedConnection,
)
self.dpad_moved.connect(
self.handle_dpad_slot,
Qt.ConnectionType.QueuedConnection,
)
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()
+373
View File
@@ -0,0 +1,373 @@
import time
from PySide6.QtCore import QPoint, QTimer, Slot, Qt
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QDialog,
QLineEdit,
QListView,
QMenu,
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_popup_menu_button(QApplication.activePopupWidget(), 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 _handle_popup_menu_button(self, popup: QWidget | None, button_code: int) -> bool:
if not isinstance(popup, QMenu):
return False
if button_code in BUTTONS['confirm']:
action = popup.activeAction()
if action is not None and action.isEnabled():
action.trigger()
popup.close()
return True
if button_code in BUTTONS['back']:
popup.close()
return True
return False
def _route_button_to_text_input(
self, active: QWidget, focused: QWidget | None, button_code: int
) -> bool:
keyboard = (
getattr(active, 'keyboard', None)
if isinstance(active, AddGameDialog)
else getattr(self._parent, 'keyboard', None)
)
if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit):
if keyboard:
keyboard.show_for_widget(focused)
return True
if button_code not in BUTTONS['prev_dir']:
return False
combo = focused if isinstance(focused, QComboBox) else None
if combo is None and focused is not None:
combo = self._find_parent_combo(focused)
if combo is not None and combo.isEditable():
line_edit = combo.lineEdit()
if line_edit is not None and keyboard:
line_edit.setFocus()
keyboard.show_for_widget(line_edit)
return True
current_tab = self._parent.stackedWidget.currentIndex()
search_name = 'searchEdit' if current_tab == 0 else 'autoInstallSearchLineEdit'
search_edit = getattr(self._parent, search_name, None) if current_tab in (0, 1) else None
if search_edit is None:
return False
search_edit.setFocus()
return True
def _find_parent_combo(self, widget: QWidget) -> QComboBox | None:
parent = widget.parentWidget()
while parent:
if isinstance(parent, QComboBox):
return parent
parent = parent.parentWidget()
return None
def _activate_list_view_selection(self, view: QListView) -> None:
index = view.currentIndex()
if not index.isValid():
return
combo = self._find_parent_combo(view)
if combo:
combo.setCurrentIndex(index.row())
combo.hidePopup()
combo.setFocus(Qt.FocusReason.OtherFocusReason)
return
view.activated.emit(index)
view.clicked.emit(index)
view.hide()
def _handle_list_view_button(self, focused: QWidget | None, button_code: int) -> bool:
if not isinstance(focused, QListView):
return False
if button_code in BUTTONS['confirm']:
self._activate_list_view_selection(focused)
return True
if button_code not in BUTTONS['back']:
return False
combo = self._find_parent_combo(focused)
if combo:
combo.hidePopup()
combo.setFocus(Qt.FocusReason.OtherFocusReason)
else:
focused.clearSelection()
focused.hide()
return False
def _handle_system_table_button(self, focused: QWidget | None, button_code: int) -> bool:
if not isinstance(focused, QTableWidget):
return False
handler = getattr(self._parent, "handleSystemTableGamepadAction", None)
if not callable(handler):
return False
routes = (
('confirm', "confirm", "click"),
('back', "back", "back"),
('prev_dir', "prev_dir", "click"),
('add_game', "add_game", "click"),
)
for button_name, action, sound in routes:
if button_code in BUTTONS[button_name] and handler(focused, action):
SoundManager().play(sound)
return True
return False
def _handle_active_window_button(self, active: QWidget, button_code: int) -> bool:
if button_code in BUTTONS['back'] and isinstance(active, QDialog):
active.reject()
return True
if not isinstance(active, FullscreenDialog):
return False
if button_code in BUTTONS['prev_tab']:
active.show_prev()
elif button_code in BUTTONS['next_tab']:
active.show_next()
elif button_code in BUTTONS['back']:
active.close()
return True
def _handle_system_quick_button(self, button_code: int) -> bool:
handler = getattr(self._parent, "handleSystemGamepadAction", None)
if not callable(handler) or button_code not in BUTTONS['add_game']:
return False
if not handler("add_game"):
return False
SoundManager().play("open")
return True
def _handle_standard_button(
self, focused: QWidget | None, button_code: int, value: int
) -> None:
if button_code in BUTTONS['confirm']:
if not isinstance(focused, GameCard):
SoundManager().play_widget_sound(focused)
QTimer.singleShot(0, self._parent.activateFocusedWidget)
elif button_code in BUTTONS['back']:
self._handle_standard_back()
elif button_code in BUTTONS['add_game']:
if self._parent.stackedWidget.currentIndex() == 0:
SoundManager().play("open")
self._parent.openAddGameDialog()
elif button_code in BUTTONS['prev_tab']:
self._switch_visible_tab(-1)
elif button_code in BUTTONS['next_tab']:
self._switch_visible_tab(1)
elif button_code in BUTTONS['increase_size'] and value > 0:
self._adjust_active_slider(1)
elif button_code in BUTTONS['decrease_size'] and value > 0:
self._adjust_active_slider(-1)
def _handle_standard_back(self) -> None:
SoundManager().play("back")
if self._is_theme_store_visible():
store_stack = getattr(self._parent, "themeStoreStack", None)
detail_page = getattr(self._parent, "themeStoreDetailPage", None)
show_list = getattr(self._parent, '_show_theme_store_list', None)
if store_stack is not None and store_stack.currentWidget() == detail_page:
if callable(show_list):
show_list()
return
self._parent.goBackDetailPage(getattr(self._parent, 'currentDetailPage', None))
def _adjust_active_slider(self, direction: int) -> bool:
current_tab = self._parent.stackedWidget.currentIndex()
if current_tab == getattr(self._parent, "system_tab_index", -1):
return self._adjust_system_volume(direction)
if current_tab == 0:
manager = getattr(self._parent, 'game_library_manager', None)
slider = getattr(manager, 'sizeSlider', None)
callback = getattr(self._parent, 'on_slider_released', None)
elif current_tab == 1:
slider = getattr(self._parent, 'auto_size_slider', None)
callback = getattr(self._parent, 'on_auto_slider_released', None)
elif current_tab == getattr(self._parent, "theme_tab_index", -1):
slider = getattr(self._parent, 'themeStoreSizeSlider', None)
callback = getattr(self._parent, '_on_theme_store_slider_released', None)
else:
return False
if not isinstance(slider, QSlider):
return False
slider.setValue(slider.value() + direction * SIZE_SLIDER_STEP)
if callable(callback):
callback()
return True
def _adjust_system_volume(self, direction: int) -> bool:
section_stack = getattr(self._parent, "systemSectionStack", None)
slider = getattr(self._parent, "audioVolumeSlider", None)
if (
not isinstance(section_stack, QStackedWidget)
or section_stack.currentIndex() != SYSTEM_AUDIO_SECTION_INDEX
):
return False
if not isinstance(slider, QSlider):
return False
slider.setValue(slider.value() + direction * SYSTEM_VOLUME_STEP)
apply_volume = getattr(self._parent, "_applySelectedAudioVolume", None)
if callable(apply_volume):
apply_volume()
return True
def _handle_guide_combination(self, button_code: int, current_time: float) -> bool:
if button_code in BUTTONS['guide']:
self.guide_held = True
self.guide_pressed_time = current_time
self.in_guide_combination_attempt = True
self.guide_timer.start(int(self.guide_combination_timeout * 1000))
return True
if button_code not in BUTTONS['menu'] or not self.guide_held:
return False
self.select_pressed_time = current_time
if current_time - self.guide_pressed_time > self.guide_combination_timeout:
self._reset_guide_combination()
return False
self.guide_timer.stop()
logger.debug("Guide + Select combination detected, refreshing game grid")
self._parent.refreshGames()
self._reset_guide_combination()
return True
def _reset_guide_combination(self) -> None:
self.guide_held = False
self.in_guide_combination_attempt = False
self.guide_pressed_time = 0
self.select_pressed_time = 0
def _route_button_to_keyboard(
self, active_window: QWidget | None, button_code: int, value: int
) -> bool:
if isinstance(active_window, AddGameDialog):
focused = QApplication.focusWidget()
if button_code in BUTTONS['confirm'] and value == 1 and isinstance(focused, QLineEdit):
active_window.show_keyboard_for_widget(focused)
return True
keyboard = getattr(active_window, 'keyboard', None)
if keyboard and keyboard.isVisible():
self.handle_virtual_keyboard(button_code, value)
return True
dialog_keyboard = getattr(active_window, 'keyboard', None) if active_window else None
if isinstance(dialog_keyboard, VirtualKeyboard) and dialog_keyboard.isVisible():
self.handle_virtual_keyboard(button_code, value)
return True
keyboard = getattr(self._parent, 'keyboard', None)
if keyboard and keyboard.isVisible():
self.handle_virtual_keyboard(button_code, value)
return True
return False
+131
View File
@@ -0,0 +1,131 @@
from enum import Enum
from PySide6.QtCore import Qt
SDL_GAMEPAD_TYPE_STANDARD = 1
SDL_GAMEPAD_TYPE_XBOX360 = 2
SDL_GAMEPAD_TYPE_XBOXONE = 3
SDL_GAMEPAD_TYPE_PS3 = 4
SDL_GAMEPAD_TYPE_PS4 = 5
SDL_GAMEPAD_TYPE_PS5 = 6
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO = 7
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT = 8
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT = 9
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR = 10
SDL_GAMEPAD_TYPE_GAMECUBE = 11
SDL_GAMEPAD_TYPE_STEAM = 12
SDL_GAMEPAD_BUTTON_SOUTH = 0
SDL_GAMEPAD_BUTTON_EAST = 1
SDL_GAMEPAD_BUTTON_WEST = 2
SDL_GAMEPAD_BUTTON_NORTH = 3
SDL_GAMEPAD_BUTTON_BACK = 4
SDL_GAMEPAD_BUTTON_GUIDE = 5
SDL_GAMEPAD_BUTTON_START = 6
SDL_GAMEPAD_BUTTON_LEFT_SHOULDER = 9
SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER = 10
SDL_GAMEPAD_BUTTON_DPAD_UP = 11
SDL_GAMEPAD_BUTTON_DPAD_DOWN = 12
SDL_GAMEPAD_BUTTON_DPAD_LEFT = 13
SDL_GAMEPAD_BUTTON_DPAD_RIGHT = 14
SDL_GAMEPAD_AXIS_LEFTX = 0
SDL_GAMEPAD_AXIS_LEFTY = 1
SDL_GAMEPAD_AXIS_RIGHTX = 2
SDL_GAMEPAD_AXIS_RIGHTY = 3
SDL_GAMEPAD_AXIS_LEFT_TRIGGER = 4
SDL_GAMEPAD_AXIS_RIGHT_TRIGGER = 5
class GamepadType(Enum):
XBOX = "Xbox"
PLAYSTATION = "PlayStation"
UNKNOWN = "Unknown"
SDL3_XBOX_LIKE_TYPES = {
SDL_GAMEPAD_TYPE_STANDARD,
SDL_GAMEPAD_TYPE_XBOX360,
SDL_GAMEPAD_TYPE_XBOXONE,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT,
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR,
SDL_GAMEPAD_TYPE_GAMECUBE,
SDL_GAMEPAD_TYPE_STEAM,
}
SDL3_PLAYSTATION_TYPES = {
SDL_GAMEPAD_TYPE_PS3,
SDL_GAMEPAD_TYPE_PS4,
SDL_GAMEPAD_TYPE_PS5,
}
PAD_BUTTON_SOUTH = 1000
PAD_BUTTON_EAST = 1001
PAD_BUTTON_WEST = 1002
PAD_BUTTON_NORTH = 1003
PAD_BUTTON_SELECT = 1004
PAD_BUTTON_GUIDE = 1005
PAD_BUTTON_START = 1006
PAD_BUTTON_LEFT_SHOULDER = 1007
PAD_BUTTON_RIGHT_SHOULDER = 1008
PAD_AXIS_LEFT_TRIGGER = 1100
PAD_AXIS_RIGHT_TRIGGER = 1101
PAD_AXIS_LEFT_X = 1200
PAD_AXIS_LEFT_Y = 1201
PAD_AXIS_RIGHT_X = 1202
PAD_AXIS_RIGHT_Y = 1203
PAD_DPAD_X = 1300
PAD_DPAD_Y = 1301
INPUT_AXIS_SCALE = 32767
KEY_BACKSPACE = Qt.Key.Key_Backspace.value
KEY_DOWN = Qt.Key.Key_Down.value
KEY_ENTER = Qt.Key.Key_Enter.value
KEY_ESCAPE = Qt.Key.Key_Escape.value
KEY_F5 = Qt.Key.Key_F5.value
KEY_F10 = Qt.Key.Key_F10.value
KEY_F11 = Qt.Key.Key_F11.value
KEY_LEFT = Qt.Key.Key_Left.value
KEY_RETURN = Qt.Key.Key_Return.value
KEY_RIGHT = Qt.Key.Key_Right.value
KEY_UP = Qt.Key.Key_Up.value
KEY_E = ord("e")
KEY_Q = ord("q")
MOD_CTRL = 1
MOD_SHIFT = 2
SIZE_SLIDER_STEP = 10
SYSTEM_AUDIO_SECTION_INDEX = 4
SYSTEM_VOLUME_STEP = 5
BUTTONS = {
'confirm': {PAD_BUTTON_SOUTH},
'back': {PAD_BUTTON_EAST},
'add_game': {PAD_BUTTON_WEST},
'prev_dir': {PAD_BUTTON_NORTH},
'prev_tab': {PAD_BUTTON_LEFT_SHOULDER},
'next_tab': {PAD_BUTTON_RIGHT_SHOULDER},
'context_menu': {PAD_BUTTON_START},
'menu': {PAD_BUTTON_SELECT},
'guide': {PAD_BUTTON_GUIDE},
'increase_size': {PAD_AXIS_RIGHT_TRIGGER},
'decrease_size': {PAD_AXIS_LEFT_TRIGGER},
}
SDL_CONTROLLER_BUTTON_TO_PAD = {
SDL_GAMEPAD_BUTTON_SOUTH: PAD_BUTTON_SOUTH,
SDL_GAMEPAD_BUTTON_EAST: PAD_BUTTON_EAST,
SDL_GAMEPAD_BUTTON_WEST: PAD_BUTTON_WEST,
SDL_GAMEPAD_BUTTON_NORTH: PAD_BUTTON_NORTH,
SDL_GAMEPAD_BUTTON_BACK: PAD_BUTTON_SELECT,
SDL_GAMEPAD_BUTTON_GUIDE: PAD_BUTTON_GUIDE,
SDL_GAMEPAD_BUTTON_START: PAD_BUTTON_START,
SDL_GAMEPAD_BUTTON_LEFT_SHOULDER: PAD_BUTTON_LEFT_SHOULDER,
SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER: PAD_BUTTON_RIGHT_SHOULDER,
}
SDL_CONTROLLER_AXIS_TO_PAD = {
SDL_GAMEPAD_AXIS_LEFTX: PAD_AXIS_LEFT_X,
SDL_GAMEPAD_AXIS_LEFTY: PAD_AXIS_LEFT_Y,
SDL_GAMEPAD_AXIS_RIGHTX: PAD_AXIS_RIGHT_X,
SDL_GAMEPAD_AXIS_RIGHTY: PAD_AXIS_RIGHT_Y,
SDL_GAMEPAD_AXIS_LEFT_TRIGGER: PAD_AXIS_LEFT_TRIGGER,
SDL_GAMEPAD_AXIS_RIGHT_TRIGGER: PAD_AXIS_RIGHT_TRIGGER,
}
+711
View File
@@ -0,0 +1,711 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any, cast
from PySide6.QtCore import QObject, Qt
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QCheckBox,
QComboBox,
QListView,
QListWidget,
QMenu,
QStackedWidget,
QTableWidget,
QWidget,
)
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.input_manager.constants import BUTTONS, PAD_DPAD_X, PAD_DPAD_Y
from portprotonqt.logger import get_logger
from portprotonqt.input_manager.mixin import InputMixin
from portprotonqt.sound_manager import SoundManager
logger = get_logger(__name__)
@dataclass
class InputSurface:
name: str
dialog: Any
button_handler: Callable[[int, int], None]
dpad_handler: Callable[[int, int, float], None]
connections: list[tuple[Any, Callable[..., Any]]] = field(default_factory=list)
class DialogInputModesMixin(InputMixin):
# WINETRICKS MODE
def enable_winetricks_mode(self, winetricks_dialog):
"""Setup gamepad handling for WinetricksDialog"""
try:
self._setup_mode_handlers(
winetricks_dialog,
self.handle_winetricks_button,
self.handle_winetricks_dpad,
'winetricks_dialog'
)
logger.debug("Gamepad handling successfully connected for WinetricksDialog")
except Exception as e:
logger.error(f"Error connecting gamepad handlers for Winetricks: {e}")
def disable_winetricks_mode(self):
"""Restore original main window handlers"""
try:
if self.winetricks_dialog:
self._restore_original_handlers('winetricks_dialog')
logger.debug("Gamepad handling successfully restored from Winetricks")
except Exception as e:
logger.error(f"Error restoring gamepad handlers from Winetricks: {e}")
def handle_winetricks_button(self, button_code, value):
if self.winetricks_dialog is None or value == 0:
return
try:
# Handle common UI elements like QMessageBox, QMenu, etc.
if self._handle_common_ui_elements(button_code):
return
# Winetricks-specific button handling
focused = QApplication.focusWidget()
if button_code in BUTTONS['confirm']: # A: Toggle checkbox
if isinstance(focused, QTableWidget):
self.handle_table_confirm(focused)
return
elif button_code in BUTTONS['add_game']: # X: Install
self.winetricks_dialog.install_selected(force=False)
elif button_code in BUTTONS['prev_dir']: # Y: Force Install
self.winetricks_dialog.install_selected(force=True)
elif button_code in BUTTONS['back']: # B: Cancel
self.winetricks_dialog.reject()
elif button_code in BUTTONS['prev_tab']: # LB
current_index = self.winetricks_dialog.tab_widget.currentIndex()
new_index = max(0, current_index - 1)
self.winetricks_dialog.tab_widget.setCurrentIndex(new_index)
if new_index != current_index:
SoundManager().play("tab_switch")
self._focus_first_row_in_current_table()
elif button_code in BUTTONS['next_tab']: # RB
current_index = self.winetricks_dialog.tab_widget.currentIndex()
new_index = min(self.winetricks_dialog.tab_widget.count() - 1, current_index + 1)
self.winetricks_dialog.tab_widget.setCurrentIndex(new_index)
if new_index != current_index:
SoundManager().play("tab_switch")
self._focus_first_row_in_current_table()
else:
self._parent.activateFocusedWidget()
except Exception as e:
logger.error(f"Error in handle_winetricks_button: {e}")
def handle_winetricks_dpad(self, code, value, now):
if self.winetricks_dialog is None:
return
try:
if value == 0: # Release
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
return
# Timer setup
if self.current_dpad_code != code or self.current_dpad_value != value:
self.dpad_timer.stop()
self.dpad_timer.setInterval(150 if self.dpad_timer.isActive() else 300)
self.dpad_timer.start()
self.current_dpad_code = code
self.current_dpad_value = value
table = self._get_current_table()
if not table or table.rowCount() == 0:
return
current_row = table.currentRow()
if code == PAD_DPAD_Y: # Up/Down
step = -1 if value < 0 else 1
new_row = current_row + step
# Skip hidden rows
while 0 <= new_row < table.rowCount() and table.isRowHidden(new_row):
new_row += step
# Bounds check
if new_row < 0:
new_row = current_row
if new_row >= table.rowCount():
new_row = current_row
if new_row != current_row:
table.setCurrentCell(new_row, 0)
table.setFocus(Qt.FocusReason.OtherFocusReason)
except Exception as e:
logger.error(f"Error in handle_winetricks_dpad: {e}")
def _get_current_table(self):
if self.winetricks_dialog:
current_container = self.winetricks_dialog.tab_widget.currentWidget()
if isinstance(current_container, QStackedWidget):
current_table = current_container.widget(1)
if isinstance(current_table, QTableWidget):
return current_table
return None
def _focus_first_row_in_current_table(self):
table = self._get_current_table()
if table and table.rowCount() > 0:
table.setCurrentCell(0, 0)
table.setFocus(Qt.FocusReason.OtherFocusReason)
def _get_navigable_menu_actions(self, menu: QMenu) -> list:
return [
action
for action in menu.actions()
if not action.isSeparator() and action.isEnabled() and action.isVisible()
]
def _navigate_menu_actions(self, menu: QMenu, direction_down: bool) -> None:
actions = self._get_navigable_menu_actions(menu)
if not actions:
return
current_action = menu.activeAction()
if current_action not in actions:
target_index = 0 if direction_down else len(actions) - 1
menu.setActiveAction(actions[target_index])
SoundManager().play("navigate")
return
current_index = actions.index(current_action)
step = 1 if direction_down else -1
next_index = (current_index + step) % len(actions)
menu.setActiveAction(actions[next_index])
SoundManager().play("navigate")
# TABLE NAVIGATION METHODS
def handle_table_navigation(self, table: QTableWidget, code: int, value: int):
"""
Handle navigation in table
Args:
table: QTableWidget for navigation handling
code: Event code (usually ABS_HAT0X or ABS_HAT0Y)
value: Event value (direction)
"""
row_count = table.rowCount()
if row_count <= 0:
return
current_row = table.currentRow()
if current_row < 0:
current_row = 0
table.setCurrentCell(0, 0)
if code == PAD_DPAD_Y and value != 0:
# Vertical navigation
if value > 0: # Down
new_row = min(current_row + 1, row_count - 1)
elif value < 0: # Up
new_row = max(current_row - 1, 0)
else:
return
table.setCurrentCell(new_row, table.currentColumn())
item = table.item(new_row, table.currentColumn())
if item:
table.scrollToItem(
item,
QAbstractItemView.ScrollHint.PositionAtCenter
)
table.setFocus(Qt.FocusReason.OtherFocusReason)
return
elif code == PAD_DPAD_X and value != 0:
# Horizontal navigation
col_count = table.columnCount()
current_col = table.currentColumn()
if current_col < 0:
current_col = 0
if value < 0: # Left
new_col = max(current_col - 1, 0)
elif value > 0: # Right
new_col = min(current_col + 1, col_count - 1)
else:
return
table.setCurrentCell(table.currentRow(), new_col)
table.setFocus(Qt.FocusReason.OtherFocusReason)
return
def handle_table_confirm(self, table: QTableWidget):
"""
Handle confirmation (e.g., A press) for table
Args:
table: QTableWidget for confirmation handling
"""
current_row = table.currentRow()
current_col = table.currentColumn()
if current_row >= 0 and current_col >= 0:
cell_widget = table.cellWidget(current_row, current_col)
if isinstance(cell_widget, QCheckBox) and cell_widget.isEnabled():
cell_widget.setChecked(not cell_widget.isChecked())
return True
if cell_widget is not None:
checkbox = cell_widget.findChild(QCheckBox)
if checkbox and checkbox.isEnabled():
checkbox.setChecked(not checkbox.isChecked())
return True
# Check if the cell contains a checkbox
item = table.item(current_row, current_col)
if item and (item.flags() & Qt.ItemFlag.ItemIsUserCheckable):
# Toggle the checkbox state
new_state = Qt.CheckState.Checked if item.checkState() == Qt.CheckState.Unchecked else Qt.CheckState.Unchecked
item.setCheckState(new_state)
return True
# Call custom confirm callback if exists
callback = getattr(table, '_on_confirm_callback', None) # type: ignore
if callback and callable(callback):
callback(table, current_row, current_col)
return True
# WIDGET NAVIGATION METHODS
def setup_widget_navigation(self, widget: QWidget, navigation_type: str = "default", **kwargs):
"""
Set navigation for widget
Args:
widget: QWidget for navigation setup
navigation_type: Navigation type ('table', 'list', 'combo', 'default')
**kwargs: Additional parameters for navigation
"""
widget.installEventFilter(cast(QObject, self))
# Use direct assignment for custom navigation properties, with type ignore for pyright
widget._navigation_type = navigation_type # type: ignore
for key, value in kwargs.items():
setattr(widget, f'_{key}', value)
def handle_widget_navigation(self, widget: QWidget, code: int, value: int):
"""
Handle navigation in widget
Args:
widget: QWidget for navigation handling
code: Event code (usually ABS_HAT0X or ABS_HAT0Y)
value: Event value (direction)
"""
nav_type = getattr(widget, '_navigation_type', 'default') # type: ignore
if nav_type == 'table' and isinstance(widget, QTableWidget):
self.handle_table_navigation(widget, code, value)
elif nav_type == 'list' and isinstance(widget, QListWidget):
self.handle_list_navigation(widget, code, value)
elif nav_type == 'combo' and isinstance(widget, QComboBox):
self.handle_combo_navigation(widget, code, value)
else:
# Default navigation behavior
if isinstance(widget, QTableWidget):
self.handle_table_navigation(widget, code, value)
elif isinstance(widget, QListWidget):
self.handle_list_navigation(widget, code, value)
elif isinstance(widget, QComboBox):
self.handle_combo_navigation(widget, code, value)
def handle_list_navigation(self, list_widget: QListWidget, code: int, value: int):
"""
Handle navigation in list
Args:
list_widget: QListWidget for navigation handling
code: Event code (usually ABS_HAT0X or ABS_HAT0Y)
value: Event value (direction)
"""
if code == PAD_DPAD_Y and value != 0:
model = list_widget.model()
current_index = list_widget.currentIndex()
if model and current_index.isValid():
row_count = model.rowCount()
current_row = current_index.row()
if value > 0: # Down
next_row = min(current_row + 1, row_count - 1)
list_widget.setCurrentIndex(model.index(next_row, current_index.column()))
elif value < 0: # Up
prev_row = max(current_row - 1, 0)
list_widget.setCurrentIndex(model.index(prev_row, current_index.column()))
list_widget.scrollTo(list_widget.currentIndex(), QListView.ScrollHint.PositionAtCenter)
def handle_combo_navigation(self, combo_widget: QComboBox, code: int, value: int):
"""
Handle navigation in combo widget
Args:
combo_widget: QComboBox for navigation handling
code: Event code (usually ABS_HAT0X or ABS_HAT0Y)
value: Event value (direction)
"""
if code == PAD_DPAD_Y and value != 0:
current_index = combo_widget.currentIndex()
if value > 0: # Down
new_index = min(current_index + 1, combo_widget.count() - 1)
elif value < 0: # Up
new_index = max(current_index - 1, 0)
else:
return
if new_index != current_index:
combo_widget.setCurrentIndex(new_index)
def _setup_mode_handlers(self, dialog_instance, button_handler, dpad_handler, dialog_attr_name):
"""Register a dialog as the active input surface."""
if not self._input_surfaces:
self._input_surface_base_state = self._gamepad_handling_enabled
previous_surface = next(
(surface for surface in self._input_surfaces if surface.name == dialog_attr_name),
None,
)
if previous_surface is not None:
for signal, callback in previous_surface.connections:
signal.disconnect(callback)
self._input_surfaces = [
surface for surface in self._input_surfaces
if surface.name != dialog_attr_name
]
surface = InputSurface(
dialog_attr_name,
dialog_instance,
button_handler,
dpad_handler,
)
self._input_surfaces.append(surface)
setattr(self, dialog_attr_name, dialog_instance)
self._gamepad_handling_enabled = True
self._reset_surface_navigation()
def _restore_original_handlers(self, dialog_attr_name):
"""Remove a dialog input surface and restore the preceding surface."""
surface = next(
(item for item in reversed(self._input_surfaces) if item.name == dialog_attr_name),
None,
)
if surface is None:
logger.warning("Cannot restore input surface: %s is not registered", dialog_attr_name)
return
for signal, callback in surface.connections:
signal.disconnect(callback)
self._input_surfaces.remove(surface)
setattr(self, dialog_attr_name, None)
if not self._input_surfaces and self._input_surface_base_state is not None:
self._gamepad_handling_enabled = self._input_surface_base_state
self._input_surface_base_state = None
self._reset_surface_navigation()
def connect_surface_updates(
self,
dialog_attr_name: str,
callback: Callable[..., Any],
) -> None:
"""Connect a callback for the lifetime of a dialog input surface."""
surface = next(
(item for item in reversed(self._input_surfaces) if item.name == dialog_attr_name),
None,
)
if surface is None:
logger.warning("Cannot connect input surface: %s is not registered", dialog_attr_name)
return
for signal in (self.button_event, self.dpad_moved):
signal.connect(callback)
surface.connections.append((signal, callback))
def connect_surface_signal(
self,
dialog_attr_name: str,
signal: Any,
callback: Callable[..., Any],
) -> None:
"""Connect one signal for the lifetime of a dialog input surface."""
surface = next(
(item for item in reversed(self._input_surfaces) if item.name == dialog_attr_name),
None,
)
if surface is None:
logger.warning("Cannot connect input surface: %s is not registered", dialog_attr_name)
return
signal.connect(callback)
surface.connections.append((signal, callback))
def _reset_surface_navigation(self) -> None:
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
def _route_surface_button(self, button_code: int, value: int) -> bool:
if not self._input_surfaces:
return False
self._input_surfaces[-1].button_handler(button_code, value)
return True
def _route_surface_dpad(self, code: int, value: int, current_time: float) -> bool:
if not self._input_surfaces:
return False
self._input_surfaces[-1].dpad_handler(code, value, current_time)
return True
# PROTON MANAGER SUPPORT
def enable_proton_manager_mode(self, proton_manager_dialog):
"""Setup gamepad handling for ProtonManagerDialog"""
try:
self._setup_mode_handlers(
proton_manager_dialog,
self.handle_proton_manager_button,
self.handle_proton_manager_dpad,
'proton_manager_dialog'
)
logger.debug("Gamepad handling successfully connected for ProtonManager")
except Exception as e:
logger.error(f"Error connecting gamepad handlers for ProtonManager: {e}")
def disable_proton_manager_mode(self):
"""Restore original main window handlers"""
try:
if self.proton_manager_dialog:
self._restore_original_handlers('proton_manager_dialog')
logger.debug("Gamepad handling successfully restored from ProtonManager")
except Exception as e:
logger.error(f"Error restoring gamepad handlers from ProtonManager: {e}")
def handle_proton_manager_button(self, button_code, value):
if self.proton_manager_dialog is None or value == 0:
return
try:
# Handle common UI elements like QMessageBox, QMenu, etc.
if self._handle_common_ui_elements(button_code):
return
# ProtonManager-specific button handling
focused = QApplication.focusWidget()
if button_code in BUTTONS['confirm']: # A: Toggle checkbox
if isinstance(focused, QTableWidget):
current_row = focused.currentRow()
if current_row >= 0:
checkbox_widget = focused.cellWidget(current_row, 0)
if checkbox_widget:
checkbox = checkbox_widget.findChild(QCheckBox)
if checkbox and checkbox.isEnabled():
checkbox.setChecked(not checkbox.isChecked())
return
elif button_code in BUTTONS['add_game']: # X: Download
self.proton_manager_dialog.download_selected()
elif button_code in BUTTONS['prev_dir']: # Y: Clear
self.proton_manager_dialog.clear_selection()
elif button_code in BUTTONS['back']: # B: Cancel/Close
# Cancel any active downloads/extractions before closing
if (self.proton_manager_dialog.current_extraction_thread and
self.proton_manager_dialog.current_extraction_thread.isRunning()) or \
(self.proton_manager_dialog.current_download_thread and
hasattr(self.proton_manager_dialog.current_download_thread, 'isRunning') and
self.proton_manager_dialog.current_download_thread.isRunning()):
# If there's an active download/extraction, cancel it
self.proton_manager_dialog.cancel_current_download()
else:
# If no active processes, just close the dialog
self.proton_manager_dialog.reject()
elif button_code in BUTTONS['prev_tab']: # LB: Previous tab
current_index = self.proton_manager_dialog.tab_widget.currentIndex()
new_index = max(0, current_index - 1)
self.proton_manager_dialog.tab_widget.setCurrentIndex(new_index)
if new_index != current_index:
SoundManager().play("tab_switch")
self._focus_first_row_in_current_proton_manager_table()
elif button_code in BUTTONS['next_tab']: # RB: Next tab
current_index = self.proton_manager_dialog.tab_widget.currentIndex()
new_index = min(self.proton_manager_dialog.tab_widget.count() - 1, current_index + 1)
self.proton_manager_dialog.tab_widget.setCurrentIndex(new_index)
if new_index != current_index:
SoundManager().play("tab_switch")
self._focus_first_row_in_current_proton_manager_table()
else:
self._parent.activateFocusedWidget()
except Exception as e:
logger.error(f"Error in handle_proton_manager_button: {e}")
def handle_proton_manager_dpad(self, code, value, now):
if self.proton_manager_dialog is None:
return
try:
if value == 0: # Release
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
return
# Timer setup
if self.current_dpad_code != code or self.current_dpad_value != value:
self.dpad_timer.stop()
self.dpad_timer.setInterval(150 if self.dpad_timer.isActive() else 300)
self.dpad_timer.start()
self.current_dpad_code = code
self.current_dpad_value = value
table = self._get_current_proton_manager_table()
if not table or table.rowCount() == 0:
return
current_row = table.currentRow()
if code == PAD_DPAD_Y: # Up/Down
step = -1 if value < 0 else 1
new_row = current_row + step
# Skip hidden rows
while 0 <= new_row < table.rowCount() and table.isRowHidden(new_row):
new_row += step
# Bounds check
if new_row < 0:
new_row = current_row
if new_row >= table.rowCount():
new_row = current_row
if new_row != current_row:
table.setCurrentCell(new_row, 0)
table.setFocus(Qt.FocusReason.OtherFocusReason)
except Exception as e:
logger.error(f"Error in handle_proton_manager_dpad: {e}")
def _get_current_proton_manager_table(self):
if self.proton_manager_dialog:
current_container = self.proton_manager_dialog.tab_widget.currentWidget()
if current_container:
table = current_container.findChild(QTableWidget)
return table
return None
def _focus_first_row_in_current_proton_manager_table(self):
table = self._get_current_proton_manager_table()
if table and table.rowCount() > 0:
table.setCurrentCell(0, 0)
table.setFocus(Qt.FocusReason.OtherFocusReason)
def enable_appimage_update_mode(self, appimage_update_dialog):
"""Setup gamepad handling for AppImageUpdateDialog"""
try:
self._setup_mode_handlers(
appimage_update_dialog,
self.handle_appimage_update_button,
self.handle_appimage_update_dpad,
'appimage_update_dialog'
)
logger.debug("Gamepad handling successfully connected for AppImageUpdateDialog")
except Exception as e:
logger.error(f"Error connecting gamepad handlers for AppImageUpdateDialog: {e}")
def disable_appimage_update_mode(self):
"""Restore original main window handlers"""
try:
if self.appimage_update_dialog:
self._restore_original_handlers('appimage_update_dialog')
logger.debug("Gamepad handling successfully restored from AppImageUpdateDialog")
except Exception as e:
logger.error(f"Error restoring gamepad handlers from AppImageUpdateDialog: {e}")
def handle_appimage_update_button(self, button_code, value):
if self.appimage_update_dialog is None or value == 0:
return
try:
if self._handle_common_ui_elements(button_code):
return
focused = QApplication.focusWidget()
if button_code in BUTTONS['confirm']:
if isinstance(focused, AutoSizeButton):
focused.click()
return
elif button_code in BUTTONS['back']:
self.appimage_update_dialog.reject()
return
self._parent.activateFocusedWidget()
except Exception as e:
logger.error(f"Error in handle_appimage_update_button: {e}")
def handle_appimage_update_dpad(self, code, value, now):
if self.appimage_update_dialog is None:
return
try:
if value == 0:
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
return
if self.current_dpad_code != code or self.current_dpad_value != value:
self.dpad_timer.stop()
self.dpad_timer.setInterval(150 if self.dpad_timer.isActive() else 300)
self.dpad_timer.start()
self.current_dpad_code = code
self.current_dpad_value = value
changelog = self.appimage_update_dialog.changelog_text
buttons = self.appimage_update_dialog.action_buttons
if changelog and changelog.hasFocus():
if code == PAD_DPAD_Y and value != 0:
scrollbar = changelog.verticalScrollBar()
at_bottom = scrollbar.value() >= scrollbar.maximum()
at_top = scrollbar.value() <= scrollbar.minimum()
if value > 0 and at_bottom:
if buttons:
buttons[0].setFocus(Qt.FocusReason.OtherFocusReason)
return
if value < 0 and at_top:
return
step = 40
scrollbar.setValue(scrollbar.value() + (step if value > 0 else -step))
return
if code == PAD_DPAD_Y and value != 0 and buttons:
focused = QApplication.focusWidget()
if focused in buttons:
if value < 0 and focused == buttons[0]:
if changelog:
changelog.setFocus(Qt.FocusReason.OtherFocusReason)
return
if code == PAD_DPAD_X and value != 0 and buttons:
focused = QApplication.focusWidget()
if focused in buttons:
idx = buttons.index(focused)
if value < 0:
new_idx = max(0, idx - 1)
else:
new_idx = min(len(buttons) - 1, idx + 1)
buttons[new_idx].setFocus(Qt.FocusReason.OtherFocusReason)
else:
buttons[0].setFocus(Qt.FocusReason.OtherFocusReason)
except Exception as e:
logger.error(f"Error in handle_appimage_update_dpad: {e}")
+537
View File
@@ -0,0 +1,537 @@
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",
"deleteThemeButton",
):
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
combo_popup = (
isinstance(focused, QListView)
and self._find_parent_combo(focused) is not None
)
if is_initial_press and not isinstance(focused, GameCard) and not combo_popup:
SoundManager().play("navigate")
if self._handle_dialog_dpad(active, code, value):
return
if self._handle_popup_dpad(popup, code, value):
return
if self._handle_list_dpad(focused, code, value):
return
if self._handle_fullscreen_dpad(active, code, value):
return
if self._handle_system_section_horizontal(code, value, is_initial_press):
return
if isinstance(focused, QTableWidget):
self.handle_table_navigation(focused, code, value)
return
if self._focus_toolbar_from_first_card(code, value):
return
if self._handle_library_dpad(code, value):
return
if code in (PAD_DPAD_X, PAD_DPAD_Y) and self._handle_theme_tab_navigation(code, value):
return
if self._handle_system_section_vertical(code, value):
return
if self._handle_detail_page_dpad(code, value):
return
self._handle_page_vertical_dpad(code, value)
def _handle_detail_page_dpad(self, code: int, value: int) -> bool:
direction = self._normalize_dpad_direction(code, value)
if direction is None:
return False
focused = QApplication.focusWidget()
page = self._parent.stackedWidget.currentWidget()
current_detail_page = getattr(self._parent, "currentDetailPage", None)
if not isinstance(focused, AutoSizeButton) or page != current_detail_page:
return False
parent = focused.parentWidget()
if parent is None:
return False
rows = self._get_detail_button_rows(parent)
if len(rows) == 1 and len(rows[0]) <= 1:
return False
target = self._get_detail_button_target(rows, focused, direction)
if target is None:
return False
self._focus_detail_button(target)
return True
def _handle_library_dpad(self, code: int, value: int) -> bool:
if code not in (PAD_DPAD_X, PAD_DPAD_Y):
return False
current_index = self._parent.stackedWidget.currentIndex()
if current_index not in (0, 1):
return False
if self._handle_toolbar_navigation(code, value):
return True
if code == PAD_DPAD_Y and value < 0 and self._focus_tab_from_search(current_index):
return True
container = (
self._parent.gamesListWidget
if current_index == 0
else self._parent.autoInstallContainer
)
if container is not None:
self._navigate_game_cards(container, current_index, code, value)
return True
def _handle_page_vertical_dpad(self, code: int, value: int) -> bool:
if code != PAD_DPAD_Y or value == 0:
return False
focused = QApplication.focusWidget()
if focused is None:
return False
if value < 0:
focused.focusPreviousChild()
return True
if not isinstance(focused, NavLabel):
focused.focusNextChild()
return True
page = self._parent.stackedWidget.currentWidget()
focusables = page.findChildren(
QWidget,
options=Qt.FindChildOption.FindChildrenRecursively,
)
focusables = [
widget for widget in focusables
if widget.focusPolicy() & Qt.FocusPolicy.StrongFocus
]
if not focusables:
return False
focusables[0].setFocus()
return True
def _normalize_dpad_direction(self, code: int, value: int) -> tuple[int, int] | None:
if code in (PAD_DPAD_X, PAD_DPAD_Y):
return (code, value) if value != 0 else None
if code not in (PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y) or abs(value) < self.dead_zone:
return None
normalized_code = PAD_DPAD_X if code == PAD_AXIS_LEFT_X else PAD_DPAD_Y
return normalized_code, 1 if value > self.dead_zone else -1
def _get_detail_button_rows(self, parent: QWidget) -> list[list[AutoSizeButton]]:
buttons = parent.findChildren(
AutoSizeButton,
options=Qt.FindChildOption.FindDirectChildrenOnly,
)
buttons = [button for button in buttons if button.isVisible() and button.isEnabled()]
centers = {button: button.geometry().center() for button in buttons}
rows: list[list[AutoSizeButton]] = []
for button in sorted(buttons, key=lambda item: (centers[item].y(), centers[item].x())):
if not rows or abs(centers[button].y() - centers[rows[-1][0]].y()) > 24:
rows.append([button])
else:
rows[-1].append(button)
for row in rows:
row.sort(key=lambda item: centers[item].x())
return rows
def _get_detail_button_target(
self, rows: list[list[AutoSizeButton]], focused: AutoSizeButton,
direction: tuple[int, int],
) -> AutoSizeButton | None:
row_index = next((index for index, row in enumerate(rows) if focused in row), -1)
if row_index < 0:
return None
code, value = direction
if code == PAD_DPAD_X:
flat = [button for row in rows for button in row]
index = flat.index(focused) + value
return flat[index] if 0 <= index < len(flat) else None
target_row_index = row_index + value
if not 0 <= target_row_index < len(rows):
return None
focused_x = focused.geometry().center().x()
return min(
rows[target_row_index],
key=lambda button: abs(button.geometry().center().x() - focused_x),
)
def _focus_detail_button(self, target: AutoSizeButton) -> None:
target.setFocus(Qt.FocusReason.OtherFocusReason)
parent = target.parentWidget()
while parent and not isinstance(parent, QScrollArea):
parent = parent.parentWidget()
if isinstance(parent, QScrollArea):
parent.ensureWidgetVisible(target, 20, 20)
def _reset_dpad_repeat(self) -> None:
self.current_dpad_code = None
self.current_dpad_value = 0
self.axis_moving = False
self.current_axis_delay = self.initial_axis_move_delay
self.dpad_timer.stop()
def _start_dpad_repeat(self, code: int, value: int, current_time: float) -> None:
self.current_dpad_code = code
self.current_dpad_value = value
if self.axis_moving:
return
self.axis_moving = True
self.last_move_time = current_time
self.current_axis_delay = self.initial_axis_move_delay
self.dpad_timer.start(int(self.repeat_axis_move_delay * 1000))
def _handle_keyboard_dpad(self, keyboard: VirtualKeyboard, code: int, value: int) -> bool:
directions = {
PAD_DPAD_X: (keyboard.move_focus_left, keyboard.move_focus_right),
PAD_AXIS_LEFT_X: (keyboard.move_focus_left, keyboard.move_focus_right),
PAD_DPAD_Y: (keyboard.move_focus_up, keyboard.move_focus_down),
PAD_AXIS_LEFT_Y: (keyboard.move_focus_up, keyboard.move_focus_down),
}
actions = directions.get(code)
if actions is None:
return False
if code in (PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y):
if abs(value) < self.dead_zone:
self._reset_dpad_repeat()
return True
value = 1 if value > self.dead_zone else -1
actions[1 if value > 0 else 0]()
return True
+317
View File
@@ -0,0 +1,317 @@
import os
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QApplication,
QDialog,
QLineEdit,
QMenu,
QPushButton,
QTableWidget,
QWidget,
)
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.input_manager.constants import (
BUTTONS,
PAD_AXIS_LEFT_X,
PAD_AXIS_LEFT_Y,
PAD_DPAD_X,
PAD_DPAD_Y,
)
from portprotonqt.logger import get_logger
from portprotonqt.input_manager.mixin import InputMixin
from portprotonqt.virtual_keyboard import VirtualKeyboard
logger = get_logger(__name__)
class FileExplorerInputMixin(InputMixin):
# FILE EXPLORER MODE
def enable_file_explorer_mode(self, file_explorer):
"""Configure gamepad handling for FileExplorer"""
try:
self._setup_mode_handlers(
file_explorer,
self.handle_file_explorer_button,
self.handle_file_explorer_dpad,
'file_explorer'
)
logger.debug("Gamepad handling successfully connected for FileExplorer")
except Exception as e:
logger.error(f"Error connecting gamepad handlers for FileExplorer: {e}")
def disable_file_explorer_mode(self):
"""Restore original handlers (return default)"""
try:
if self.file_explorer:
# Additional cleanup for file explorer
self.nav_timer.stop()
self._restore_original_handlers('file_explorer')
logger.debug("Gamepad handling successfully restored")
except Exception as e:
logger.error(f"Error restoring gamepad handlers: {e}")
def handle_file_explorer_button(self, button_code, value):
active_window = QApplication.activeWindow()
dialog_keyboard = getattr(active_window, 'keyboard', None) if active_window else None
if isinstance(dialog_keyboard, VirtualKeyboard) and dialog_keyboard.isVisible():
self.handle_virtual_keyboard(button_code, value)
return
keyboard = getattr(self._parent, 'keyboard', None)
if keyboard and keyboard.isVisible():
self.handle_virtual_keyboard(button_code, value)
return
if value == 0: # Ignore releases
return
try:
# 1. Handle Popups (Menus)
popup = QApplication.activePopupWidget()
if isinstance(popup, QMenu):
if button_code in BUTTONS['confirm']: # A button
if popup.activeAction():
popup.activeAction().trigger()
popup.close()
return
elif button_code in BUTTONS['back']: # B button
popup.close()
return
return # Skip other handling if menu is open
active_window = QApplication.activeWindow()
if isinstance(active_window, QDialog) and active_window != self.file_explorer:
if button_code in BUTTONS['confirm']: # A button
focused_widget = QApplication.focusWidget()
if isinstance(focused_widget, QLineEdit):
keyboard = getattr(active_window, 'keyboard', None)
if not keyboard:
keyboard = getattr(self._parent, 'keyboard', None)
if keyboard:
focused_widget.setFocus()
keyboard.show_for_widget(focused_widget)
elif isinstance(focused_widget, QPushButton):
focused_widget.click()
else:
active_window.accept()
elif button_code in BUTTONS['back']: # B button
active_window.reject()
return
# 2. Validate File Explorer state
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list'):
logger.debug("No file explorer or file_list available")
return
focused_widget = QApplication.focusWidget()
# 3. Handle Buttons
if button_code in BUTTONS['confirm']: # A button
# Check if a drive button is focused
if isinstance(focused_widget, AutoSizeButton) and \
hasattr(self.file_explorer, 'drive_buttons') and \
focused_widget in self.file_explorer.drive_buttons:
self.file_explorer.select_drive()
return
if self.file_explorer.file_list.count() == 0:
return
selected = self.file_explorer.file_list.currentItem().text()
full_path = os.path.join(self.file_explorer.current_path, selected)
if os.path.isdir(full_path):
self.file_explorer.current_path = os.path.normpath(full_path)
self.file_explorer.update_file_list()
elif not self.file_explorer.directory_only:
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
self.file_explorer.accept()
else:
logger.debug(f"Selected item is not a directory: {full_path}")
elif button_code in BUTTONS['context_menu']: # Start button
if self.file_explorer.file_list.count() == 0:
return
current_item = self.file_explorer.file_list.currentItem()
if current_item:
item_rect = self.file_explorer.file_list.visualItemRect(current_item)
self.file_explorer.show_folder_context_menu(item_rect.center())
elif button_code in BUTTONS['add_game']: # X button
if self.file_explorer.file_list.count() == 0:
return
selected = self.file_explorer.file_list.currentItem().text()
full_path = os.path.join(self.file_explorer.current_path, selected)
if os.path.isdir(full_path):
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
self.file_explorer.accept()
elif button_code in BUTTONS['back']: # B button
self.file_explorer.close()
elif button_code in BUTTONS['prev_dir']: # Y button
self.file_explorer.previous_dir()
else:
self._handle_default_button(button_code, value)
except Exception as e:
logger.error(f"Error in FileExplorer button handler: {e}")
def handle_file_explorer_dpad(self, code, value, current_time):
try:
active_window = QApplication.activeWindow()
keyboard = getattr(active_window, 'keyboard', None) if active_window else None
if keyboard is None:
keyboard = getattr(self._parent, 'keyboard', None)
if keyboard and keyboard.isVisible():
if code in (PAD_DPAD_X, PAD_AXIS_LEFT_X):
normalized_value = value
if code == PAD_AXIS_LEFT_X:
if abs(value) < self.dead_zone:
return
normalized_value = 1 if value > self.dead_zone else -1
if normalized_value > 0:
keyboard.move_focus_right()
elif normalized_value < 0:
keyboard.move_focus_left()
elif code in (PAD_DPAD_Y, PAD_AXIS_LEFT_Y):
normalized_value = value
if code == PAD_AXIS_LEFT_Y:
if abs(value) < self.dead_zone:
return
normalized_value = 1 if value > self.dead_zone else -1
if normalized_value > 0:
keyboard.move_focus_down()
elif normalized_value < 0:
keyboard.move_focus_up()
return
# 1. Handle Popups (Menus)
popup = QApplication.activePopupWidget()
if isinstance(popup, QMenu):
if code == PAD_DPAD_Y and value != 0:
self._navigate_menu_actions(popup, direction_down=value > 0)
return
focused_widget = QApplication.focusWidget()
if isinstance(active_window, QDialog) and active_window != self.file_explorer:
if not focused_widget or not active_window.focusWidget():
focusables = active_window.findChildren(
QWidget,
options=Qt.FindChildOption.FindChildrenRecursively
)
focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
if focusables:
focusables[0].setFocus(Qt.FocusReason.OtherFocusReason)
return
if code == PAD_DPAD_X and value != 0:
if value > 0:
active_window.focusNextChild()
elif value < 0:
active_window.focusPreviousChild()
return
if code == PAD_DPAD_Y and value != 0 and not isinstance(focused_widget, QTableWidget):
if value > 0:
active_window.focusNextChild()
elif value < 0:
active_window.focusPreviousChild()
return
# 2. Validate State
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list') or not self.file_explorer.file_list:
return
focused_widget = QApplication.focusWidget()
# 3. Handle Drive Buttons Navigation (Horizontal)
if code in (PAD_DPAD_X, PAD_AXIS_LEFT_X) and \
hasattr(self.file_explorer, 'drive_buttons') and \
self.file_explorer.drive_buttons:
if code == PAD_DPAD_X and value == 0:
return
is_drive_button = (
isinstance(focused_widget, AutoSizeButton) and
focused_widget in self.file_explorer.drive_buttons
)
if code == PAD_AXIS_LEFT_X:
if abs(value) < self.dead_zone or not is_drive_button:
return
value = 1 if value > self.dead_zone else -1
if not is_drive_button:
# Focus first drive button if not currently on one
self.file_explorer.drive_buttons[0].setFocus()
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[0])
return
current_idx = self.file_explorer.drive_buttons.index(focused_widget)
next_idx = current_idx
if value < 0: # Left
next_idx = max(current_idx - 1, 0)
elif value > 0: # Right
next_idx = min(current_idx + 1, len(self.file_explorer.drive_buttons) - 1)
if next_idx != current_idx:
self.file_explorer.drive_buttons[next_idx].setFocus()
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
return
# 4. Handle Vertical Navigation (File List vs Drive Buttons)
elif code in (PAD_DPAD_Y, PAD_AXIS_LEFT_Y):
# Move from buttons to list
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.file_explorer.drive_buttons:
if code == PAD_DPAD_Y and value > 0 and self.file_explorer.file_list.count() > 0:
self.file_explorer.file_list.setFocus()
self.file_explorer.file_list.setCurrentRow(0)
self.file_explorer.file_list.scrollToItem(self.file_explorer.file_list.currentItem())
return
# D-pad: Fixed speed
if code == PAD_DPAD_Y:
if value != 0:
self.current_direction = value
self.stick_value = 1.0
if not self.nav_timer.isActive():
self.file_explorer.move_selection(self.current_direction)
self.last_nav_time = current_time
self.nav_timer.start(int(self.initial_nav_delay * 1000))
else:
self.current_direction = 0
self.nav_timer.stop()
# Stick: Analog speed
elif code == PAD_AXIS_LEFT_Y:
if abs(value) < self.dead_zone:
if self.stick_activated:
self.current_direction = 0
self.nav_timer.stop()
self.stick_activated = False
return
normalized_value = (abs(value) - self.dead_zone) / (32768 - self.dead_zone)
speed_factor = 0.3 + (normalized_value * 0.7)
self.current_direction = -1 if value < 0 else 1
self.stick_value = speed_factor
self.stick_activated = True
if not self.nav_timer.isActive():
self.file_explorer.move_selection(self.current_direction)
self.last_nav_time = current_time
self.nav_timer.start(int(self.initial_nav_delay * 1000))
# 5. Fallback
else:
self._handle_default_dpad(code, value, current_time)
except Exception as e:
logger.error(f"Error in FileExplorer dpad handler: {e}")
+611
View File
@@ -0,0 +1,611 @@
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_activated_connected"):
parent.activated.connect(
lambda _index: SoundManager().play("toggle"),
)
parent.setProperty("_sound_activated_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",
"onlyInstalledCheckBox",
"gamesBadgeViewCombo",
):
widget = getattr(self._parent, attr_name, None)
if isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled():
widgets.append(widget)
return widgets
def _focus_library_filter_controls(self) -> bool:
controls_button = getattr(self._parent, "libraryControlsButton", None)
if not isinstance(controls_button, AutoSizeButton):
return False
if not controls_button.isChecked():
controls_button.setChecked(True)
toggle_controls = getattr(self._parent, "_toggle_library_controls", None)
if callable(toggle_controls):
toggle_controls()
filter_widgets = self._get_library_filter_widgets()
if not filter_widgets:
return False
filter_widgets[0].setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _focus_first_game_card(self, container: QWidget | None) -> bool:
if container is None:
return False
for card in container.findChildren(GameCard):
if card.isVisible() and card.isEnabled():
card.setFocus(Qt.FocusReason.OtherFocusReason)
scroll_area = container.parentWidget()
while scroll_area and not isinstance(scroll_area, QScrollArea):
scroll_area = scroll_area.parentWidget()
if isinstance(scroll_area, QScrollArea):
scroll_area.ensureWidgetVisible(card, 50, 50)
return True
return False
def _focus_first_library_card(self) -> bool:
container = getattr(self._parent, "gamesListWidget", None)
return self._focus_first_game_card(container)
def _get_autoinstall_toolbar_widgets(self) -> list[QWidget]:
widgets = []
for attr_name in ("autoInstallSearchLineEdit", "autoInstallRefreshButton"):
widget = getattr(self._parent, attr_name, None)
if isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled():
widgets.append(widget)
return widgets
def _focus_first_autoinstall_card(self) -> bool:
container = getattr(self._parent, "autoInstallContainer", None)
return self._focus_first_game_card(container)
def _handle_toolbar_navigation(self, code: int, value: int) -> bool:
current_index = self._parent.stackedWidget.currentIndex()
if value == 0 or current_index not in (0, 1):
return False
if current_index == 0:
toolbar_widgets = self._get_library_toolbar_widgets()
focus_first_card = self._focus_first_library_card
else:
toolbar_widgets = self._get_autoinstall_toolbar_widgets()
focus_first_card = self._focus_first_autoinstall_card
focused = self._focused_widget()
filter_widgets = self._get_library_filter_widgets() if current_index == 0 else []
if focused in filter_widgets:
if code == PAD_DPAD_X:
widget_index = filter_widgets.index(cast(QWidget, focused))
next_index = widget_index + (1 if value > 0 else -1)
if 0 <= next_index < len(filter_widgets):
filter_widgets[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
return True
if code != PAD_DPAD_Y:
return False
if value < 0:
controls_button = getattr(self._parent, "libraryControlsButton", None)
if isinstance(controls_button, QWidget):
controls_button.setFocus(Qt.FocusReason.OtherFocusReason)
return True
return focus_first_card()
if focused not in toolbar_widgets:
return False
if code == PAD_DPAD_X:
widget_index = toolbar_widgets.index(cast(QWidget, focused))
next_index = widget_index + (1 if value > 0 else -1)
if 0 <= next_index < len(toolbar_widgets):
toolbar_widgets[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
return True
if code != PAD_DPAD_Y:
return False
if value > 0:
controls_button = getattr(self._parent, "libraryControlsButton", None)
if focused is controls_button and self._focus_library_filter_controls():
return True
return focus_first_card()
tab_button = self._parent.tabButtons.get(current_index)
if tab_button is not None and tab_button.isVisible():
tab_button.setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _switch_visible_tab(self, step: int) -> bool:
idx = self._parent.stackedWidget.currentIndex()
visible = [i for i, btn in self._parent.tabButtons.items() if btn.isVisible()]
visible.sort()
if not visible:
return False
try:
current_pos = visible.index(idx)
except ValueError:
current_pos = 0
new_idx = visible[(current_pos + step) % len(visible)]
self._parent.switchTab(new_idx)
self._parent.tabButtons[new_idx].setFocus(Qt.FocusReason.OtherFocusReason)
return True
def _handle_input_arrow_press(self, key: int) -> bool:
dpad = {
KEY_UP: (PAD_DPAD_Y, -1),
KEY_DOWN: (PAD_DPAD_Y, 1),
KEY_LEFT: (PAD_DPAD_X, -1),
KEY_RIGHT: (PAD_DPAD_X, 1),
}.get(key)
if dpad is None:
return False
self.dpad_moved.emit(dpad[0], dpad[1], time.time())
return True
def _handle_input_action_key(self, key: int, modifiers: int) -> bool:
focused = self._focused_widget()
if isinstance(focused, GameCard) and key == KEY_F10 and modifiers & MOD_SHIFT:
pos = QPoint(focused.width() // 2, focused.height() // 2)
focused._show_context_menu(pos)
return True
if key in (KEY_RETURN, KEY_ENTER):
if isinstance(focused, QTableWidget):
self.handle_table_confirm(focused)
else:
self._activate_focused_widget(focused)
return True
elif key in (KEY_ESCAPE, KEY_BACKSPACE):
return self._handle_input_back_key(focused)
elif key == KEY_E and not isinstance(focused, QLineEdit):
if self._parent.stackedWidget.currentIndex() == 0:
self._parent.openAddGameDialog()
return True
return False
def _handle_input_back_key(self, focused: QWidget | None) -> bool:
if isinstance(focused, QLineEdit) or self._focused_editable_combo(focused):
return False
self._parent.goBackDetailPage(self._parent.currentDetailPage)
return True
def _handle_input_key_release(self, key: int) -> bool:
if key in (KEY_UP, KEY_DOWN):
self.dpad_moved.emit(PAD_DPAD_Y, 0, time.time())
return True
elif key in (KEY_LEFT, KEY_RIGHT):
self.dpad_moved.emit(PAD_DPAD_X, 0, time.time())
return True
return False
+6
View File
@@ -0,0 +1,6 @@
from typing import TYPE_CHECKING, Any
class InputMixin:
if TYPE_CHECKING:
def __getattr__(self, name: str) -> Any: ...
+404
View File
@@ -0,0 +1,404 @@
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 (
GAMEPAD_LIBRARY_PATH,
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 (library: %s)",
GAMEPAD_LIBRARY_PATH,
)
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 emulation_combo:
return
if self._should_skip_regular_events():
return
self.button_event.emit(button_code, value)
if self._should_schedule_menu_fullscreen(button_code, value, emulation_combo):
self.pending_menu_fullscreen_time = current_time + self.guide_combination_timeout
def _handle_emulation_combo(self, button_code: int, value: int) -> bool:
emulation_combo = value == 1 and (
(button_code in BUTTONS['menu'] and self.start_held) or
(button_code == PAD_BUTTON_START and self.select_held)
)
if emulation_combo:
self.emulation_triggered = not self.emulation_triggered
self.pending_menu_fullscreen_time = 0.0
return emulation_combo
def _should_schedule_menu_fullscreen(self, button_code: int, value: int, emulation_combo: bool) -> bool:
return (
value == 1 and button_code in BUTTONS['menu'] and
not emulation_combo and
not self._is_gamescope_session and not self.in_guide_combination_attempt and
self._parent.isActiveWindow()
)
def _poll_hat_events(self, gamepad: SDLGamepad, current_time: float) -> None:
"""Read D-pad state from SDL's standardized gamepad buttons."""
hat_value = (
self._read_dpad_button_axis(gamepad, SDL_GAMEPAD_BUTTON_DPAD_LEFT, SDL_GAMEPAD_BUTTON_DPAD_RIGHT),
self._read_dpad_button_axis(gamepad, SDL_GAMEPAD_BUTTON_DPAD_UP, SDL_GAMEPAD_BUTTON_DPAD_DOWN),
)
previous_value = self._hat_states.get(0, (0, 0))
self._hat_states[0] = hat_value
if self._is_mouse_emulation_active():
if previous_value[0] != hat_value[0]:
if hat_value[0] < 0:
self.move_mouse(-10, 0)
elif hat_value[0] > 0:
self.move_mouse(10, 0)
if previous_value[1] != hat_value[1]:
if hat_value[1] > 0:
self.move_mouse(0, -10)
elif hat_value[1] < 0:
self.move_mouse(0, 10)
if self._should_skip_regular_events():
return
if previous_value[0] != hat_value[0]:
self.dpad_moved.emit(PAD_DPAD_X, hat_value[0], current_time)
if previous_value[1] != hat_value[1]:
self.dpad_moved.emit(PAD_DPAD_Y, hat_value[1], current_time)
def _read_dpad_button_axis(self, gamepad: SDLGamepad, negative_button: int, positive_button: int) -> int:
negative = gamepad.get_button(negative_button)
positive = gamepad.get_button(positive_button)
return positive - negative
def _poll_axis_events(self, gamepad: SDLGamepad, current_time: float) -> None:
"""Emit axis changes using the same evdev-like codes used by the UI."""
for axis_index, axis_code in SDL_CONTROLLER_AXIS_TO_PAD.items():
raw_value = gamepad.get_axis(axis_index)
previous_value = self._axis_states.get(axis_index)
if previous_value == raw_value:
continue
self._axis_states[axis_index] = raw_value
if axis_code == PAD_AXIS_LEFT_X:
self.stick_x_raw = raw_value
elif axis_code == PAD_AXIS_LEFT_Y:
self.stick_y_raw = raw_value
elif axis_code == self.scroll_axis_code:
self.handle_scroll(raw_value)
if self._should_skip_regular_events():
continue
if axis_code in (PAD_AXIS_LEFT_TRIGGER, PAD_AXIS_RIGHT_TRIGGER):
self._emit_trigger_event(axis_code, raw_value, current_time)
continue
if axis_code not in (PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y):
continue
self.dpad_moved.emit(axis_code, raw_value, current_time)
def _emit_trigger_event(self, axis_code: int, raw_value: int, current_time: float) -> None:
"""Convert SDL trigger axis values into press/release button events."""
if current_time - self.last_trigger_time < self.trigger_cooldown:
return
is_pressed = raw_value > 16384
if axis_code == PAD_AXIS_LEFT_TRIGGER and is_pressed != self.lt_pressed:
self.lt_pressed = is_pressed
self.button_event.emit(axis_code, int(is_pressed))
self.last_trigger_time = current_time
elif axis_code == PAD_AXIS_RIGHT_TRIGGER and is_pressed != self.rt_pressed:
self.rt_pressed = is_pressed
self.button_event.emit(axis_code, int(is_pressed))
self.last_trigger_time = current_time
def _is_mouse_emulation_active(self) -> bool:
"""Return True when the gamepad currently drives mouse emulation."""
return self.mouse_emulation_enabled and self.emulation_active and self.emulation_triggered
def _should_skip_regular_events(self) -> bool:
"""Skip regular UI events while mouse emulation owns the controller."""
return self._is_mouse_emulation_active() and QApplication.activeWindow() is not None
def _poll_gamepad(self) -> None:
if not self.running or self._gamepad_polling_suspended:
return
current_time = time.time()
active_gamepad = self.gamepad
if not active_gamepad:
if current_time - self._last_gamepad_check_time >= 1.0:
self.gamepad_hotplug.emit('add')
self._last_gamepad_check_time = current_time
return
try:
active_gamepad.update()
if not active_gamepad.connected():
self.gamepad_hotplug.emit('remove')
return
self._poll_button_events(active_gamepad, current_time)
self._handle_pending_menu_fullscreen(current_time)
self._poll_hat_events(active_gamepad, current_time)
self._poll_axis_events(active_gamepad, current_time)
if (
current_time - self.last_update >= self.update_interval and
self.mouse_emulation_enabled and self.emulation_active and self.emulation_triggered
):
self.update_mouse_position()
self.last_update = current_time
except Exception as error:
logger.error("Unexpected error in gamepad polling: %s", error, exc_info=True)
def cleanup(self) -> None:
"""
Proper shutdown of gamepad and udev monitor.
"""
try:
# Mouse emulation cleanup
self.disable_mouse_emulation()
# Stop focus check timer
self.focus_check_timer.stop()
# Flag to stop udev monitor loop
self.running = False
# Stop all timers
if hasattr(self, 'gamepad_poll_timer'):
self.gamepad_poll_timer.stop()
self.dpad_timer.stop()
self.nav_timer.stop()
if self.gamepad:
self.gamepad.close()
self.gamepad = None
self._reset_input_state()
self.gamepad_type = self._get_configured_gamepad_type()
shutdown_gamepad_backend()
logger.info("Gamepad cleanup completed")
except Exception as e:
logger.error(f"Error during cleanup: {e}", exc_info=True)
+514
View File
@@ -0,0 +1,514 @@
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QCheckBox,
QComboBox,
QDialog,
QLineEdit,
QPushButton,
QTableWidget,
)
from portprotonqt.input_manager.constants import (
BUTTONS,
PAD_AXIS_LEFT_X,
PAD_AXIS_LEFT_Y,
PAD_DPAD_X,
PAD_DPAD_Y,
)
from portprotonqt.input_manager.mixin import InputMixin
from portprotonqt.logger import get_logger
from portprotonqt.sound_manager import SoundManager
logger = get_logger(__name__)
class SettingsInputMixin(InputMixin):
# SETTINGS MODE
def enable_settings_mode(self, settings_dialog):
"""Setup gamepad handling for ExeSettingsDialog"""
try:
self._setup_mode_handlers(
settings_dialog,
self.handle_settings_button,
self.handle_settings_dpad,
'settings_dialog'
)
logger.debug("Gamepad handling successfully connected for SettingsDialog")
except Exception as e:
logger.error(f"Error connecting gamepad handlers for SettingsDialog: {e}")
def disable_settings_mode(self):
"""Restore original main window handlers"""
try:
if self.settings_dialog:
self._restore_original_handlers('settings_dialog')
logger.debug("Gamepad handling successfully restored from Settings")
except Exception as e:
logger.error(f"Error restoring gamepad handlers from Settings: {e}")
def handle_settings_button(self, button_code, value):
if self.settings_dialog is None:
return
try:
# 1. Virtual Keyboard Handling
kb = getattr(self.settings_dialog, 'keyboard', None)
if kb and kb.isVisible():
if button_code in BUTTONS['back']:
if value != 0: # Only handle press, not release
kb.hide()
if kb.current_input_widget:
kb.current_input_widget.setFocus()
return # Return early to avoid dialog closing logic
elif button_code in (BUTTONS['confirm'] | BUTTONS['context_menu']):
if value != 0: # Only handle press, not release
kb.activateFocusedKey()
return
elif button_code in BUTTONS['prev_tab']:
if value != 0: # Only handle press, not release
kb.on_lang_click()
return
elif button_code in BUTTONS['next_tab']:
if value != 0: # Only handle press, not release
kb.on_shift_click(not kb.shift_pressed)
return
elif button_code in BUTTONS['add_game']:
if value != 0: # Press event
kb.on_backspace_pressed()
else: # Release event
kb.stop_backspace_repeat()
return
# Handle common UI elements like QMessageBox, QMenu, etc.
# Only handle press events (value != 0), ignore release events
if value != 0 and self._handle_common_ui_elements(button_code):
return
# Handle other QDialogs
if value != 0: # Only handle press events, not releases
popup = QApplication.activePopupWidget()
if isinstance(popup, QDialog):
if button_code in BUTTONS['confirm']:
popup.accept()
elif button_code in BUTTONS['back']:
popup.reject()
return
# 3. Combo Box popup handling in settings (Advanced + MangoHud)
table = self._get_current_settings_table()
open_combo = self._get_open_settings_combo()
# B Button - Close combo or dialog
if button_code in BUTTONS['back']:
if open_combo:
open_combo.hidePopup()
if open_combo.isEditable() and open_combo.lineEdit() is not None:
open_combo.lineEdit().setFocus()
else:
open_combo.setFocus()
else:
self.settings_dialog.reject()
return
if button_code in BUTTONS['context_menu']:
handler = getattr(self.settings_dialog, "handle_settings_context_menu", None)
if callable(handler) and handler():
return
# A Button - Confirm
if button_code in BUTTONS['confirm']:
if open_combo:
view = open_combo.view()
if view.currentIndex().isValid():
open_combo.setCurrentIndex(view.currentIndex().row())
open_combo.hidePopup()
if open_combo.isEditable() and open_combo.lineEdit() is not None:
open_combo.lineEdit().setFocus()
else:
open_combo.setFocus()
return
# Standard interaction
focused = QApplication.focusWidget()
if isinstance(focused, QCheckBox) and focused.isEnabled():
focused.setChecked(not focused.isChecked())
return
if isinstance(focused, QPushButton) and focused.isEnabled():
focused.click()
return
combo_from_focus = None
if isinstance(focused, QComboBox):
combo_from_focus = focused
else:
parent = focused.parentWidget() if focused else None
if isinstance(parent, QComboBox):
combo_from_focus = parent
if combo_from_focus and combo_from_focus.isEnabled():
combo_from_focus.showPopup()
combo_from_focus.setFocus()
return
if isinstance(focused, QTableWidget) and table and focused.currentRow() >= 0:
# Main settings (checkboxes)
if self.settings_dialog and table == self.settings_dialog.settings_table:
self.handle_table_confirm(focused)
return
# Advanced settings
cell = focused.cellWidget(focused.currentRow(), 1)
if isinstance(cell, QComboBox) and cell.isEnabled():
cell.showPopup()
cell.setFocus()
return
if isinstance(cell, QLineEdit):
cell.setFocus()
self.settings_dialog.show_virtual_keyboard(cell)
return
if isinstance(focused, QLineEdit):
self.settings_dialog.show_virtual_keyboard(focused)
return
if self._is_current_settings_tool_tab():
if isinstance(focused, QCheckBox):
focused.toggle()
return
if isinstance(focused, QPushButton):
focused.click()
return
if isinstance(focused, QComboBox):
focused.showPopup()
return
self._parent.activateFocusedWidget()
return
# 4. Global Shortcuts
if button_code in BUTTONS['add_game']: # X: Apply
self.settings_dialog.apply_changes()
elif button_code in BUTTONS['prev_dir']: # Y: Search + Keyboard
focused = QApplication.focusWidget()
focused_combo = None
if isinstance(focused, QTableWidget) and self.settings_dialog:
table = self._get_current_settings_table()
if table is not None and table == self.settings_dialog.advanced_table:
row = focused.currentRow()
if row >= 0:
cell_widget = focused.cellWidget(row, 1)
if isinstance(cell_widget, QComboBox):
focused_combo = cell_widget
if focused_combo is None:
if isinstance(focused, QComboBox):
focused_combo = focused
else:
parent = focused.parentWidget() if focused else None
if isinstance(parent, QComboBox):
focused_combo = parent
if focused_combo and focused_combo.isEditable() and focused_combo.isEnabled():
line_edit = focused_combo.lineEdit()
if line_edit is not None:
line_edit.setFocus()
self.settings_dialog.show_virtual_keyboard(line_edit)
return
self.settings_dialog.search_edit.setFocus()
self.settings_dialog.show_virtual_keyboard(self.settings_dialog.search_edit)
elif button_code in BUTTONS['prev_tab']: # LB
current_index = self.settings_dialog.tab_widget.currentIndex()
idx = max(0, current_index - 1)
self.settings_dialog.tab_widget.setCurrentIndex(idx)
if idx != current_index:
SoundManager().play("tab_switch")
self._focus_first_row_in_current_settings_table()
elif button_code in BUTTONS['next_tab']: # RB
current_index = self.settings_dialog.tab_widget.currentIndex()
idx = min(self.settings_dialog.tab_widget.count() - 1, current_index + 1)
self.settings_dialog.tab_widget.setCurrentIndex(idx)
if idx != current_index:
SoundManager().play("tab_switch")
self._focus_first_row_in_current_settings_table()
else:
self._parent.activateFocusedWidget()
except Exception as e:
logger.error(f"Error in handle_settings_button: {e}")
def handle_settings_dpad(self, code, value, now):
if self.settings_dialog is None:
return
try:
# 1. Virtual Keyboard Navigation
kb = getattr(self.settings_dialog, 'keyboard', None)
if kb and kb.isVisible():
normalized_value = 0
# Normalize Stick vs D-pad
if code in (PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y): # Sticks
if abs(value) < self.dead_zone:
self.current_dpad_code = None
self.current_dpad_value = 0
self.dpad_timer.stop()
return
normalized_value = 1 if value > self.dead_zone else -1
else: # D-pad
normalized_value = value
if normalized_value != 0:
if code in (PAD_DPAD_X, PAD_AXIS_LEFT_X):
if normalized_value > 0:
kb.move_focus_right()
else:
kb.move_focus_left()
elif code in (PAD_DPAD_Y, PAD_AXIS_LEFT_Y):
if normalized_value > 0:
kb.move_focus_down()
else:
kb.move_focus_up()
return
if self._is_current_settings_tool_tab():
focused = QApplication.focusWidget()
open_combo = self._get_open_settings_combo()
if open_combo:
if code == PAD_DPAD_Y and value != 0:
view = open_combo.view()
model = view.model()
current_index = view.currentIndex()
if model and current_index.isValid():
row_count = model.rowCount()
current_row = current_index.row()
if value > 0:
next_row = min(current_row + 1, row_count - 1)
else:
next_row = max(current_row - 1, 0)
new_index = model.index(next_row, current_index.column())
view.setCurrentIndex(new_index)
view.scrollTo(new_index, QAbstractItemView.ScrollHint.PositionAtCenter)
return
if code not in (PAD_DPAD_X, PAD_DPAD_Y, PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y):
return
normalized_value = value
if code in (PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y):
if abs(value) < self.dead_zone:
normalized_value = 0
else:
normalized_value = 1 if value > 0 else -1
if normalized_value == 0:
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
return
if code in (PAD_DPAD_X, PAD_DPAD_Y):
if self.current_dpad_code != code or self.current_dpad_value != normalized_value:
self.dpad_timer.stop()
self.dpad_timer.setInterval(120 if self.dpad_timer.isActive() else 220)
self.dpad_timer.start()
self.current_dpad_code = code
self.current_dpad_value = normalized_value
sections = self._get_mangohud_nav_sections()
if not sections:
return
if not focused or not self._find_widget_in_sections(focused, sections):
self._focus_first_row_in_current_settings_table()
return
if code in (PAD_DPAD_X, PAD_AXIS_LEFT_X):
self._move_mangohud_horizontal(focused, normalized_value, sections)
elif code in (PAD_DPAD_Y, PAD_AXIS_LEFT_Y):
self._move_mangohud_vertical(focused, normalized_value, sections)
return
# 2. Combo Box Navigation (within Advanced Table)
table = self._get_current_settings_table()
if not table or table.rowCount() == 0:
return
if self.settings_dialog and table == self.settings_dialog.advanced_table and table.currentRow() >= 0:
cell_widget = table.cellWidget(table.currentRow(), 1)
if isinstance(cell_widget, QComboBox) and cell_widget.view().isVisible():
if code == PAD_DPAD_Y and value != 0:
idx = cell_widget.currentIndex()
new_idx = max(0, idx - 1) if value < 0 else min(cell_widget.count() - 1, idx + 1)
if new_idx != idx:
cell_widget.setCurrentIndex(new_idx)
return # Consume event
# 3. Standard Table Navigation
if value == 0:
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
return
if self.current_dpad_code != code or self.current_dpad_value != value:
self.dpad_timer.stop()
self.dpad_timer.setInterval(150 if self.dpad_timer.isActive() else 300)
self.dpad_timer.start()
self.current_dpad_code = code
self.current_dpad_value = value
current_row = table.currentRow()
if code == PAD_DPAD_Y: # Up/Down
step = -1 if value < 0 else 1
new_row = current_row + step
while 0 <= new_row < table.rowCount() and table.isRowHidden(new_row):
new_row += step
if 0 <= new_row < table.rowCount():
focus_column = 1
table.setCurrentCell(new_row, focus_column)
self._focus_settings_advanced_value_widget(table, new_row)
elif code == PAD_DPAD_X: # Left/Right
if self._move_settings_inline_button_focus(table, value):
return
current_col = table.currentColumn()
if value < 0: # Left
if current_col > 0:
new_col = max(0, current_col - 1)
table.setCurrentCell(current_row, new_col)
if new_col == 1:
self._focus_settings_advanced_value_widget(table, current_row)
else:
table.setFocus(Qt.FocusReason.OtherFocusReason)
else: # Right
if current_col < table.columnCount() - 1:
new_col = min(table.columnCount() - 1, current_col + 1)
table.setCurrentCell(current_row, new_col)
if new_col == 1:
self._focus_settings_advanced_value_widget(table, current_row)
else:
table.setFocus(Qt.FocusReason.OtherFocusReason)
except Exception as e:
logger.error(f"Error in handle_settings_dpad: {e}")
def _get_current_settings_table(self) -> QTableWidget | None:
if not self.settings_dialog:
return None
getter = getattr(self.settings_dialog, "_get_current_settings_table", None)
if callable(getter):
table = getter()
if isinstance(table, QTableWidget):
return table
return None
def _is_current_settings_tool_tab(self):
if not self.settings_dialog:
return False
current_tab = self.settings_dialog.tab_widget.currentWidget()
return current_tab in (
getattr(self.settings_dialog, "mangohud_tab", None),
getattr(self.settings_dialog, "vkbasalt_tab", None),
getattr(self.settings_dialog, "gamescope_tab", None),
)
def _get_open_settings_combo(self):
"""Return currently opened combo popup in settings dialog if any."""
if not self.settings_dialog:
return None
for combo in self.settings_dialog.findChildren(
QComboBox, options=Qt.FindChildOption.FindChildrenRecursively
):
if combo.isVisible() and combo.view().isVisible():
return combo
return None
def _focus_settings_advanced_value_widget(self, table, row):
"""Focus value widget in settings row when available."""
if not self.settings_dialog:
table.setFocus(Qt.FocusReason.OtherFocusReason)
return
if table == self.settings_dialog.settings_table:
cell_widget = table.cellWidget(row, 1)
if cell_widget is not None:
checkbox = cell_widget.findChild(QCheckBox)
if checkbox and checkbox.isEnabled():
checkbox.setFocus(Qt.FocusReason.OtherFocusReason)
return
table.setFocus(Qt.FocusReason.OtherFocusReason)
return
if table == getattr(self.settings_dialog, "favorites_table", None):
cell_widget = table.cellWidget(row, 1)
if isinstance(cell_widget, (QCheckBox, QComboBox, QLineEdit)):
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
return
if cell_widget is not None:
checkbox = cell_widget.findChild(QCheckBox)
if checkbox and checkbox.isEnabled():
checkbox.setFocus(Qt.FocusReason.OtherFocusReason)
return
table.setFocus(Qt.FocusReason.OtherFocusReason)
return
if table != self.settings_dialog.advanced_table:
table.setFocus(Qt.FocusReason.OtherFocusReason)
return
cell_widget = table.cellWidget(row, 1)
if isinstance(cell_widget, QComboBox):
line_edit = cell_widget.lineEdit()
if cell_widget.isEditable() and line_edit is not None:
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
else:
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
return
if isinstance(cell_widget, QLineEdit):
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
return
if (
cell_widget is not None
and cell_widget.property("ppqt_run_after_exe_widget")
):
line_edit = cell_widget.findChild(QLineEdit)
if line_edit and line_edit.isEnabled():
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
return
table.setFocus(Qt.FocusReason.OtherFocusReason)
def _move_settings_inline_button_focus(self, table: QTableWidget, value: int) -> bool:
"""Move focus between inline line edit and button in settings cell."""
if not self.settings_dialog or table != self.settings_dialog.advanced_table:
return False
if table.currentColumn() != 1:
return False
cell_widget = table.cellWidget(table.currentRow(), 1)
if cell_widget is None or not cell_widget.property("ppqt_run_after_exe_widget"):
return False
line_edit = cell_widget.findChild(QLineEdit)
button = cell_widget.findChild(QPushButton)
focused = QApplication.focusWidget()
if value > 0 and focused == line_edit and button and button.isEnabled():
button.setFocus(Qt.FocusReason.OtherFocusReason)
return True
if value < 0 and focused == button and line_edit and line_edit.isEnabled():
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
return True
return False
def _focus_first_row_in_current_settings_table(self):
table = self._get_current_settings_table()
if table and table.rowCount() > 0:
col = 1
table.setCurrentCell(0, col)
self._focus_settings_advanced_value_widget(table, 0)
return
if self._is_current_settings_tool_tab():
sections = self._get_mangohud_nav_sections()
if sections and sections[0]:
self._focus_mangohud_widget(sections[0][0])
@@ -0,0 +1,496 @@
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 a vertical target in grid columns."""
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]
return None
if direction < 0:
if row_idx - 1 >= 0:
return columns[col_idx][row_idx - 1]
return None
return None
@@ -2063,6 +2063,9 @@ msgstr ""
msgid "Display Filter:"
msgstr ""
msgid "Only Installed"
msgstr ""
msgid "Detailed"
msgstr ""
@@ -2087,6 +2090,9 @@ msgstr ""
msgid "Auto"
msgstr ""
msgid "Account"
msgstr ""
msgid "Brief"
msgstr ""
@@ -2120,6 +2126,9 @@ msgstr ""
msgid "Hide Auto-Install Tab"
msgstr ""
msgid "Hide Control Hints"
msgstr ""
msgid "Enable UI Sounds"
msgstr ""
@@ -2353,6 +2362,9 @@ msgstr ""
msgid "Apply Theme"
msgstr ""
msgid "Delete Theme"
msgstr ""
msgid "No link"
msgstr ""
@@ -2365,6 +2377,13 @@ msgstr ""
msgid "Link:"
msgstr ""
#, python-brace-format
msgid "Delete theme '{0}'?"
msgstr ""
msgid "Failed to delete theme."
msgstr ""
msgid "Prefix:"
msgstr ""
@@ -9,7 +9,7 @@ msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-06-18 23:41+0500\n"
"PO-Revision-Date: 2026-07-28 16:30+0000\n"
"PO-Revision-Date: 2026-08-16 16:24+0000\n"
"Last-Translator: Rubin13 <rubin13@noreply.codeberg.org>\n"
"Language-Team: German <https://translate.codeberg.org/projects/portprotonqt/"
"portprotonqt/de/>\n"
@@ -18,7 +18,7 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.7.1\n"
"X-Generator: Weblate 2026.8.1\n"
"Generated-By: Babel 2.18.0\n"
msgid "Stop Game"
@@ -2073,7 +2073,6 @@ msgstr "GOG-Anmeldung fehlgeschlagen: {0}"
msgid "GOG account connected"
msgstr "GOG-Konto verknüpft"
#, fuzzy
msgid "Log out"
msgstr "Abmelden"
@@ -2161,6 +2160,9 @@ msgstr "Alle"
msgid "Display Filter:"
msgstr "Anzeigefilter:"
msgid "Only Installed"
msgstr "Nur installierte"
msgid "Detailed"
msgstr "Detailliert"
@@ -2187,6 +2189,9 @@ msgstr "Accounts"
msgid "Auto"
msgstr "Auto"
msgid "Account"
msgstr "Account"
msgid "Brief"
msgstr "Vereinfacht"
@@ -2220,6 +2225,9 @@ msgstr "Minimiert im Tray starten"
msgid "Hide Auto-Install Tab"
msgstr "Tab „Automatische Installation“ ausblenden"
msgid "Hide Control Hints"
msgstr ""
msgid "Enable UI Sounds"
msgstr "Benutzeroberflächen-Sounds aktivieren"
@@ -2456,6 +2464,10 @@ msgstr "Theme auswählen:"
msgid "Apply Theme"
msgstr "Theme anwenden"
#, fuzzy
msgid "Delete Theme"
msgstr "Theme auswählen:"
msgid "No link"
msgstr "Kein Link"
@@ -2468,6 +2480,14 @@ msgstr "Autor:"
msgid "Link:"
msgstr "Link:"
#, fuzzy, python-brace-format
msgid "Delete theme '{0}'?"
msgstr "Theme auswählen:"
#, fuzzy
msgid "Failed to delete theme."
msgstr "Laden der Themes fehlgeschlagen"
msgid "Prefix:"
msgstr "Prefix:"
@@ -9,7 +9,7 @@ msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
"PO-Revision-Date: 2026-07-25 17:07+0000\n"
"PO-Revision-Date: 2026-08-11 19:26+0000\n"
"Last-Translator: gallegonovato <gallegonovato@noreply.codeberg.org>\n"
"Language-Team: Spanish <https://translate.codeberg.org/projects/portprotonqt/"
"portprotonqt/es/>\n"
@@ -18,7 +18,7 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.6.1\n"
"X-Generator: Weblate 2026.8.1\n"
"Generated-By: Babel 2.17.0\n"
msgid "Stop Game"
@@ -2163,6 +2163,9 @@ msgstr ""
msgid "Display Filter:"
msgstr ""
msgid "Only Installed"
msgstr "Solo instalados"
msgid "Detailed"
msgstr ""
@@ -2189,6 +2192,9 @@ msgstr ""
msgid "Auto"
msgstr ""
msgid "Account"
msgstr ""
msgid "Brief"
msgstr ""
@@ -2222,6 +2228,9 @@ msgstr ""
msgid "Hide Auto-Install Tab"
msgstr ""
msgid "Hide Control Hints"
msgstr ""
msgid "Enable UI Sounds"
msgstr ""
@@ -2455,6 +2464,10 @@ msgstr ""
msgid "Apply Theme"
msgstr ""
#, fuzzy
msgid "Delete Theme"
msgstr "Borrar el archivo"
msgid "No link"
msgstr ""
@@ -2467,6 +2480,14 @@ msgstr ""
msgid "Link:"
msgstr ""
#, python-brace-format
msgid "Delete theme '{0}'?"
msgstr ""
#, fuzzy
msgid "Failed to delete theme."
msgstr "Error al cargar los temas"
msgid "Prefix:"
msgstr "Prefijo:"
+20 -1
View File
@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-07-28 11:58+0500\n"
"POT-Creation-Date: 2026-08-21 21:05+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -2059,6 +2059,9 @@ msgstr ""
msgid "Display Filter:"
msgstr ""
msgid "Only Installed"
msgstr ""
msgid "Detailed"
msgstr ""
@@ -2083,6 +2086,9 @@ msgstr ""
msgid "Auto"
msgstr ""
msgid "Account"
msgstr ""
msgid "Brief"
msgstr ""
@@ -2116,6 +2122,9 @@ msgstr ""
msgid "Hide Auto-Install Tab"
msgstr ""
msgid "Hide Control Hints"
msgstr ""
msgid "Enable UI Sounds"
msgstr ""
@@ -2350,6 +2359,9 @@ msgstr ""
msgid "Apply Theme"
msgstr ""
msgid "Delete Theme"
msgstr ""
msgid "No link"
msgstr ""
@@ -2362,6 +2374,13 @@ msgstr ""
msgid "Link:"
msgstr ""
#, python-brace-format
msgid "Delete theme '{0}'?"
msgstr ""
msgid "Failed to delete theme."
msgstr ""
msgid "Prefix:"
msgstr ""
@@ -7,9 +7,9 @@
msgid ""
msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
"POT-Creation-Date: 2026-06-04 10:32+0500\n"
"PO-Revision-Date: 2026-07-22 12:06+0000\n"
"PO-Revision-Date: 2026-08-20 21:25+0000\n"
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
"Language-Team: Portuguese (Brazil) <https://translate.codeberg.org/projects/"
"portprotonqt/portprotonqt/pt_BR/>\n"
@@ -18,7 +18,7 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Weblate 2026.6.1\n"
"X-Generator: Weblate 2026.8.1\n"
"Generated-By: Babel 2.18.0\n"
msgid "Stop Game"
@@ -153,9 +153,8 @@ msgstr "Excluir do PortProton"
msgid "Launch Game"
msgstr "Iniciar Jogo"
#, fuzzy
msgid "Repair"
msgstr "Pareado"
msgstr "Reparar"
msgid "Remove from Desktop"
msgstr "Remover da Área de Trabalho"
@@ -173,7 +172,7 @@ msgid "Delete"
msgstr "Excluir"
msgid "Import"
msgstr ""
msgstr "Importar"
msgid "Open Game Folder"
msgstr "Abrir Pasta do Jogo"
@@ -372,9 +371,8 @@ msgstr "Sistema"
msgid "Themes"
msgstr "Temas"
#, fuzzy
msgid "Downloads"
msgstr "Baixar"
msgstr "Downloads"
msgid "Warning"
msgstr "Aviso"
@@ -960,14 +958,15 @@ msgid "No cover selected"
msgstr "Nenhuma capa selecionada"
msgid "Compatibility report"
msgstr ""
msgstr "Relatório de compatibilidade"
msgid "The program stopped. Compatibility details were collected automatically."
msgstr ""
"O programa foi encerrado. As informações de compatibilidade foram coletadas "
"automaticamente."
#, fuzzy
msgid "Close"
msgstr "Mouse"
msgstr "Fechar"
msgid "Open"
msgstr "Abrir"
@@ -2023,131 +2022,114 @@ msgstr "Desmontar"
msgid "Set output"
msgstr "Definir saída"
#, fuzzy
msgid "Queued"
msgstr "Usado"
msgstr "Na fila"
#, fuzzy
msgid "Completed"
msgstr "Atualização concluída"
msgstr "Concluído"
#, fuzzy
msgid "Clear List"
msgstr "Limpar Tudo"
msgstr "Limpar lista"
#, fuzzy
msgid "Now Downloading"
msgstr "Baixando"
msgstr "Baixando agora"
#, fuzzy
msgid "Waiting…"
msgstr "Aviso"
msgstr "Aguardando…"
#, fuzzy
msgid "Disk: "
msgstr "Link:"
msgstr "Disco: "
#, fuzzy
msgid "Game Title"
msgstr "Largura do jogo"
msgstr "Título do jogo"
#, fuzzy
msgid "Started at"
msgstr "Estado"
msgstr "Iniciado em"
msgid "Type"
msgstr "Tipo"
#, fuzzy
msgid "Store"
msgstr "Armazenamento"
msgstr "Loja"
msgid "Copy the link to clipboard"
msgstr ""
msgstr "Copiar o link para a área de transferência"
msgid "Failed to log out of GOG account"
msgstr ""
msgstr "Falha ao sair da conta GOG"
msgid "Refreshing…"
msgstr "Atualizando…"
#, fuzzy
msgid "Unknown error"
msgstr "Jogo Desconhecido"
msgstr "Erro desconhecido"
#, fuzzy, python-brace-format
#, python-brace-format
msgid "GOG login failed: {0}"
msgstr "Arquivo: {0}"
msgstr "Falha no login da GOG: {0}"
#, fuzzy
msgid "GOG account connected"
msgstr "Conectado"
msgstr "Conta GOG conectada"
#, fuzzy
msgid "Log out"
msgstr "Encerrar sessão"
msgid "Open login page"
msgstr ""
msgstr "Abrir página de login"
msgid "GOG account not connected"
msgstr ""
msgstr "Conta GOG não conectada"
#, python-brace-format
msgid "Refreshing GOG library… {0}/{1}"
msgstr ""
msgstr "Atualizando biblioteca da GOG… {0}/{1}"
#, fuzzy, python-brace-format
#, python-brace-format
msgid "Failed to refresh GOG library: {0}"
msgstr "Falha ao iniciar o jogo: {0}"
msgstr "Falha ao atualizar a biblioteca da GOG: {0}"
msgid "Select installation folder"
msgstr ""
msgstr "Selecionar pasta de instalação"
msgid "Another GOG operation is already running"
msgstr ""
msgstr "Outra operação da GOG já está em execução"
msgid "GOG installation not found"
msgstr ""
msgstr "Instalação da GOG não encontrada"
#, fuzzy
msgid "Select GOG game folder"
msgstr "Excluir Pasta"
msgstr "Selecionar pasta do jogo da GOG"
msgid "Selected folder is not this GOG game"
msgstr ""
msgstr "A pasta selecionada não corresponde a este jogo da GOG"
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr ""
msgstr "Excluir '{0}' e todos os arquivos da sua pasta de instalação?"
#, fuzzy
msgid "Starting"
msgstr "Configuração"
msgstr "Iniciando"
#, fuzzy, python-brace-format
#, python-brace-format
msgid "Download: {0} MiB/s"
msgstr "Baixando… {0}%"
msgstr "Baixando: {0} MiB/s"
#, python-brace-format
msgid "Disk: {0} MiB/s"
msgstr ""
msgstr "Disco: {0} MiB/s"
#, fuzzy, python-brace-format
#, python-brace-format
msgid "ETA: {0}"
msgstr ", ETA: {}s"
msgstr "Tempo estimado: {0}"
#, fuzzy
msgid "Failed"
msgstr "Detalhado"
msgstr "Falhou"
#, fuzzy
msgid "Repaired"
msgstr "Pareado"
msgstr "Reparado"
#, fuzzy
msgid "Download process failed"
msgstr "Download em Andamento"
msgstr "O processo de download falhou"
msgid "Quick Launch"
msgstr "Início Rápido"
@@ -2176,6 +2158,9 @@ msgstr "Todos"
msgid "Display Filter:"
msgstr "Filtro de Exibição:"
msgid "Only Installed"
msgstr "Apenas instalados"
msgid "Detailed"
msgstr "Detalhado"
@@ -2196,13 +2181,15 @@ msgstr ""
msgid "PortProton Settings"
msgstr "Configurações do PortProton"
#, fuzzy
msgid "Accounts"
msgstr "Montar"
msgstr "Contas"
msgid "Auto"
msgstr "Automático"
msgid "Account"
msgstr "Conta"
msgid "Brief"
msgstr "Resumido"
@@ -2236,11 +2223,14 @@ msgstr "Iniciar na bandeja"
msgid "Hide Auto-Install Tab"
msgstr "Ocultar Aba de Instalação Automática"
msgid "Hide Control Hints"
msgstr "Ocultar dicas de controle"
msgid "Enable UI Sounds"
msgstr "Ativar sons da interface"
msgid "Show compatibility report after game crash"
msgstr ""
msgstr "Mostrar relatório de compatibilidade após a falha do jogo"
msgid "Disable runtime download"
msgstr "Desativar download de runtime"
@@ -2263,9 +2253,8 @@ msgstr "Baixar automaticamente o PPDB de"
msgid "Auto update AppImage"
msgstr "Atualização automática do AppImage"
#, fuzzy
msgid "Integrate AppImage"
msgstr "Atualização automática do AppImage"
msgstr "Integrar AppImage"
msgid "Force system DPI for Wine"
msgstr "Forçar DPI do sistema para o Wine"
@@ -2337,11 +2326,11 @@ msgstr "Tem certeza de que deseja limpar o cache? Esta ação não pode ser desf
#, python-brace-format
msgid "AppImage integrated at: {path}"
msgstr ""
msgstr "AppImage integrado em: {path}"
#, fuzzy, python-brace-format
#, python-brace-format
msgid "Failed to integrate AppImage: {error}"
msgstr "Falha ao renomear o arquivo: {error}"
msgstr "Falha ao integrar o AppImage: {error}"
msgid "Failed to update xdg-autostart entry."
msgstr "Falha ao atualizar a entrada xdg-autostart."
@@ -2405,7 +2394,7 @@ msgid "Output"
msgstr "Saída"
msgid "Logout"
msgstr "Encerrar sessão"
msgstr "Sair"
msgid "Reboot"
msgstr "Reiniciar"
@@ -2471,6 +2460,10 @@ msgstr "Selecionar Tema:"
msgid "Apply Theme"
msgstr "Aplicar Tema"
#, fuzzy
msgid "Delete Theme"
msgstr "Selecionar Tema:"
msgid "No link"
msgstr "Sem link"
@@ -2483,6 +2476,14 @@ msgstr "Autor:"
msgid "Link:"
msgstr "Link:"
#, fuzzy, python-brace-format
msgid "Delete theme '{0}'?"
msgstr "Selecionar Tema:"
#, fuzzy
msgid "Failed to delete theme."
msgstr "Falha ao carregar temas"
msgid "Prefix:"
msgstr "Prefixo:"
@@ -7,10 +7,10 @@
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
"PO-Revision-Date: 2026-07-24 20:10+0000\n"
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
"PO-Revision-Date: 2026-08-17 06:15+0000\n"
"Last-Translator: Boria138 <boria138@noreply.codeberg.org>\n"
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
"portprotonqt/ru/>\n"
"Language: ru\n"
@@ -19,7 +19,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
"X-Generator: Weblate 2026.6.1\n"
"X-Generator: Weblate 2026.8.1\n"
"Generated-By: Babel 2.18.0\n"
msgid "Stop Game"
@@ -958,14 +958,15 @@ msgid "No cover selected"
msgstr "Обложка не выбрана"
msgid "Compatibility report"
msgstr ""
msgstr "Отчет о совместимости"
msgid "The program stopped. Compatibility details were collected automatically."
msgstr ""
"Программа остановилась. Информация о совместимости была собрана "
"автоматически."
#, fuzzy
msgid "Close"
msgstr "Мышь"
msgstr "Закрыть"
msgid "Open"
msgstr "Открыть"
@@ -2152,6 +2153,9 @@ msgstr "Все"
msgid "Display Filter:"
msgstr "Фильтр:"
msgid "Only Installed"
msgstr "Только Установленные"
msgid "Detailed"
msgstr "Детальный"
@@ -2178,6 +2182,9 @@ msgstr "Аккаунты"
msgid "Auto"
msgstr "Авто"
msgid "Account"
msgstr "Аккаунт"
msgid "Brief"
msgstr "Упрощённый"
@@ -2211,11 +2218,14 @@ msgstr "Запускать свёрнутым"
msgid "Hide Auto-Install Tab"
msgstr "Скрыть вкладку \"Автоустановка\""
msgid "Hide Control Hints"
msgstr "Скрыть подсказки управления"
msgid "Enable UI Sounds"
msgstr "Включить звуки интерфейса"
msgid "Show compatibility report after game crash"
msgstr ""
msgstr "Показать отчет о совместимости после сбоя игры"
msgid "Disable runtime download"
msgstr "Отключить скачивание контейнера"
@@ -2444,6 +2454,10 @@ msgstr "Выбрать тему:"
msgid "Apply Theme"
msgstr "Применить тему"
#, fuzzy
msgid "Delete Theme"
msgstr "Выбрать тему:"
msgid "No link"
msgstr "Нет ссылки"
@@ -2456,6 +2470,14 @@ msgstr "Автор:"
msgid "Link:"
msgstr "Ссылка:"
#, fuzzy, python-brace-format
msgid "Delete theme '{0}'?"
msgstr "Выбрать тему:"
#, fuzzy
msgid "Failed to delete theme."
msgstr "Не удалось загрузить темы"
msgid "Prefix:"
msgstr "Префикс:"
@@ -2115,6 +2115,10 @@ msgstr "全部"
msgid "Display Filter:"
msgstr "显示筛选:"
#, fuzzy
msgid "Only Installed"
msgstr "已安装"
msgid "Detailed"
msgstr "详细"
@@ -2142,6 +2146,9 @@ msgstr "挂载"
msgid "Auto"
msgstr "自动"
msgid "Account"
msgstr ""
msgid "Brief"
msgstr "简要"
@@ -2175,6 +2182,9 @@ msgstr "启动时最小化到托盘"
msgid "Hide Auto-Install Tab"
msgstr "隐藏自动安装标签页"
msgid "Hide Control Hints"
msgstr ""
msgid "Enable UI Sounds"
msgstr "启用界面音效"
@@ -2408,6 +2418,10 @@ msgstr "选择主题:"
msgid "Apply Theme"
msgstr "应用主题"
#, fuzzy
msgid "Delete Theme"
msgstr "选择主题:"
msgid "No link"
msgstr "无链接"
@@ -2420,6 +2434,14 @@ msgstr "作者:"
msgid "Link:"
msgstr "链接:"
#, fuzzy, python-brace-format
msgid "Delete theme '{0}'?"
msgstr "选择主题:"
#, fuzzy
msgid "Failed to delete theme."
msgstr "加载主题失败"
msgid "Prefix:"
msgstr "前缀:"
+96 -76
View File
@@ -6,7 +6,6 @@ import signal
import shlex
import subprocess
import tempfile
import psutil
import re
import time
from queue import Empty, Queue
@@ -77,7 +76,7 @@ from portprotonqt.tabs.workers import MainWindowWorkersMixin
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
QMessageBox, QApplication, QPushButton, QCheckBox)
from PySide6.QtCore import Qt, QEvent, QUrl, Signal, QTimer, Slot, QProcess, QFileSystemWatcher, QObject
from PySide6.QtCore import Qt, QEvent, QEventLoop, QUrl, Signal, QTimer, Slot, QProcess, QThread, QFileSystemWatcher, QObject
from PySide6.QtGui import QColor, QDesktopServices, QHideEvent, QShowEvent, QGuiApplication
from typing import cast
from collections.abc import Callable
@@ -91,6 +90,7 @@ logger = get_logger(__name__)
DISC_IMAGE_EXTENSIONS = (".iso", ".mdf", ".nrg")
ALT_BIARCH_REPO = "x86_64-i586"
ALT_BIARCH_URL = "https://www.altlinux.org/Biarch"
GAME_LAUNCH_MARKER = "PORTPROTONQT_GAME_LAUNCH_STARTED"
ALT_I586_BASE_PACKAGES = (
"glibc-nss",
"glibc-gconv-modules",
@@ -323,6 +323,7 @@ class MainWindow(
self.launch_exe = launch_exe # Store launch_exe path
self._pending_log_exe: str | None = None
self.appimageUpdateWorker: AppImageUpdateWorker | None = None
self.gogdlUpdateWorker: QThread | None = None
self.compatibility_report_ready.connect(self._show_compatibility_report)
self.game_library_manager = GameLibraryManager(self, self.theme, None)
@@ -846,15 +847,10 @@ class MainWindow(
line_text = line.strip()
line_lower = line.lower()
status = percent = None
launch_started = False
launch_started = line_text == GAME_LAUNCH_MARKER
progress_match = re.fullmatch(r'([0-9]*\.?[0-9]+)%', line_text)
if progress_match:
percent = float(progress_match.group(1))
elif "update prefix log:" in line_lower \
or "the prefix has been updated" in line_lower \
or "log wine:" in line_lower \
or "log from runtime and wine:" in line_lower:
launch_started = True
elif "download " in line_lower and " from " in line_lower:
filename_match = re.search(r'download\s+(.+?)\s+from\s+', line, re.IGNORECASE)
filename = filename_match.group(1) if filename_match else line
@@ -948,12 +944,11 @@ class MainWindow(
self.games = []
def start_loading():
if display_filter == "steam":
self._load_steam_games_async(lambda games: self.games_loaded.emit(games))
elif display_filter == "gog":
self._load_gog_games_async(lambda games: self.games_loaded.emit(games))
elif display_filter == "portproton":
self._load_portproton_games_async(lambda games: self.games_loaded.emit(games))
source_loader = getattr(
self, f"_load_{display_filter}_games_async", None
)
if callable(source_loader):
source_loader(lambda games: self.games_loaded.emit(games))
elif display_filter == "favorites":
def on_all_games_favorites(portproton_games, steam_games, gog_games):
games = [game for game in portproton_games + steam_games + gog_games if game[0] in favorites]
@@ -1038,6 +1033,7 @@ class MainWindow(
def _load_gog_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
api = getattr(self, "gog_api", None) or GOGAPI()
installed = api.load_installed()
only_installed = game_config.get_only_installed()
library = [game for game in api.load_library() if game.get("app_id")]
if self._upgrade_legacy_gog_library(api, library, callback):
return
@@ -1045,27 +1041,38 @@ class MainWindow(
if not library:
callback(games)
return
from portprotonqt.time_utils import get_statistics_path
statistics_file = get_statistics_path()
processed_count = 0
def on_game_info(game: dict, steam_info: dict) -> None:
nonlocal processed_count
app_id = str(game.get("app_id", ""))
action = "launch" if api.is_game_installed(app_id, installed) else "install"
games.append((
str(game.get("title", app_id)),
str(game.get("description", "")), str(game.get("cover", "")),
app_id,
steam_info.get("controller_support", ""),
f"gog://{action}/{app_id}",
_("Never"), format_playtime(0),
steam_info.get("protondb_tier", ""),
steam_info.get("anticheat_status", ""),
0, 0,
"gog",
steam_info.get("anticheat_slug", ""),
steam_info.get("ppdb_id", ""), steam_info.get("ppdb_rating", ""),
steam_info.get("appid", ""),
))
is_installed = api.is_game_installed(app_id, installed)
if not only_installed or is_installed:
action = "launch" if is_installed else "install"
target = api.get_launch_target(app_id) if is_installed else None
playtime_seconds = get_playtime_for_exe(
statistics_file, target, exact_path=True
) if target else 0
playtime_seconds = playtime_seconds or 0
games.append((
str(game.get("title", app_id)),
str(game.get("description", "")), str(game.get("cover", "")),
app_id,
steam_info.get("controller_support", ""),
f"gog://{action}/{app_id}",
get_last_launch(f"gog-{app_id}") if target else _("Never"),
format_playtime(playtime_seconds),
steam_info.get("protondb_tier", ""),
steam_info.get("anticheat_status", ""),
get_last_launch_timestamp(f"gog-{app_id}") if target else 0,
playtime_seconds,
"gog",
steam_info.get("anticheat_slug", ""),
steam_info.get("ppdb_id", ""), steam_info.get("ppdb_rating", ""),
steam_info.get("appid", ""),
))
processed_count += 1
if processed_count == len(library):
callback(games)
@@ -1471,7 +1478,7 @@ class MainWindow(
def on_pixmap(pixmap):
if pixmap.isNull():
if callback:
callback([QColor("#1a1a1a")] * num_colors)
callback([QColor(self.theme.color_default_fallback)] * num_colors)
return
image = pixmap.toImage()
@@ -1507,7 +1514,7 @@ class MainWindow(
"""Extract color palette from a QPixmap directly."""
if pixmap.isNull():
if callback:
callback([QColor("#1a1a1a")] * num_colors)
callback([QColor(self.theme.color_default_fallback)] * num_colors)
return
image = pixmap.toImage()
@@ -1919,35 +1926,17 @@ class MainWindow(
parent = parent.parent()
def is_target_exe_running(self):
"""Check if process named self.target_exe is running via psutil."""
if not self.target_exe:
return False
for proc in psutil.process_iter(attrs=["name"]):
if proc.info["name"].lower() == self.target_exe.lower():
return True
return False
def checkTargetExe(self):
"""
Check if game is running.
If game process (target_exe) detected - set flag and update button.
If game completed - reset flag, update button and stop timer.
"""
target_running = self.is_target_exe_running()
"""Update launch state from the marker and PortProton process."""
child_running = self._has_running_game_process()
dependency_active = self._drain_launch_output_progress()
if dependency_active:
# Dependencies are downloading/extracting - update button with progress
self._set_running_button_progress()
elif target_running or (self.game_launch_started and child_running):
self.game_launch_started = True
# Game started - set flag, update button to "Stop"
self._set_running_button_stop()
elif child_running:
self._set_running_button_stop()
elif not child_running:
else:
# Game completed - reset flag, reset button and stop timer
self._analyze_short_launch()
self.resetPlayButton()
@@ -2025,10 +2014,12 @@ class MainWindow(
elapsed = int((datetime.now() - start_time).total_seconds())
if elapsed > 0:
from portprotonqt.time_utils import save_playtime
save_playtime(start_exe, elapsed)
exact_path = getattr(self, "game_start_exact_path", False)
save_playtime(start_exe, elapsed, exact_path=exact_path)
self._update_playtime_after_exit(start_exe, elapsed)
self.game_start_time = None
self.game_start_exe = None
self.game_start_exact_path = False
self.game_launch_monotonic = None
self.game_stopped_by_user = False
@@ -2049,7 +2040,7 @@ class MainWindow(
updated_games = []
changed = False
for game in games:
game_exe = extract_exec_target_path(game[5]) if len(game) > 5 else ""
game_exe = self._get_game_executable(game[5]) if len(game) > 5 else ""
if (
len(game) <= 11
or not game_exe
@@ -2078,7 +2069,7 @@ class MainWindow(
)
for card in self.game_library_manager.game_card_cache.values():
card_exe = extract_exec_target_path(card.exec_line) or ""
card_exe = self._get_game_executable(card.exec_line)
if os.path.normpath(card_exe) != target_path:
continue
card.playtime_seconds = (card.playtime_seconds or 0) + additional_seconds
@@ -2093,7 +2084,7 @@ class MainWindow(
updated_games = []
changed = False
for game in games:
game_exe = extract_exec_target_path(game[5]) if len(game) > 5 else ""
game_exe = self._get_game_executable(game[5]) if len(game) > 5 else ""
if (
len(game) <= 10
or not game_exe
@@ -2122,7 +2113,7 @@ class MainWindow(
)
for card in self.game_library_manager.game_card_cache.values():
card_exe = extract_exec_target_path(card.exec_line) or ""
card_exe = self._get_game_executable(card.exec_line)
if os.path.normpath(card_exe) != target_path:
continue
card.last_launch = format_last_launch(launch_time)
@@ -2131,6 +2122,13 @@ class MainWindow(
self.game_library_manager.update_game_grid(focus_first_card=False)
self._refresh_current_detail_time()
def _get_game_executable(self, exec_line: str) -> str:
"""Resolve a library launch entry to its tracked executable."""
if exec_line.startswith("gog://launch/"):
app_id = exec_line.rsplit("/", 1)[-1]
return str(self.gog_api.get_launch_target(app_id) or "")
return extract_exec_target_path(exec_line) or ""
def _has_running_game_process(self) -> bool:
return any(proc.poll() is None for proc in self.game_processes)
@@ -2309,13 +2307,21 @@ class MainWindow(
]
def _get_installed_alt_package_names(self) -> list[str]:
result = subprocess.run(
["rpm", "-qa", "--qf", "%{NAME}\n"],
capture_output=True,
text=True,
timeout=10,
check=False,
)
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(
subprocess.run,
["rpm", "-qa", "--qf", "%{NAME}\n"],
capture_output=True,
text=True,
timeout=10,
check=False,
)
event_flags = QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents
QApplication.processEvents(event_flags)
while not future.done():
QApplication.processEvents(event_flags)
time.sleep(0.01)
result = future.result()
if result.returncode != 0:
raise subprocess.SubprocessError(result.stderr.strip())
@@ -2442,7 +2448,9 @@ class MainWindow(
if game:
self._install_gog_game(game)
def _launch_gog_game(self, app_id: str, button=None) -> None:
def _launch_gog_game(
self, app_id: str, button=None, play_sound: bool = True
) -> None:
self.gog_api.ensure_launch_parameters(app_id)
discovered_path = self.gog_api.get_installed_path(app_id)
install_path = str(discovered_path) if discovered_path else ""
@@ -2477,10 +2485,19 @@ class MainWindow(
logger.error("Failed to launch GOG game %s: %s", app_id, error)
QMessageBox.warning(self, _("Error"), str(error))
return
SoundManager().play("game_launch")
if play_sound:
SoundManager().play("game_launch")
self.game_processes.append(process)
self.target_exe = target
self.current_running_button = button
launch_target = self.gog_api.get_launch_target(app_id)
if launch_target:
launch_time = datetime.now()
self.game_start_time = launch_time
self.game_start_exe = launch_target
self.game_start_exact_path = True
save_last_launch(f"gog-{app_id}", launch_time)
self._update_last_launch_after_start(launch_target, launch_time)
self._start_launch_output_reader(process)
self.input_manager.suspend_gamepad_polling()
self.checkProcessTimer = QTimer(self)
@@ -2547,7 +2564,19 @@ class MainWindow(
if not self.stop_running_game(update_button):
QMessageBox.warning(self, _("Error"), _("Failed to stop game"))
else:
if update_button:
try:
update_button.setText(_("Stop"))
icon = self.theme_manager.get_icon("stop", as_path=True)
update_button.setIcon(icon)
except RuntimeError:
update_button = None
SoundManager().play("game_launch")
if not self._check_alt_i586_dependencies_before_launch():
if update_button:
update_button.setText(_("Start"))
icon = self.theme_manager.get_icon("play", as_path=True)
update_button.setIcon(icon)
return
# Save button reference for reset after game completion
@@ -2575,9 +2604,8 @@ class MainWindow(
errors="replace",
bufsize=1,
)
SoundManager().play("game_launch")
self.game_processes.append(process)
self.game_launch_monotonic = time.monotonic()
self.game_launch_monotonic = None
self._start_launch_output_reader(process)
self.input_manager.suspend_gamepad_polling()
launch_time = datetime.now()
@@ -2585,14 +2613,6 @@ class MainWindow(
self.game_start_exe = file_to_check
save_last_launch(exe_name, launch_time)
self._update_last_launch_after_start(file_to_check, launch_time)
if update_button:
try:
update_button.setText(_("Stop"))
icon = self.theme_manager.get_icon("stop", as_path=True)
update_button.setIcon(icon)
except RuntimeError:
pass
# Reset dependency setup monitoring
self.wine_download_seen = False
self.wine_download_percent = 0.0
+38 -3
View File
@@ -1,7 +1,6 @@
# Install Python source files
install_data(
['__init__.py',
'app.py',
'appimage_integration.py',
'appimage_updater.py',
'cli.py',
@@ -12,13 +11,11 @@ install_data(
'downloader.py',
'compatibility_report.py',
'game_card.py',
'gamepad_backend.py',
'game_library_manager.py',
'gog_api.py',
'howlongtobeat_api.py',
'image_utils.py',
'icon_extractor.py',
'input_manager.py',
'keyboard_layouts.py',
'localization.py',
'logger.py',
@@ -39,6 +36,44 @@ 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(
'native_gamepad.py',
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_sources,
dependencies: sdl3_dep,
install: true,
install_dir: pkgdatadir,
)
install_data(
['compatibility_rules.json',
'keyboard_layouts.json',
+99
View File
@@ -0,0 +1,99 @@
import ctypes
from dataclasses import dataclass
from pathlib import Path
GAMEPAD_LIBRARY_NAME = "libportprotonqt_gamepad.so"
SYSTEM_GAMEPAD_LIBRARY = Path(__file__).parent / GAMEPAD_LIBRARY_NAME
DEV_GAMEPAD_LIBRARY = (
Path(__file__).parent.parent / "build-aux" / "lib" / GAMEPAD_LIBRARY_NAME
)
GAMEPAD_LIBRARY_PATH = (
SYSTEM_GAMEPAD_LIBRARY
if SYSTEM_GAMEPAD_LIBRARY.is_file()
else DEV_GAMEPAD_LIBRARY
)
def _load_library() -> ctypes.CDLL:
return ctypes.CDLL(str(GAMEPAD_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()
+7 -6
View File
@@ -10,7 +10,7 @@ from PySide6.QtWidgets import QWidget
from portprotonqt.config import ui_config
from portprotonqt.theme_manager import ThemeManager, load_theme
_DEFAULT_PRELOADER = {
PRELOADER_DEFAULT = {
"style": "default",
"speed": 180.0,
"line_width": 20,
@@ -18,6 +18,8 @@ _DEFAULT_PRELOADER = {
"bat_flap_speed": 3.0,
"bat_alpha": 220,
"bat_color": "#bd93f9",
"bat_eye_color": "#282a36",
"bat_fang_color": "#f8f8f2",
"pulse_count": 3,
"pulse_max_radius": 40,
"pulse_speed": 2.0,
@@ -43,7 +45,7 @@ def _load_preloader_config() -> dict:
theme = load_theme(ui_config.get_theme())
except FileNotFoundError:
theme = load_theme("standart")
return getattr(theme, "PRELOADER", _DEFAULT_PRELOADER)
return PRELOADER_DEFAULT | getattr(theme, "PRELOADER", {})
class Preloader(QWidget):
@@ -236,7 +238,7 @@ class Preloader(QWidget):
eye_r = size * 0.018
blink = max(0.1, abs(math.sin((time.time() - self._pulse_start) * 0.4)))
painter.setBrush(QBrush(QColor("#282a36")))
painter.setBrush(QBrush(QColor(self._config["bat_eye_color"])))
for side in (-1, 1):
painter.drawEllipse(
QPointF(side * head_r * 0.35, head_y),
@@ -245,7 +247,7 @@ class Preloader(QWidget):
fang_len = size * 0.07
fang_w = size * 0.015
painter.setBrush(QBrush(QColor("#f8f8f2")))
painter.setBrush(QBrush(QColor(self._config["bat_fang_color"])))
for side in (-1, 1):
path = QPainterPath()
fx = side * head_r * 0.18
@@ -297,7 +299,6 @@ class Preloader(QWidget):
if parsed.isValid():
return parsed
fallback = QColor(0, 120, 215)
theme_manager = ThemeManager()
theme = theme_manager.current_theme_module
if theme is None:
@@ -312,7 +313,7 @@ class Preloader(QWidget):
parsed = self._parse_color_string(getattr(theme, attr_name))
if parsed.isValid():
return parsed
return fallback
return QColor(load_theme("standart").color_preloader)
def _parse_color_string(self, value):
if isinstance(value, QColor):
+13
View File
@@ -11,7 +11,9 @@ from typing import Any, cast
from dbus_fast import BusType, DBusError, Message, Variant
from dbus_fast.aio import MessageBus
from portprotonqt.config import ui_config
from portprotonqt.logger import get_logger
from portprotonqt.theme_manager import ThemeManager, load_theme
logger = get_logger(__name__)
@@ -347,6 +349,17 @@ async def _main() -> int:
args = parse_args()
try:
if args.command == "notify":
if args.icon in {"info", "warning", "error"}:
theme_name = ui_config.get_theme()
theme_manager = ThemeManager()
load_theme(theme_name)
icon_path = theme_manager.get_icon(
args.icon,
theme_name,
as_path=True,
)
if isinstance(icon_path, str):
args.icon = icon_path
request = NotificationRequest(
args.app,
args.icon,
+1 -1
View File
@@ -291,7 +291,7 @@ def get_advanced_settings(disabled_text, logical_core_options=None, locale_optio
'description': _("Changing the WINDOWS emulation version may be required to run older games. WINDOWS versions below 10 do not support new games with DirectX 12"),
'type': 'combo',
'options': ['11', '10', '7', 'XP'],
'default': '10'
'default': '11'
})
# 5. DLL Overrides
+2
View File
@@ -63,6 +63,8 @@ class _SoundSlot:
self._loaded_event: str | None = None
def play(self, event: str, url: QUrl) -> None:
if self._loaded_event == event and self._effect.isPlaying():
return
if self._loaded_event != event:
self._effect.setSource(url)
self._loaded_event = event
+35 -11
View File
@@ -2,9 +2,11 @@
import os
import shlex
import threading
import time
import urllib.parse
from collections.abc import Callable
from functools import lru_cache
import orjson
from PIL import Image, UnidentifiedImageError
@@ -39,6 +41,7 @@ downloader = Downloader()
_STEAM_APPS_CACHE = CacheManager(name="steam_apps")
_ANTICHEAT_CACHE = CacheManager(name="anticheat")
_PPDB_CACHE = CacheManager(name="ppdb")
_CACHED_INDEX_LOCK = threading.RLock()
SGDB_CACHE_DURATION = 24 * 60 * 60
STEAM_CLIENT_ICON_SIZES = (16, 32, 64, 128, 256)
@@ -341,6 +344,8 @@ def clear_steam_api_caches() -> None:
_STEAM_APPS_CACHE.clear_memory_cache()
_ANTICHEAT_CACHE.clear_memory_cache()
_PPDB_CACHE.clear_memory_cache()
with _CACHED_INDEX_LOCK:
_load_cached_data_and_index.cache_clear()
logger.info("Cleared Steam API in-memory caches")
@@ -469,6 +474,32 @@ def _read_cached_app_data(appid: int) -> dict:
return cached
@lru_cache(maxsize=4)
def _load_cached_data_and_index(
cache_path: str,
cache_mtime_ns: int,
build_index_func: Callable[[list], dict],
) -> tuple[list, dict]:
cached = _load_cached_json_file(cache_path)
if not isinstance(cached, list):
return [], {}
return cached, build_index_func(cached)
def _get_cached_data_and_index(
cache_file: os.PathLike,
build_index_func: Callable[[list], dict],
) -> tuple[list, dict]:
try:
cache_mtime_ns = os.stat(cache_file).st_mtime_ns
except OSError:
return [], {}
with _CACHED_INDEX_LOCK:
return _load_cached_data_and_index(
os.fspath(cache_file), cache_mtime_ns, build_index_func
)
def _get_cached_steam_cover_path(appid: int) -> str:
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
image_folder = os.path.join(xdg_cache_home, "PortProtonQt", "images")
@@ -490,20 +521,14 @@ def _read_cached_protondb_tier(appid: int) -> str:
def _read_cached_ppdb_id(game_name: str) -> str:
cache_file = CacheManager().cache_dir / "ppdb_games.json"
cached = _load_cached_json_file(str(cache_file))
if not isinstance(cached, list):
return ""
ppdb_index = build_ppdb_index(cached)
_, ppdb_index = _get_cached_data_and_index(cache_file, build_ppdb_index)
entry = search_ppdb_entry(game_name, ppdb_index) or {}
return str(entry.get("id", ""))
def _read_cached_ppdb_rating(game_name: str) -> str:
cache_file = CacheManager().cache_dir / "ppdb_games.json"
cached = _load_cached_json_file(str(cache_file))
if not isinstance(cached, list):
return ""
ppdb_index = build_ppdb_index(cached)
_, ppdb_index = _get_cached_data_and_index(cache_file, build_ppdb_index)
entry = search_ppdb_entry(game_name, ppdb_index) or {}
return str(entry.get("overall_rating", ""))
@@ -538,11 +563,10 @@ def get_cached_steam_game_info(desktop_name: str, exec_line: str) -> dict:
folder_name = os.path.basename(os.path.dirname(game_exe)) if game_exe else ""
candidates = remove_duplicates(filter_candidates([desktop_name, exe_name, folder_name]))
apps_path = CacheManager().cache_dir / "steam_apps.json"
steam_apps = _load_cached_json_file(str(apps_path))
if not isinstance(steam_apps, list):
steam_apps, steam_apps_index = _get_cached_data_and_index(apps_path, build_index)
if not steam_apps:
return {}
steam_apps_index = build_index(steam_apps)
matching_app = None
for candidate in sorted(candidates, key=lambda s: len(s.split()), reverse=True):
matching_app = search_app(candidate, steam_apps_index)
+7 -1
View File
@@ -7,6 +7,7 @@ from PySide6.QtGui import QMouseEvent, QPainter, QPaintEvent, QPixmap
from PySide6.QtSvg import QSvgRenderer
from PySide6.QtWidgets import QHBoxLayout, QLabel, QStackedWidget, QWidget
from portprotonqt.config import ui_config
from portprotonqt.custom_widgets import FlowLayout
from portprotonqt.input_manager import BUTTONS, GamepadType
from portprotonqt.localization import _
@@ -450,6 +451,12 @@ class MainWindowControlHintsMixin:
"""Update control hints based on gamepad connection status and type."""
if not hasattr(self, "hintsLabels"):
return
control_hints_widget = getattr(self, "controlHintsWidget", None)
hide_control_hints = ui_config.get_hide_control_hints()
if isinstance(control_hints_widget, QWidget):
control_hints_widget.setVisible(not hide_control_hints)
if hide_control_hints:
return
force_update = bool(args and args[0] == "force")
is_gamepad_connected = self.input_manager.gamepad is not None
gtype = self.input_manager.gamepad_type
@@ -569,7 +576,6 @@ class MainWindowControlHintsMixin:
container.setVisible(not is_gamepad_connected and not on_system_tab)
self._updateSystemGamepadHintTexts()
control_hints_widget = getattr(self, "controlHintsWidget", None)
if isinstance(control_hints_widget, QWidget):
control_hints_widget.updateGeometry()
control_hints_widget.update()
+14 -1
View File
@@ -31,6 +31,7 @@ from portprotonqt.gog_api import GOGAPI, GOG_LOGIN_URL
from portprotonqt.image_utils import load_pixmap_async
from portprotonqt.localization import _
from portprotonqt.logger import get_logger
from portprotonqt.sound_manager import SoundManager
logger = get_logger(__name__)
GOG_CANCEL_KILL_TIMEOUT_MS = 3000
@@ -369,6 +370,8 @@ class MainWindowGOGTabMixin(_MainWindowTypingBase):
self.gog_account_worker = None
def _refresh_gog_library(self) -> None:
if not self.gog_api.auth_path.is_file():
return
if getattr(self, "gog_library_worker", None) is not None:
return
self.gogAccountStatus.setText(_("Refreshing…"))
@@ -734,6 +737,7 @@ class MainWindowGOGTabMixin(_MainWindowTypingBase):
)
worker.finished.connect(lambda: self._finish_gog_support_setup(worker))
self.gog_support_workers.append(worker)
SoundManager().play("game_launch")
worker.start()
def _track_gog_support_process(
@@ -743,15 +747,24 @@ class MainWindowGOGTabMixin(_MainWindowTypingBase):
self.game_processes.append(process)
self.target_exe = os.path.basename(target or app_id)
self.current_running_button = button
self._start_launch_output_reader(process)
self.input_manager.suspend_gamepad_polling()
self._set_running_button_stop()
self.checkProcessTimer = QTimer(self)
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
self.checkProcessTimer.start(500)
def _launch_after_gog_support(self, app_id: str, button=None) -> None:
self.game_processes = [
process for process in self.game_processes
if process.poll() is None
]
self._launch_gog_game(app_id, button)
timer = self.checkProcessTimer
if timer is not None:
timer.stop()
timer.deleteLater()
self.checkProcessTimer = None
self._launch_gog_game(app_id, button, play_sound=False)
def _finish_gog_support_setup(self, worker: GOGSupportWorker) -> None:
workers = getattr(self, "gog_support_workers", [])
+78 -15
View File
@@ -6,8 +6,10 @@ from typing import TYPE_CHECKING, Any, cast
from PySide6.QtCore import QAbstractAnimation, QPoint, QStandardPaths, Qt, QTimer, Slot
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QGridLayout,
QHBoxLayout,
QLineEdit,
QMessageBox,
@@ -30,6 +32,10 @@ from portprotonqt.config import (
from portprotonqt.context_menu_manager import CustomLineEdit
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox
from portprotonqt.dialogs import AddGameDialog, FileExplorer
from portprotonqt.dialogs.proton_manager import (
WINE_ARCHIVE_EXTENSIONS,
show_proton_manager,
)
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
from portprotonqt.localization import _, get_metadata_language, read_metadata_translations
from portprotonqt.logger import get_logger
@@ -89,7 +95,19 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
def _on_library_filter_changed(self, index: int) -> None:
if index < 0 or index >= len(self.filter_keys):
return
game_config.set_display_filter(self.filter_keys[index])
display_filter = self.filter_keys[index]
game_config.set_display_filter(display_filter)
self.onlyInstalledCheckBox.setVisible(
display_filter not in ("steam", "portproton")
)
self._position_library_controls_widget()
self.searchEdit.clear()
self.games = []
self._preserve_library_focus_after_load = True
self.loadGames(force_load=True)
def _on_only_installed_changed(self, checked: bool) -> None:
game_config.set_only_installed(checked)
self.searchEdit.clear()
self.games = []
self._preserve_library_focus_after_load = True
@@ -123,15 +141,20 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
if controls_widget is not None:
controls_widget.hide()
def _create_library_controls_widget(self) -> QHBoxLayout:
def _create_library_controls_widget(self) -> QGridLayout:
self.libraryControlsWidget = QWidget(self)
controls_layout = QHBoxLayout(self.libraryControlsWidget)
self.libraryControlsWidget.setObjectName("libraryControlsWidget")
controls_layout = QGridLayout(self.libraryControlsWidget)
controls_layout.setContentsMargins(5, 5, 5, 5)
controls_layout.setSpacing(10)
self.libraryControlsWidget.setStyleSheet(self.theme.LIBRARY_CONTROL_STYLE)
return controls_layout
def _position_library_controls_widget(self) -> None:
controls_layout = self.libraryControlsWidget.layout()
if controls_layout is not None:
controls_layout.invalidate()
controls_layout.activate()
size_hint = self.libraryControlsWidget.sizeHint()
button_bottom = self.libraryControlsButton.mapTo(
self,
@@ -264,7 +287,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
button.setGeometry(x, y, size.width(), size.height())
return
def _add_library_filter_controls(self, controls_layout: QHBoxLayout) -> None:
def _add_library_filter_controls(self, controls_layout: QGridLayout) -> None:
self.sort_keys = ["last_launch", "playtime", "alphabetical"]
self.sort_labels = [_("Last launch"), _("Time spent"), _("Alphabetical")]
self.gamesSortCombo = self._create_library_combo(self.sort_labels, _("Sort Method:"))
@@ -275,19 +298,39 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
)
self.gamesSortCombo.currentIndexChanged.connect(self._on_library_sort_changed)
self.gamesSortCombo.activated.connect(self._delay_library_controls_hover_close)
controls_layout.addWidget(self.gamesSortCombo)
controls_layout.addWidget(self.gamesSortCombo, 0, 0)
display_filter = game_config.get_display_filter()
only_installed = game_config.get_only_installed()
self.filter_keys = ["all", "steam", "gog", "portproton", "favorites"]
self.filter_labels = [_("All"), "Steam", "GOG", "PortProton", _("Favorites")]
self.filter_labels = [
_("All"),
"Steam",
"GOG",
"PortProton",
_("Favorites"),
]
self.gamesDisplayCombo = self._create_library_combo(self.filter_labels, _("Display Filter:"))
self._set_combo_current_key(
self.gamesDisplayCombo,
self.filter_keys,
game_config.get_display_filter(),
display_filter,
)
self.gamesDisplayCombo.currentIndexChanged.connect(self._on_library_filter_changed)
self.gamesDisplayCombo.activated.connect(self._delay_library_controls_hover_close)
controls_layout.addWidget(self.gamesDisplayCombo)
self.onlyInstalledCheckBox = QCheckBox(_("Only Installed"))
self.onlyInstalledCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
self.onlyInstalledCheckBox.setChecked(only_installed)
self.onlyInstalledCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self._register_gamepad_tooltip(self.onlyInstalledCheckBox, _("Only Installed"))
self.onlyInstalledCheckBox.toggled.connect(self._on_only_installed_changed)
controls_layout.addWidget(self.gamesDisplayCombo, 0, 1)
controls_layout.addWidget(self.onlyInstalledCheckBox, 1, 1, 1, 2)
self.onlyInstalledCheckBox.setVisible(
display_filter not in ("steam", "portproton")
)
self.badge_view_keys = ["detailed", "compact", "hidden"]
self.badge_view_labels = [_("Detailed"), _("Compact"), _("Hidden")]
@@ -302,7 +345,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
self.gamesBadgeViewCombo.setEnabled(False)
self.gamesBadgeViewCombo.currentIndexChanged.connect(self._on_library_badge_view_changed)
self.gamesBadgeViewCombo.activated.connect(self._delay_library_controls_hover_close)
controls_layout.addWidget(self.gamesBadgeViewCombo)
controls_layout.addWidget(self.gamesBadgeViewCombo, 0, 2)
def _delay_library_controls_hover_close(self, _index: int = -1) -> None:
self._library_controls_hover_close_delayed = True
@@ -377,12 +420,18 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
if hasattr(self.game_library_manager, 'gamesListLayout'):
self.game_library_manager.gamesListLayout.update()
if (
self.gog_api.auth_path.is_file()
and getattr(self, "gog_library_worker", None) is None
):
display_filter = game_config.get_display_filter()
source_loader = getattr(
self, f"_load_{display_filter}_games_async", None
)
source_refresh = getattr(
self, f"_refresh_{display_filter}_library", None
)
if callable(source_refresh):
source_refresh()
if not callable(source_loader) and self.gog_api.auth_path.is_file():
self._refresh_gog_library()
return
# Reload games using the existing loadGames functionality
# Use a small delay to allow UI to update before starting the refresh
@@ -486,15 +535,21 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
if event.mimeData().hasUrls():
for url in event.mimeData().urls():
path = url.toLocalFile().lower()
if path.endswith(LAUNCH_FILE_EXTENSIONS + PP_FILE_EXTENSIONS):
if path.endswith(
LAUNCH_FILE_EXTENSIONS + PP_FILE_EXTENSIONS + WINE_ARCHIVE_EXTENSIONS
):
event.acceptProposedAction()
return
event.ignore()
def dropEvent(self, event):
wine_archives = []
for url in event.mimeData().urls():
path = url.toLocalFile()
path_lower = path.lower()
if path_lower.endswith(WINE_ARCHIVE_EXTENSIONS) and os.path.isfile(path):
wine_archives.append(path)
continue
if path_lower.endswith(BACKUP_EXTENSION):
self._perform_restore(path)
event.acceptProposedAction()
@@ -507,6 +562,14 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
self.openAddGameDialog(path)
event.acceptProposedAction()
break
if wine_archives:
show_proton_manager(
self,
self.portproton_location,
input_manager=self.input_manager,
local_archives=wine_archives,
)
event.acceptProposedAction()
def openAddGameDialog(self, exe_path=None):
if self.current_add_game_dialog is not None and self.current_add_game_dialog.isVisible():
+28 -7
View File
@@ -36,7 +36,7 @@ from portprotonqt.config import (
ui_config,
)
from portprotonqt.context_menu_manager import CustomLineEdit
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox, FlowLayout
from portprotonqt.debug_utils import get_selectable_gpu_list
from portprotonqt.localization import _
from portprotonqt.logger import get_logger
@@ -145,7 +145,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
selected_account = game_config.get_steam_account_id()
selected_index = self.steamAccountCombo.findData(selected_account)
self.steamAccountCombo.setCurrentIndex(max(selected_index, 0))
steam_account_title = QLabel(f"{_('Account')} Steam:")
steam_account_title = QLabel(_("Account") + " Steam:")
steam_account_title.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
gogForm.addRow(steam_account_title, self.steamAccountCombo)
self.gogAccountStatus = QLabel()
@@ -160,7 +160,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
gog_buttons = QHBoxLayout()
gog_buttons.addWidget(self.gogAccountStatus)
gog_buttons.addWidget(self.gogLoginButton)
gog_account_title = QLabel(f"{_('Account')} GOG:")
gog_account_title = QLabel(_("Account") + " GOG:")
gog_account_title.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
gogForm.addRow(gog_account_title, gog_buttons)
self._update_gog_account_state()
@@ -344,6 +344,21 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
hide_autoinstall_layout.addStretch()
uiForm.addRow(hide_autoinstall_layout)
self.hideControlHintsCheckBox = QCheckBox()
self.hideControlHintsCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
self.hideControlHintsCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.hideControlHintsTitle = QLabel(_("Hide Control Hints"))
self.hideControlHintsTitle.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.hideControlHintsTitle.setStyleSheet(self.theme.SETTINGS_TITLE_CHECKBOX_STYLE)
self.hideControlHintsTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.hideControlHintsCheckBox.setChecked(ui_config.get_hide_control_hints())
hide_control_hints_layout = QHBoxLayout()
hide_control_hints_layout.setContentsMargins(0, 0, 0, 0)
hide_control_hints_layout.addWidget(self.hideControlHintsCheckBox)
hide_control_hints_layout.addWidget(self.hideControlHintsTitle)
hide_control_hints_layout.addStretch()
uiForm.addRow(hide_control_hints_layout)
self.soundsEnabledCheckBox = QCheckBox()
self.soundsEnabledCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
self.soundsEnabledCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
@@ -505,7 +520,9 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
auto_appimage_updates_layout.addWidget(self.autoAppImageUpdatesCheckBox)
auto_appimage_updates_layout.addWidget(self.autoAppImageUpdatesTitle)
auto_appimage_updates_layout.addStretch()
self.integrateAppImageButton = AutoSizeButton(_("Integrate AppImage"))
self.integrateAppImageButton = AutoSizeButton(
_("Integrate AppImage"), icon=self.theme_manager.get_icon("desktop", as_path=True)
)
self.integrateAppImageButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.integrateAppImageButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.integrateAppImageButton.clicked.connect(self.integrateAppImage)
@@ -645,8 +662,9 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
layout.addWidget(self.settingsScrollArea)
# Buttons (outside scroll area, always visible)
buttonsLayout = QHBoxLayout()
buttonsLayout.setSpacing(10)
buttonsLayout = FlowLayout(center_rows=True)
buttonsLayout.setContentsMargins(0, 0, 0, 0)
buttonsLayout.setSpacing(self.theme.portProtonPageVerticalSpacing)
self.saveButton = AutoSizeButton(_("Save Settings"), icon=self.theme_manager.get_icon("save", as_path=True))
self.saveButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
@@ -854,6 +872,9 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
auto_fullscreen_gamepad = self.autoFullscreenGamepadCheckBox.isChecked()
display_config.set_auto_fullscreen_gamepad(auto_fullscreen_gamepad)
hide_control_hints = self.hideControlHintsCheckBox.isChecked()
ui_config.set_hide_control_hints(hide_control_hints)
gamepad_type_idx = self.gamepadTypeCombo.currentIndex()
gamepad_type = self.gamepad_type_keys[gamepad_type_idx]
gamepad_config.set_gamepad_type(gamepad_type)
@@ -928,7 +949,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()
+23 -1
View File
@@ -42,10 +42,11 @@ class ThemeStoreCard(QFrame):
super().__init__()
self.theme_data = theme_data
self.card_width = card_width
self._hover_sound_pending = False
self.setObjectName("themeStoreCard")
self.setProperty("sound_event", "open")
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setMouseTracking(True)
self.setFixedWidth(card_width)
self.setStyleSheet(theme.THEME_STORE_CARD_STYLE)
@@ -54,6 +55,7 @@ class ThemeStoreCard(QFrame):
layout.setSpacing(0)
self.previewLabel = QLabel()
self.previewLabel.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
self.previewLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.previewLabel.setFixedHeight(int(card_width * 0.57))
self.previewLabel.setStyleSheet(theme.THEME_STORE_PREVIEW_STYLE)
@@ -61,15 +63,18 @@ class ThemeStoreCard(QFrame):
name = str(theme_data.get("name", ""))
nameLabel = QLabel(name)
nameLabel.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
nameLabel.setStyleSheet(theme.THEME_STORE_CARD_TITLE_STYLE)
layout.addWidget(nameLabel)
author = str(theme_data.get("author") or _("Unknown"))
authorLabel = QLabel(_("by {0}").format(author))
authorLabel.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
authorLabel.setStyleSheet(theme.THEME_STORE_CARD_META_STYLE)
layout.addWidget(authorLabel)
statsLabel = QLabel(self._stats_text())
statsLabel.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
statsLabel.setStyleSheet(theme.THEME_STORE_CARD_META_STYLE)
layout.addWidget(statsLabel)
@@ -99,7 +104,24 @@ class ThemeStoreCard(QFrame):
self.click()
super().mousePressEvent(event)
def enterEvent(self, event) -> None:
self._hover_sound_pending = True
super().enterEvent(event)
def leaveEvent(self, event) -> None:
self._hover_sound_pending = False
super().leaveEvent(event)
def mouseMoveEvent(self, event) -> None:
if self._hover_sound_pending:
from portprotonqt.sound_manager import SoundManager
SoundManager().play("navigate")
self._hover_sound_pending = False
super().mouseMoveEvent(event)
def click(self) -> None:
from portprotonqt.sound_manager import SoundManager
SoundManager().play("open")
self.clicked.emit(self.theme_data)
+52
View File
@@ -5,6 +5,7 @@ from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QMessageBox,
QStackedWidget,
QVBoxLayout,
QWidget,
@@ -134,6 +135,15 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
self.applyButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.themeInfoLayout.addWidget(self.applyButton)
self.deleteThemeButton = AutoSizeButton(
_("Delete Theme"),
icon=self.theme_manager.get_icon("delete", as_path=True),
)
self.deleteThemeButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.deleteThemeButton.setObjectName("themeDeleteButton")
self.deleteThemeButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.themeInfoLayout.addWidget(self.deleteThemeButton)
installedLayout.addLayout(self.themeInfoLayout)
self.themeContentStack.addWidget(self.themeInstalledPage)
self.themeStorePage = self._create_theme_store_page()
@@ -150,6 +160,8 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
base_theme = self.themesCombo.currentText()
variant = self.themeVariantCombo.currentData() or "light"
theme_name = ui_config.resolve_theme(base_theme, variant)
custom_themes = self._get_selected_custom_themes()
self.deleteThemeButton.setVisible(bool(custom_themes))
meta = load_theme_metainfo(theme_name)
link = meta.get("author_link", "")
link_html = f'<a href="{link}">{link}</a>' if link else _("No link")
@@ -191,10 +203,50 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
)
self.applyButton.clicked.connect(on_apply)
self.deleteThemeButton.clicked.connect(self._delete_selected_theme)
# Add widget to stackedWidget
self.theme_tab_index = self.stackedWidget.addWidget(self.themeTabWidget)
def _get_selected_custom_themes(self) -> list[str]:
base_theme = self.themesCombo.currentText()
variants = {
ui_config.resolve_theme(base_theme, "dark"),
ui_config.resolve_theme(base_theme, "light"),
}
return [name for name in variants if self.theme_manager.is_custom_theme(name)]
def _delete_selected_theme(self) -> None:
theme_names = self._get_selected_custom_themes()
if not theme_names:
return
message = _("Delete theme '{0}'?").format(self.themesCombo.currentText())
message_box = QMessageBox(self)
message_box.setIcon(QMessageBox.Icon.Question)
message_box.setWindowTitle(_("Confirm Deletion"))
message_box.setText(message)
message_box.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
message_box.setDefaultButton(QMessageBox.StandardButton.No)
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
if message_box.exec() != QMessageBox.StandardButton.Yes:
return
removal_results = [
self.theme_manager.remove_custom_theme(name) for name in theme_names
]
if not all(removal_results):
QMessageBox.warning(self, _("Error"), _("Failed to delete theme."))
return
active_theme_deleted = ui_config.get_theme_base() == self.themesCombo.currentText()
self._refresh_theme_combo("standart")
if active_theme_deleted:
self._apply_theme_and_restart("standart", self.themeVariantCombo.currentData() or "light")
return
self.themesCombo.setCurrentIndex(-1)
self.themesCombo.setCurrentIndex(0)
def _refresh_theme_store_visibility(self) -> None:
store_index = self.themesCombo.findData(THEME_STORE_ITEM)
if ui_config.get_enable_theme_store():
+25 -1
View File
@@ -3,6 +3,7 @@ import hashlib
import importlib.util
import os
import re
import shutil
import stat
import xml.etree.ElementTree as ET
import orjson
@@ -67,8 +68,12 @@ SVG_PAINT_ATTR_PATTERN = re.compile(
SVG_PAINT_ATTRIBUTES = {"fill", "stroke", "color", "stop-color", "flood-color", "lighting-color"}
SVG_ANIMATION_TAGS = {"animate", "animateMotion", "animateTransform", "set"}
SVG_KEEP_PAINT_VALUES = {"none", "transparent", "inherit", "initial", "unset", "freeze", "remove"}
SVG_NAMESPACE = "http://www.w3.org/2000/svg"
SVG_CACHE_VERSION = 2
NON_INHERITED_THEME_CONSTANTS = {"ICON_COLORS"}
ET.register_namespace("", SVG_NAMESPACE)
# Folder where all custom themes are located
xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
THEMES_DIRS = [
@@ -216,7 +221,7 @@ def _colored_svg_cache_path(icon_path: str, color: str) -> str | None:
except OSError as e:
logger.warning("Cannot stat SVG icon '%s': %s", icon_path, e)
return None
digest_source = f"{icon_path}:{stat.st_mtime_ns}:{color}".encode()
digest_source = f"{SVG_CACHE_VERSION}:{icon_path}:{stat.st_mtime_ns}:{color}".encode()
digest = hashlib.sha256(digest_source).hexdigest()[:16]
return str(CACHE_DIR / "icons" / f"{digest}.svg")
@@ -905,6 +910,25 @@ class ThemeManager:
"""Return list of available themes."""
return list_themes()
def is_custom_theme(self, theme_name: str) -> bool:
"""Return whether a theme is installed in the user theme directory."""
if not _is_valid_theme_name(theme_name):
return False
theme_path = os.path.join(THEMES_DIRS[0], theme_name)
return os.path.isdir(theme_path) and not os.path.islink(theme_path)
def remove_custom_theme(self, theme_name: str) -> bool:
"""Remove a theme from the user theme directory."""
if not self.is_custom_theme(theme_name):
return False
theme_path = os.path.join(THEMES_DIRS[0], theme_name)
try:
shutil.rmtree(theme_path)
except OSError as e:
logger.warning("Failed to remove custom theme '%s': %s", theme_name, e)
return False
return True
def apply_theme(self, theme_name: str):
"""
Apply selected theme if not already applied.
+19 -12
View File
@@ -35,6 +35,7 @@ color_surface = "#F0F2F5"
color_surface_elevated = "#E9ECEF"
color_surface_hover = "#DEE2E6"
color_text = "#ffffff"
color_text_accent_hover = "#F8F9FC"
color_surface_light = "#d2d3db"
color_surface_mid = "#9394a5"
color_text_dark = "#212529"
@@ -199,6 +200,7 @@ PLAY_BUTTON_STYLE = f"""
}}
QPushButton:hover, QPushButton:pressed, QPushButton:focus {{
background: {color_accent};
color: {color_text_accent_hover};
}}
"""
@@ -216,10 +218,12 @@ ADDGAME_BACK_BUTTON_STYLE = f"""
}}
QPushButton:hover, QPushButton:pressed {{
background: {color_accent};
color: {color_text_accent_hover};
}}
QPushButton:focus {{
background: {color_accent};
border: {border_thin} {color_accent};
color: {color_text_accent_hover};
}}
"""
@@ -238,6 +242,7 @@ LIBRARY_CONTROLS_BUTTON_STYLE = f"""
QPushButton:hover, QPushButton:pressed, QPushButton:focus, QPushButton:checked {{
background: {color_accent};
border: {border_thin} {color_accent};
color: {color_text_accent_hover};
}}
"""
@@ -314,12 +319,12 @@ COMBOBOX_STYLE = f"""
QComboBox:hover {{
border: {border_medium} {color_accent};
background: {color_accent};
color: {color_text};
color: {color_text_accent_hover};
}}
QComboBox:focus {{
border: {border_medium} {color_accent};
background-color: {color_accent};
color: {color_text};
color: {color_text_accent_hover};
}}
QComboBox:disabled {{
background: {color_surface_hover};
@@ -385,7 +390,7 @@ COMBOBOX_STYLE = f"""
}}
QListView::item:focus {{
background: {color_accent};
color: {color_text};
color: {color_text_accent_hover};
}}
""" % (
theme_manager.get_icon("down", current_theme_name, as_path=True),
@@ -460,12 +465,11 @@ TAB_STYLE = f"""
}}
QTabBar::tab:selected {{
background: {color_accent};
color: {color_text_dark};
color: {color_text};
color: {color_text_accent_hover};
}}
QTabBar::tab:hover {{
background: {color_accent};
color: {color_text};
color: {color_text_accent_hover};
}}
"""
@@ -498,21 +502,22 @@ QTableWidget::item:selected,
QTableWidget::item:focus,
QTableWidget::item:selected:focus {{
background: {color_accent};
color: {color_text};
color: {color_text_accent_hover};
selection-background-color: {color_accent};
}}
QTableWidget::item:hover {{
background: {color_transparent};
color: {color_text_accent_hover};
}}
QTableWidget::item:selected:hover {{
background: {color_accent};
color: {color_text};
color: {color_text_accent_hover};
}}
"""
HINT_BAR_STYLE = f"""
QWidget {{
max-height: 40px;
max-height: 82px;
}}
"""
@@ -530,15 +535,17 @@ ACTION_BUTTON_STYLE = f"""
QPushButton:hover {{
background: {color_accent};
border: {border_medium} {color_accent};
color: {color_text_accent_hover};
}}
QPushButton:pressed {{
background: {color_accent};
color: {color_text};
color: {color_text_accent_hover};
}}
QPushButton:focus {{
border: {border_medium} {color_accent};
background-color: {color_accent};
color: {color_text};
color: {color_text_accent_hover};
}}
"""
@@ -556,7 +563,7 @@ ACTION_BUTTON_ACTIVE_STYLE = f"""
QPushButton:hover {{
background: {color_accent};
border: {border_medium} {color_accent};
color: {color_text};
color: {color_text_accent_hover};
}}
QPushButton:pressed {{
background: {color_bg};
@@ -564,7 +571,7 @@ ACTION_BUTTON_ACTIVE_STYLE = f"""
QPushButton:focus {{
border: {border_medium} {color_accent};
background-color: {color_accent};
color: {color_text};
color: {color_text_accent_hover};
}}
"""
+20 -5
View File
@@ -142,6 +142,7 @@ PLAY_BUTTON_STYLE = f"""
}}
QPushButton:hover, QPushButton:pressed, QPushButton:focus {{
background: {color_accent};
color: {color_text_accent_hover};
}}
"""
@@ -159,10 +160,12 @@ ADDGAME_BACK_BUTTON_STYLE = f"""
}}
QPushButton:hover, QPushButton:pressed {{
background: {color_accent};
color: {color_text_accent_hover};
}}
QPushButton:focus {{
background: {color_accent};
border: {border_thin} {color_accent};
color: {color_text_accent_hover};
}}
"""
@@ -181,6 +184,7 @@ LIBRARY_CONTROLS_BUTTON_STYLE = f"""
QPushButton:hover, QPushButton:pressed, QPushButton:focus, QPushButton:checked {{
background: {color_accent};
border: {border_thin} {color_accent};
color: {color_text_accent_hover};
}}
"""
@@ -272,10 +276,12 @@ COMBOBOX_STYLE = f"""
QComboBox:hover {{
border: {border_medium} {color_accent};
background: {color_accent};
color: {color_text_accent_hover};
}}
QComboBox:focus {{
border: {border_medium} {color_accent};
background-color: {color_accent};
color: {color_text_accent_hover};
}}
QComboBox:disabled {{
background: {color_combo_disabled_bg};
@@ -340,7 +346,7 @@ COMBOBOX_STYLE = f"""
}}
QListView::item:focus {{
background: {color_accent};
color: {color_text};
color: {color_text_accent_hover};
}}
""" % (
theme_manager.get_icon("down", current_theme_name, as_path=True),
@@ -352,6 +358,7 @@ SETTINGS_TABLE_COMBOBOX_STYLE = f"""
QComboBox#settingsTableCombo:focus {{
background: {color_surface};
border: {border_medium} {color_accent};
color: {color_text};
}}
"""
@@ -414,16 +421,17 @@ TAB_STYLE = f"""
}}
QTabBar::tab:selected {{
background: {color_accent};
color: {color_text};
color: {color_text_accent_hover};
}}
QTabBar::tab:hover {{
background: {color_accent};
color: {color_text_accent_hover};
}}
"""
HINT_BAR_STYLE = """
QWidget {
max-height: 40px;
max-height: 82px;
}
"""
@@ -476,15 +484,16 @@ QTableWidget::item:selected,
QTableWidget::item:focus,
QTableWidget::item:selected:focus {{
background: {color_accent};
color: {color_text};
color: {color_text_accent_hover};
selection-background-color: {color_accent};
}}
QTableWidget::item:hover {{
background: {color_transparent};
color: {color_text_accent_hover};
}}
QTableWidget::item:selected:hover {{
background: {color_accent};
color: {color_text};
color: {color_text_accent_hover};
}}
"""
@@ -501,14 +510,17 @@ ACTION_BUTTON_STYLE = f"""
}}
QPushButton:hover {{
background: {color_accent};
color: {color_text_accent_hover};
border: {border_medium} {color_accent};
}}
QPushButton:pressed {{
background: {color_bg};
color: {color_text_accent_hover};
}}
QPushButton:focus {{
border: {border_medium} {color_accent};
background-color: {color_accent};
color: {color_text_accent_hover};
}}
"""
@@ -526,13 +538,16 @@ ACTION_BUTTON_ACTIVE_STYLE = f"""
QPushButton:hover {{
background: {color_accent};
border: {border_medium} {color_accent};
color: {color_text_accent_hover};
}}
QPushButton:pressed {{
background: {color_bg};
color: {color_text_accent_hover};
}}
QPushButton:focus {{
border: {border_medium} {color_accent};
background-color: {color_accent};
color: {color_text_accent_hover};
}}
"""
@@ -1,5 +1,2 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><style type="text/css">
.fil0 {fill:black}
</style></defs><g transform="matrix(-.99619 0 0 -.99619 -3.2034 6.568)" clip-rule="evenodd" fill-rule="evenodd" shape-rendering="geometricPrecision"><g stroke-width="0"><path d="m-33.501-15.9-5.6277 5.6272c-0.43985 0.43985-0.97478 0.6619-1.5968 0.6619h-8.1521c-0.71077 0-1.2909-0.58009-1.2909-1.2914v-13.19c0-0.7113 0.58009-1.2914 1.2909-1.2914h8.1521c0.62206 0 1.157 0.22205 1.5968 0.66136l5.6277 5.6277c0.8797 0.8797 0.8797 2.3145 0 3.1947z" color="#000000" fill="#3f424d"/><path d="m-48.877-26.133c-1.1188 0-2.041 0.92195-2.041 2.041v13.189c0 1.1191 0.92218 2.041 2.041 2.041h8.1523c0.80053 0 1.5614-0.31526 2.127-0.88086l5.627-5.627c1.1678-1.1685 1.168-3.0878 0-4.2559l-5.627-5.627v-2e-3c-0.56557-0.56488-1.3264-0.87891-2.127-0.87891zm0 1.5h8.1523c0.44358 0 0.75228 0.12766 1.0664 0.44141l5.627 5.627c0.59137 0.59136 0.59163 1.5428 0 2.1348l-5.627 5.627c-0.3141 0.3141-0.62283 0.44141-1.0664 0.44141h-8.1523c-0.3027 0-0.54102-0.23748-0.54102-0.54102v-13.189c0-0.30353 0.23832-0.54102 0.54102-0.54102z" color="#000000" fill="#fff"/><path d="m-26.756-12.35 5.6277 5.6277c0.43932 0.43985 0.66137 0.97478 0.66137 1.5968v8.1521c0 0.71077-0.58009 1.2909-1.2914 1.2909h-13.19c-0.7113 0-1.2914-0.58009-1.2914-1.2909v-8.1521c0-0.62206 0.22205-1.157 0.6619-1.5968l5.6272-5.6277c0.88023-0.8797 2.315-0.8797 3.1947 0z" color="#000000" fill="#3f424d"/><path d="m-28.354-13.756c-0.77175-8e-6 -1.5427 0.29112-2.127 0.875l-5.6289 5.6289c-0.5656 0.5656-0.88086 1.3264-0.88086 2.127v8.1504c0 1.1188 0.92195 2.041 2.041 2.041h13.191c1.1191 0 2.041-0.92218 2.041-2.041v-8.1504c0-0.80053-0.31598-1.5614-0.88086-2.127l-5.6289-5.6289c-0.58401-0.58401-1.3552-0.87499-2.127-0.875zm0 1.4922c0.38564-7e-6 0.77072 0.14768 1.0664 0.44336l5.627 5.627c0.31375 0.31413 0.44336 0.62478 0.44336 1.0684v8.1504c0 0.3027-0.23748 0.54102-0.54102 0.54102h-13.191c-0.30353 0-0.54102-0.23832-0.54102-0.54102v-8.1504c0-0.44358 0.12731-0.75231 0.44141-1.0664l5.6289-5.6289c0.29599-0.29581 0.68077-0.44335 1.0664-0.44336z" color="#000000" fill="#fff"/></g><path d="m-23.943-19.241 6.145-6.145c0.48028-0.4797 1.0644-0.72216 1.7436-0.72216h8.9014c0.77668 0 1.4095 0.63341 1.4095 1.4101v14.403c0 0.77668-0.63283 1.4101-1.4095 1.4101h-8.9014c-0.67924 0-1.2633-0.24246-1.7436-0.72274l-6.145-6.1445c-0.96056-0.96114-0.96056-2.5279 0-3.4884z" color="#000000" fill="#fff" style="-inkscape-stroke:none"/><path d="m-16.055-27.109c-0.91721 0-1.8013 0.3685-2.4492 1.0156l-6.1465 6.1445c-1.345 1.345-1.3447 3.5569 0 4.9023l6.1465 6.1445c0.64795 0.64795 1.532 1.0156 2.4492 1.0156h8.9023c1.3204-1e-7 2.4102-1.0903 2.4102-2.4102v-14.402c0-1.3199-1.0898-2.4102-2.4102-2.4102zm0 2h8.9023c0.23299 0 0.41016 0.17666 0.41016 0.41016v14.402c0 0.23349-0.17716 0.41016-0.41016 0.41016h-8.9023c-0.44127 0-0.72254-0.11707-1.0352-0.42969l-6.1465-6.1426v-2e-3c-0.57508-0.57691-0.57566-1.4986 0-2.0742l6.1445-6.1445c0.31266-0.31228 0.59584-0.42969 1.0371-0.42969z" color="#000000" fill="#3f424d" style="-inkscape-stroke:none"/><g stroke-width="0"><path d="m-29.951-22.644-5.6272-5.6277c-0.43985-0.43985-0.6619-0.97478-0.6619-1.5968v-8.1521c0-0.7113 0.58009-1.2909 1.2914-1.2909h13.19c0.7113 0 1.2914 0.57956 1.2914 1.2909v8.1521c0 0.62205-0.22205 1.157-0.66137 1.5968l-5.6277 5.6277c-0.8797 0.8797-2.3145 0.8797-3.1947 0z" color="#000000" fill="#3f424d"/><path d="m-34.949-40.062c-1.1187 0-2.041 0.92195-2.041 2.041v8.1523c0 0.80053 0.31526 1.5614 0.88086 2.127l5.6289 5.6289c1.1685 1.1678 3.0859 1.168 4.2539 0l5.6289-5.6289c0.56488-0.56557 0.88086-1.3264 0.88086-2.127v-8.1523c0-1.1191-0.92232-2.041-2.041-2.041zm0 1.5h13.191c0.3039 0 0.54102 0.23748 0.54102 0.54102v8.1523c0 0.44357-0.12961 0.75228-0.44336 1.0664l-5.627 5.6289c-0.59136 0.59136-1.5408 0.59163-2.1328 0l-5.6289-5.6289c-0.3141-0.3141-0.44141-0.62283-0.44141-1.0664v-8.1523c0-0.30353 0.23711-0.54102 0.54102-0.54102z" color="#000000" fill="#fff"/></g></g></svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g fill="#3f424d"><path d="m30.17 22.407 5.6063-5.6058c0.43817-0.43818 0.97107-0.65938 1.5907-0.65938h8.121c0.70806 0 1.286 0.57788 1.286 1.2865v13.14c0 0.70859-0.57788 1.2865-1.286 1.2865h-8.121c-0.61969 0-1.1526-0.2212-1.5907-0.65884l-5.6063-5.6063c-0.87635-0.87635-0.87635-2.3057 0-3.1825z"/><path d="m23.451 18.871-5.6063-5.6063c-0.43765-0.43817-0.65885-0.97107-0.65885-1.5907v-8.121c0-0.70806 0.57788-1.286 1.2865-1.286h13.14c0.70859 0 1.2865 0.57788 1.2865 1.286v8.121c0 0.61969-0.2212 1.1526-0.65938 1.5907l-5.6058 5.6063c-0.87688 0.87635-2.3062 0.87635-3.1825 0z"/><path d="m12.79 33.574c0.91372 0 1.7944-0.3671 2.4399-1.0117l6.1231-6.1211c1.3399-1.3399 1.3396-3.5433 0-4.8836l-6.1231-6.1211c-0.64548-0.64548-1.5262-1.0117-2.4399-1.0117h-8.8684c-1.3154 1e-6 -2.401 1.0861-2.401 2.401v14.347c0 1.3149 1.0856 2.401 2.401 2.401zm0-1.9924h-8.8684c-0.2321 0-0.4086-0.17599-0.4086-0.4086v-14.347c0-0.2326 0.17648-0.4086 0.4086-0.4086h8.8684c0.43959 0 0.71979 0.11662 1.0313 0.42805l6.1231 6.1192v2e-3c0.57289 0.57471 0.57347 1.4929 0 2.0663l-6.1211 6.1211c-0.31147 0.31109-0.59357 0.42805-1.0331 0.42805z"/><path d="m26.633 29.126 5.6058 5.6063c0.43817 0.43817 0.65938 0.97107 0.65938 1.5907v8.121c0 0.70859-0.57788 1.286-1.2865 1.286h-13.14c-0.70859 0-1.2865-0.57735-1.2865-1.286v-8.121c0-0.61968 0.2212-1.1526 0.65885-1.5907l5.6063-5.6063c0.87635-0.87635 2.3057-0.87635 3.1825 0z"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,5 +1,2 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><style type="text/css">
.fil0 {fill:black}
</style></defs><g transform="matrix(.99619 0 0 .99619 52.245 41.43)" clip-rule="evenodd" fill-rule="evenodd" shape-rendering="geometricPrecision"><g stroke-width="0"><path d="m-33.501-15.9-5.6277 5.6272c-0.43985 0.43985-0.97478 0.6619-1.5968 0.6619h-8.1521c-0.71077 0-1.2909-0.58009-1.2909-1.2914v-13.19c0-0.7113 0.58009-1.2914 1.2909-1.2914h8.1521c0.62206 0 1.157 0.22205 1.5968 0.66136l5.6277 5.6277c0.8797 0.8797 0.8797 2.3145 0 3.1947z" color="#000000" fill="#3f424d"/><path d="m-48.877-26.133c-1.1188 0-2.041 0.92195-2.041 2.041v13.189c0 1.1191 0.92218 2.041 2.041 2.041h8.1523c0.80053 0 1.5614-0.31526 2.127-0.88086l5.627-5.627c1.1678-1.1685 1.168-3.0878 0-4.2559l-5.627-5.627v-2e-3c-0.56557-0.56488-1.3264-0.87891-2.127-0.87891zm0 1.5h8.1523c0.44358 0 0.75228 0.12766 1.0664 0.44141l5.627 5.627c0.59137 0.59136 0.59163 1.5428 0 2.1348l-5.627 5.627c-0.3141 0.3141-0.62283 0.44141-1.0664 0.44141h-8.1523c-0.3027 0-0.54102-0.23748-0.54102-0.54102v-13.189c0-0.30353 0.23832-0.54102 0.54102-0.54102z" color="#000000" fill="#fff"/><path d="m-26.756-12.35 5.6277 5.6277c0.43932 0.43985 0.66137 0.97478 0.66137 1.5968v8.1521c0 0.71077-0.58009 1.2909-1.2914 1.2909h-13.19c-0.7113 0-1.2914-0.58009-1.2914-1.2909v-8.1521c0-0.62206 0.22205-1.157 0.6619-1.5968l5.6272-5.6277c0.88023-0.8797 2.315-0.8797 3.1947 0z" color="#000000" fill="#3f424d"/><path d="m-28.354-13.756c-0.77175-8e-6 -1.5427 0.29112-2.127 0.875l-5.6289 5.6289c-0.5656 0.5656-0.88086 1.3264-0.88086 2.127v8.1504c0 1.1188 0.92195 2.041 2.041 2.041h13.191c1.1191 0 2.041-0.92218 2.041-2.041v-8.1504c0-0.80053-0.31598-1.5614-0.88086-2.127l-5.6289-5.6289c-0.58401-0.58401-1.3552-0.87499-2.127-0.875zm0 1.4922c0.38564-7e-6 0.77072 0.14768 1.0664 0.44336l5.627 5.627c0.31375 0.31413 0.44336 0.62478 0.44336 1.0684v8.1504c0 0.3027-0.23748 0.54102-0.54102 0.54102h-13.191c-0.30353 0-0.54102-0.23832-0.54102-0.54102v-8.1504c0-0.44358 0.12731-0.75231 0.44141-1.0664l5.6289-5.6289c0.29599-0.29581 0.68077-0.44335 1.0664-0.44336z" color="#000000" fill="#fff"/></g><path d="m-23.943-19.241 6.145-6.145c0.48028-0.4797 1.0644-0.72216 1.7436-0.72216h8.9014c0.77668 0 1.4095 0.63341 1.4095 1.4101v14.403c0 0.77668-0.63283 1.4101-1.4095 1.4101h-8.9014c-0.67924 0-1.2633-0.24246-1.7436-0.72274l-6.145-6.1445c-0.96056-0.96114-0.96056-2.5279 0-3.4884z" color="#000000" fill="#fff" style="-inkscape-stroke:none"/><path d="m-16.055-27.109c-0.91721 0-1.8013 0.3685-2.4492 1.0156l-6.1465 6.1445c-1.345 1.345-1.3447 3.5569 0 4.9023l6.1465 6.1445c0.64795 0.64795 1.532 1.0156 2.4492 1.0156h8.9023c1.3204-1e-7 2.4102-1.0903 2.4102-2.4102v-14.402c0-1.3199-1.0898-2.4102-2.4102-2.4102zm0 2h8.9023c0.23299 0 0.41016 0.17666 0.41016 0.41016v14.402c0 0.23349-0.17716 0.41016-0.41016 0.41016h-8.9023c-0.44127 0-0.72254-0.11707-1.0352-0.42969l-6.1465-6.1426v-2e-3c-0.57508-0.57691-0.57566-1.4986 0-2.0742l6.1445-6.1445c0.31266-0.31228 0.59584-0.42969 1.0371-0.42969z" color="#000000" fill="#3f424d" style="-inkscape-stroke:none"/><g stroke-width="0"><path d="m-29.951-22.644-5.6272-5.6277c-0.43985-0.43985-0.6619-0.97478-0.6619-1.5968v-8.1521c0-0.7113 0.58009-1.2909 1.2914-1.2909h13.19c0.7113 0 1.2914 0.57956 1.2914 1.2909v8.1521c0 0.62205-0.22205 1.157-0.66137 1.5968l-5.6277 5.6277c-0.8797 0.8797-2.3145 0.8797-3.1947 0z" color="#000000" fill="#3f424d"/><path d="m-34.949-40.062c-1.1187 0-2.041 0.92195-2.041 2.041v8.1523c0 0.80053 0.31526 1.5614 0.88086 2.127l5.6289 5.6289c1.1685 1.1678 3.0859 1.168 4.2539 0l5.6289-5.6289c0.56488-0.56557 0.88086-1.3264 0.88086-2.127v-8.1523c0-1.1191-0.92232-2.041-2.041-2.041zm0 1.5h13.191c0.3039 0 0.54102 0.23748 0.54102 0.54102v8.1523c0 0.44357-0.12961 0.75228-0.44336 1.0664l-5.627 5.6289c-0.59136 0.59136-1.5408 0.59163-2.1328 0l-5.6289-5.6289c-0.3141-0.3141-0.44141-0.62283-0.44141-1.0664v-8.1523c0-0.30353 0.23711-0.54102 0.54102-0.54102z" color="#000000" fill="#fff"/></g></g></svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g fill="#3f424d"><path d="m18.872 25.591-5.6063 5.6058c-0.43817 0.43818-0.97107 0.65938-1.5907 0.65938h-8.121c-0.70806 0-1.286-0.57788-1.286-1.2865v-13.14c0-0.70859 0.57788-1.2865 1.286-1.2865h8.121c0.61969 0 1.1526 0.2212 1.5907 0.65884l5.6063 5.6063c0.87635 0.87635 0.87635 2.3057 0 3.1825z"/><path d="m25.591 29.127 5.6063 5.6063c0.43765 0.43817 0.65885 0.97107 0.65885 1.5907v8.121c0 0.70806-0.57788 1.286-1.2865 1.286h-13.14c-0.70859 0-1.2865-0.57788-1.2865-1.286v-8.121c0-0.61969 0.2212-1.1526 0.65938-1.5907l5.6058-5.6063c0.87688-0.87635 2.3062-0.87635 3.1825 0z"/><path d="m36.251 14.424c-0.91372 0-1.7944 0.3671-2.4399 1.0117l-6.1231 6.1211c-1.3399 1.3399-1.3396 3.5433 0 4.8836l6.1231 6.1211c0.64548 0.64548 1.5262 1.0117 2.4399 1.0117h8.8684c1.3154-1e-6 2.401-1.0861 2.401-2.401v-14.347c0-1.3149-1.0856-2.401-2.401-2.401zm0 1.9924h8.8684c0.2321 0 0.4086 0.17599 0.4086 0.4086v14.347c0 0.2326-0.17648 0.4086-0.4086 0.4086h-8.8684c-0.43959 0-0.71979-0.11662-1.0313-0.42805l-6.1231-6.1192v-2e-3c-0.57289-0.57471-0.57347-1.4929 0-2.0663l6.1211-6.1211c0.31147-0.31109 0.59357-0.42805 1.0331-0.42805z"/><path d="m22.408 18.872-5.6058-5.6063c-0.43817-0.43817-0.65938-0.97107-0.65938-1.5907v-8.121c0-0.70859 0.57788-1.286 1.2865-1.286h13.14c0.70859 0 1.2865 0.57735 1.2865 1.286v8.121c0 0.61968-0.2212 1.1526-0.65885 1.5907l-5.6063 5.6063c-0.87635 0.87635-2.3057 0.87635-3.1825 0z"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1 +1,2 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m24 13.476c-5.7918 0-10.524 4.7162-10.524 10.524 0 5.7918 4.7162 10.524 10.524 10.524 5.7918 0 10.524-4.7162 10.524-10.524 0-5.7918-4.7162-10.524-10.524-10.524zm0 18.037c-4.137 0-7.5128-3.3758-7.5128-7.5128s3.3758-7.5128 7.5128-7.5128 7.5128 3.3758 7.5128 7.5128-3.3592 7.5128-7.5128 7.5128z" fill="#3f424d" stroke-width="1.6548"/></svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m24 1a23 23 0 0 0-23 23 23 23 0 0 0 23 23 23 23 0 0 0 23-23 23 23 0 0 0-23-23zm0 8.7227c7.88 0 14.277 6.4196 14.277 14.277 0 7.88-6.4196 14.277-14.277 14.277-7.88 0-14.277-6.4196-14.277-14.277 0-7.88 6.4196-14.277 14.277-14.277zm0 4.084c-5.613 0-10.195 4.5784-10.195 10.191-1e-6 5.613 4.5823 10.195 10.195 10.195 5.6356 0 10.195-4.5823 10.195-10.195 1e-6 -5.613-4.5823-10.191-10.195-10.191z" fill="#3f424d"/></svg>

Before

Width:  |  Height:  |  Size: 736 B

After

Width:  |  Height:  |  Size: 581 B

Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff. Show more