Compare commits

...
Author SHA1 Message Date
Boria138 afa9519efc chore: enable NVAPI Reflex
Code check / Check code (pull_request) Successful in 1m41s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-27 18:15:38 +05:00
Boria138 13e89d8b2b chore(locale): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-26 12:01:15 +05:00
Boria138 30c3bfb3b1 feat(settings): allow skipping ALT Linux dependency checks
Code check / Check code (push) Successful in 1m34s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-26 11:59:37 +05:00
Boria138 8e12ec713f fix: repair malformed desktop entries
Code check / Check code (push) Successful in 3m43s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-25 22:38:30 +05:00
Boria138 e809f031ce fix: scroll horizontal library layouts with mouse wheel
Code check / Check code (push) Successful in 1m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-25 14:28:09 +05:00
Boria138 1b42c672bb fix: include library layout in gamepad navigation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-25 14:20:43 +05:00
Boria138 07b53f3160 chore(locale): update
Code check / Check code (push) Successful in 2m2s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-25 13:59:45 +05:00
Boria138 40d80f0ce8 fix: preserve theme card animations across library layouts
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-25 13:58:53 +05:00
Boria138 0b8536b163 feat: add configurable library layout variant
Code check / Check code (push) Successful in 1m38s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-25 13:37:05 +05:00
Boria138 31f6d84537 feat: add vertical cards to horizontal library layouts
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-25 13:13:11 +05:00
Boria138 5c86b3d943 chore: remove unversioned LG Wine aliases from settings
Code check / Check code (push) Successful in 2m2s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-24 20:13:29 +05:00
Boria138 ec2ffee64d chore(locale): update
Code check / Check code (push) Successful in 1m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-24 20:08:51 +05:00
Boria138 60e9a2866a chore: move Wine defaults to user.conf settings
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-24 20:07:47 +05:00
Boria138 94269455ec fix: restore gamepad control for user.conf combos
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-24 19:55:38 +05:00
Boria138 f5c07cf3ea fix: add resolution save to global game settings editor
Code check / Check code (push) Successful in 2m43s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-24 12:18:24 +05:00
Boria138 509d4272ab chore(locale): update
Code check / Check code (push) Successful in 1m54s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-24 11:40:43 +05:00
Boria138 ff560fb43a feat: add global game settings editor
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-24 11:39:34 +05:00
Boria138 ed78af49d2 feat: add unified store download queue
Code check / Check code (push) Successful in 1m45s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-23 11:14:24 +05:00
Boria138 5c5a154a0d feat: improve completed download actions
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-23 10:59:46 +05:00
Boria138 ae011adfdf feat: add install all button to DLC dialog
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-23 10:36:12 +05:00
Boria138 b0d97e0e0b chore: added Temporarily disable OptiScaler hint to compatibility_report
Code check / Check code (push) Successful in 1m48s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-22 19:06:39 +05:00
Boria138 8a303bf9ed fix: improve game crash detection
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-22 19:03:40 +05:00
Boria138 a14563dd78 fix: prevent duplicate game card when adding a game
Code check / Check code (push) Successful in 2m42s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-21 15:49:39 +05:00
Codeberg TranslateandBoria138 abb511c173 Translated using Weblate (Russian)
Currently translated at 100.0% (781 of 781 strings)

Co-authored-by: Boria138 <boria138@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-09-20 18:28:52 +00:00
Boria138 ef0626a069 chore(locale): merge Russian Weblate updates 2026-09-20 23:26:03 +05:00
Boria138 40f06caa16 fix: update library search after adding executable
Code check / Check code (push) Successful in 1m50s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-20 23:23:30 +05:00
Boria138 bfd4bf4065 chore: Added Kex Engine support to graphics_detector
Code check / Check code (push) Successful in 2m15s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-20 14:11:59 +05:00
Boria138 92ecc008f3 chore(locale): update
Code check / Check code (push) Successful in 4m4s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-20 13:25:21 +05:00
Boria138 0c78e9c689 feat: add MangoHud font settings
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-20 13:24:23 +05:00
Codeberg Translateandadem 4840d512b5 Translated using Weblate (Russian)
Currently translated at 100.0% (779 of 779 strings)

Co-authored-by: Andrei Stepanov <adem4ik@gmail.com>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-09-20 07:04:46 +00:00
Boria138 c22992b803 chore: use unity player to graphics_detector
Code check / Check code (push) Successful in 3m38s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-20 12:04:22 +05:00
Renovate Bot 57f955d827 chore(deps): update debian docker tag to trixie-20260918
Code check / Check code (push) Successful in 2m42s
2026-09-20 06:48:57 +00:00
Renovate Bot f9694e576b chore(deps): update archlinux:base-devel docker digest to 4894f5a
Code check / Check code (pull_request) Successful in 1m57s
Package Build - AppImage, Arch, Fedora, Debian / changes (pull_request) Successful in 14s
Package Build - AppImage, Arch, Fedora, Debian / Build AppImage (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (43) (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (44) (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (45) (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (rawhide) (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 1m56s
2026-09-20 00:00:58 +00:00
Gitea Actions a74c5631ec chore: download stats 2026-09-18T17:06:55Z 2026-09-18 17:06:56 +00:00
Boria138 55361797c3 chore: round download stats
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-18 22:05:28 +05:00
Boria138 f3999e9bf1 fix: refresh detail page after editing a shortcut
Code check / Check code (push) Successful in 1m56s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-18 21:32:13 +05:00
Boria138 35366bbf12 fix: require confirmation before deleting EGS games
Code check / Check code (push) Successful in 1m36s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-18 11:44:40 +05:00
Boria138 bab6d6b84e feat: add DLC selection and installation for GOG and EGS
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-18 11:40:24 +05:00
Boria138 061ee7fedf fix: keep library search results packed in both layouts
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-18 11:18:21 +05:00
Boria138 8707c93835 feat: verify existing GOG and Epic files during installation
Code check / Check code (push) Successful in 2m1s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-17 22:51:02 +05:00
Boria138 53a44c5708 fix: filter GOG and Epic DLCs from the game library
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-17 22:24:52 +05:00
Boria138 154cf43bf8 fix: show Steam demos and related applications
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-17 22:17:21 +05:00
Boria138 a70bf97774 ci: check Bash 5.1 conditional separator compatibility
Code check / Check code (push) Successful in 2m50s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-16 18:31:34 +05:00
Boria138 a06f76a2e9 fix: wait for background workers during shutdown
Code check / Check code (push) Successful in 2m41s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-15 19:33:04 +05:00
Boria138 649aec7229 fix: restore prefix backup support with libarchive-c 5.1
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-15 19:20:46 +05:00
Boria138 0da0d55537 docs: separate third-party license notices
Code check / Check code (push) Successful in 3m45s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-15 16:25:14 +05:00
Renovate Bot c2f611c73e chore(deps): update https://git.linux-gaming.ru/actions/anylinux-setup-action action to v4
Code check / Check code (push) Successful in 1m50s
2026-09-15 10:33:12 +00:00
Renovate Bot c4f9f05363 chore(deps): update archlinux:base-devel docker digest to 70d777a
Code check / Check code (pull_request) Successful in 3m11s
Package Build - AppImage, Arch, Fedora, Debian / changes (pull_request) Successful in 14s
Package Build - AppImage, Arch, Fedora, Debian / Build AppImage (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (43) (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (44) (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (45) (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (rawhide) (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 3m36s
2026-09-15 10:27:15 +00:00
Boria138 56c8a9781b fix: restore gamepad hotplug and active type hints
Code check / Check code (push) Successful in 1m55s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-14 17:11:54 +05:00
Boria138 9d72466402 fix: align autoinstall behavior with library layouts
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-14 17:05:51 +05:00
Boria138 4df207fa3b chore: migrate AppImage builds to anylinux setup action
Code check / Check code (push) Successful in 1m32s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-14 13:11:57 +05:00
Renovate Bot bd6dc490b1 fix(deps): update dependency evdev to v2
Code check / Check code (push) Successful in 1m43s
2026-09-14 07:55:51 +00:00
Renovate Bot 383d17f523 fix(deps): update dependency tqdm to >=4.70.1
Code check / Check code (push) Successful in 1m33s
2026-09-14 07:55:36 +00:00
Codeberg Translateandadem 9b889a8d2e Translated using Weblate (Russian)
Currently translated at 100.0% (779 of 779 strings)

Co-authored-by: Andrei Stepanov <adem4ik@gmail.com>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-09-14 07:54:46 +00:00
Boria138 64c9f4d196 fix: support input from multiple gamepads
Code check / Check code (push) Successful in 1m34s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-14 07:54:40 +00:00
Boria138 0de9e46485 fix: check ALT dependencies before GOG support setup
Code check / Check code (push) Successful in 2m25s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-13 12:53:44 +05:00
Boria138 bdc14d1d86 fix: preserve terminal errors for inspection
Code check / Check code (push) Successful in 1m46s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-13 12:48:39 +05:00
Boria138 1fc8d62a85 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-13 11:58:12 +05:00
Codeberg Translateandadem4ik 0e3a0be146 Translated using Weblate (Russian)
Currently translated at 100.0% (776 of 776 strings)

Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: adem4ik <adem4ik@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-09-11 19:32:38 +00:00
Codeberg Translateandrogerio-mattos 6e2f7452bd Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (773 of 773 strings)

Co-authored-by: Codeberg Translate <translate@codeberg.org>
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-09-11 19:32:38 +00:00
Codeberg Translateandgallegonovato 37168f8892 Translated using Weblate (Spanish)
Currently translated at 44.9% (349 of 776 strings)

Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: gallegonovato <gallegonovato@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/es/
Translation: PortProtonQt/PortProtonQt
2026-09-11 19:32:38 +00:00
Codeberg TranslateandRubin13 141f98d81f Translated using Weblate (German)
Currently translated at 100.0% (779 of 779 strings)

Co-authored-by: Rubin13 <rubin13@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/
Translation: PortProtonQt/PortProtonQt
2026-09-11 19:32:38 +00:00
Boria138 7031051dc4 feat: added vulkan to graphics API executable detection
Code check / Check code (push) Failing after 30s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-12 00:29:42 +05:00
Boria138 769ad421a2 feat: improve graphics API executable detection
Code check / Check code (push) Successful in 1m58s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-12 00:25:41 +05:00
Boria138 2316efe3d6 fix: prevent autoinstall custom data collisions
Code check / Check code (push) Successful in 2m27s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-11 17:40:57 +05:00
Boria138 09b790fc26 feat: add ID-based GOG and EGS shortcut editing
Code check / Check code (push) Successful in 1m34s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-11 17:19:11 +05:00
Boria138 a48b9050d2 chore: migrate legacy data to persistent storage
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-11 17:12:15 +05:00
Boria138 b602880907 chore: move cache and launcher storage to XDG data directory
Code check / Check code (push) Successful in 2m20s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-11 12:33:40 +05:00
Boria138 05a5b136e9 chore(locale): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-11 12:19:18 +05:00
Boria138 bde4ba9602 feat: offer removal or executable replacement for missing games
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-11 12:16:59 +05:00
Renovate Bot 2634370ee3 chore(deps): update archlinux:base-devel docker digest to 61f7de2
Code check / Check code (push) Successful in 2m5s
2026-09-10 15:56:58 +00:00
Renovate Bot 9cea141f9c chore(deps): update dependency pyright to >=1.1.414
Code check / Check code (push) Successful in 2m16s
2026-09-10 15:56:48 +00:00
Boria138 3d417c1a83 fix: refresh button sizing during live theme changes
Code check / Check code (push) Successful in 3m19s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-10 20:56:21 +05:00
Boria138 27003d4967 fix: support library background during live theme switch
Code check / Check code (push) Successful in 1m57s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-10 20:42:27 +05:00
Boria138 c75755bb64 fix: initialize tray before silent launch
Code check / Check code (push) Failing after 55s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-10 13:55:41 +05:00
Boria138 67652ea258 chore(locale): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-10 13:50:14 +05:00
Boria138 3735c7514c feat: add game pause controls to system tray
Code check / Check code (push) Failing after 1m28s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-10 13:49:01 +05:00
Boria138 803c98b6ff fix: prevent empty tray icon during slow startup
Code check / Check code (push) Failing after 1m25s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-10 12:55:19 +05:00
Boria138 9214018c03 fix: wrap game titles in horizontal library modes
Code check / Check code (push) Successful in 4m19s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-09 17:45:51 +05:00
Boria138 d2597fa8d6 feat: support horizontal auto-hide scrollbars
Code check / Check code (push) Successful in 3m21s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-09 17:43:08 +05:00
Boria138 25e20c3ea1 feat: reload custom themes when files change
Code check / Check code (push) Successful in 1m46s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-09 17:32:14 +05:00
Boria138 f1a5db4f34 fix: use XDG data paths on NixOS
Code check / Check code (push) Successful in 1m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-08 20:26:34 +05:00
Boria138 096d86cb7b fix: set icon for built-in terminal window
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-08 20:16:06 +05:00
Boria138 7b7553a1fe feat: add ALT i586 dependency CLI check
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-08 20:13:56 +05:00
Boria138 7c30418d61 fix: filter Wine builds by host architecture
Code check / Check code (push) Successful in 2m52s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-08 14:57:17 +05:00
Boria138 fa30835e97 fix: handle store game shutdown without stale UI state
Code check / Check code (push) Successful in 2m33s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-08 14:37:06 +05:00
Renovate Bot 0bdab8fd40 fix(deps): update python dependencies
Code check / Check code (push) Successful in 2m41s
2026-09-08 09:19:59 +00:00
Renovate Bot 43f4d0a665 chore(deps): update https://git.linux-gaming.ru/actions/setup-python action to v7
Code check / Check code (push) Successful in 3m29s
2026-09-08 09:19:48 +00:00
Renovate Bot 779a5eca65 chore(deps): update https://git.linux-gaming.ru/actions/setup-node action to v7
Code check / Check code (pull_request) Successful in 3m36s
Code check / Check code (push) Successful in 2m21s
2026-09-08 08:59:41 +00:00
Boria138 1a30a105d0 test: use built-in TimeoutError in D-Bus test
Code check / Check code (push) Successful in 2m6s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-08 13:54:40 +05:00
Boria138 45fe72d393 build: require Python 3.11
Code check / Check code (push) Failing after 1m31s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-08 13:44:42 +05:00
Boria138 3b9e531eda chore: added minazuki to GameMode conflicts
Code check / Check code (push) Failing after 2m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-07 22:47:05 +05:00
Boria138 822b454bf4 fix: detect EOS overlay registry section case-insensitively
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-07 22:42:45 +05:00
Renovate Bot 77a65d9e81 chore(deps): update archlinux:base-devel docker digest to 84cd9ef
Code check / Check code (push) Successful in 2m44s
2026-09-06 14:56:44 +00:00
Codeberg Translateandadem4ik eee8fb5f0e Translated using Weblate (Russian)
Currently translated at 100.0% (773 of 773 strings)

Co-authored-by: adem4ik <adem4ik@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-09-05 17:10:27 +00:00
Codeberg TranslateandRubin13 4d84d74d70 Translated using Weblate (German)
Currently translated at 100.0% (773 of 773 strings)

Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: Rubin13 <rubin13@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/
Translation: PortProtonQt/PortProtonQt
2026-09-05 17:10:27 +00:00
Boria138 c9fea6e713 feat: add vertical library table layout
Code check / Check code (push) Successful in 1m47s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-05 22:07:19 +05:00
Boria138 b4f39b003b fix: close silent tray after store game exits
Code check / Check code (push) Successful in 2m33s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-05 12:37:51 +05:00
Boria138 f7e44d72c8 feat: add EGS desktop and menu shortcuts
Code check / Check code (push) Successful in 2m44s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-05 12:22:12 +05:00
Boria138 747b85ce2f fix: improve EGS update cancellation and progress display
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-05 12:12:03 +05:00
Boria138 b907b5400c fix: handle EGS launch and cancellation correctly
Code check / Check code (push) Successful in 1m42s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-05 11:53:17 +05:00
Boria138 043031baec fix: preserve log button state on compact layout
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-05 10:40:53 +05:00
Boria138 17e7fa2690 chore(changelog): clean
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-03 19:58:28 +05:00
Boria138 be1e517c68 fix: retranslate composed settings labels
Code check / Check code (push) Successful in 2m45s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-02 23:38:53 +05:00
Boria138 bcd089439e chore: correct desktop MIME associations
Code check / Check code (push) Successful in 2m42s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-02 22:54:50 +05:00
Boria138 cb1b984f04 fix: bundle Bash in AppImage for system GameMode
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-02 22:29:12 +05:00
Boria138 22bf9b2451 feat: apply locale changes without restart
Code check / Check code (push) Successful in 2m55s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-02 12:17:28 +05:00
Boria138 c8ff25dd69 test: mute UI sounds during pytest runs
Code check / Check code (push) Successful in 2m14s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-02 12:08:20 +05:00
Boria138 927531620a fix: bound delayed system adapter polling
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-02 12:05:44 +05:00
Boria138 bb14e1a346 feat: add Proton CachyOS WineLand support
Code check / Check code (push) Successful in 3m9s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-02 11:48:24 +05:00
Boria138 2a5a45fabd fix: retry system adapters after delayed initialization
Code check / Check code (push) Successful in 2m24s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-01 17:36:28 +05:00
Boria138 5e0e5b785e chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-01 17:22:46 +05:00
Codeberg Translateandadem4ik 90c4901e8e Translated using Weblate (Russian)
Currently translated at 100.0% (757 of 757 strings)

Co-authored-by: adem4ik <adem4ik@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-09-01 12:21:12 +00:00
Boria138 c1e76b8e8c feat: add forced English language setting
Code check / Check code (push) Successful in 2m6s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-09-01 17:19:42 +05:00
Gitea Actions 782c9cae6a chore: download stats 2026-09-01T00:01:40Z 2026-09-01 00:01:40 +00:00
Boria138 725c8d371e chore(locale): update
Code check / Check code (push) Successful in 2m19s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-31 11:07:34 +05:00
Boria138 53d79c50d3 feat: add horizontal library layout
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-31 11:07:00 +05:00
Boria138 af8679aa08 fix: refresh Epic library with all sources
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-31 11:06:59 +05:00
Boria138 96a2bd92e8 fix: handle empty settings categories in search
Code check / Check code (push) Successful in 1m30s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-30 19:58:20 +05:00
Renovate Bot 810d09b0ad chore(deps): update https://git.linux-gaming.ru/actions/checkout action to v7
Code check / Check code (push) Successful in 3m6s
2026-08-30 04:31:27 +00:00
Renovate Bot 359ac7d72e fix(deps): update python dependencies
Code check / Check code (pull_request) Successful in 2m39s
Code check / Check code (push) Successful in 1m33s
2026-08-30 00:01:22 +00:00
Boria138 6135f640fd fix: reuse library cache when toggling installed filter
Code check / Check code (push) Successful in 2m29s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-29 22:25:52 +05:00
Boria138 58d85dcf9f feat: add configurable return-to-desktop action
Code check / Check code (push) Successful in 2m32s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-29 21:54:50 +05:00
Boria138 6b614ce0c5 chore(changelog): update
Code check / Check code (push) Successful in 3m52s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-29 21:29:44 +05:00
Boria138 7b563dff32 chore: filter mobile-only Epic games
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-29 21:27:22 +05:00
Boria138 d04e637030 fix: show Epic library refresh progress
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-29 21:27:22 +05:00
Boria138 00a222073f feat: improve library substring search
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-29 21:27:22 +05:00
Codeberg Translateandrogerio-mattos e09c87491f Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (757 of 757 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-29 06:08:47 +00:00
Codeberg TranslateandRubin13 4f5fa36a0d Translated using Weblate (German)
Currently translated at 100.0% (757 of 757 strings)

Co-authored-by: Rubin13 <rubin13@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/
Translation: PortProtonQt/PortProtonQt
2026-08-29 06:08:47 +00:00
Boria138 6e70c92afc fix(appimage): preserve MIME metadata in AppImage
Code check / Check code (push) Successful in 1m42s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-29 11:08:33 +05:00
Boria138 ff568fad1c feat: Added EGS integration
Code check / Check code (push) Failing after 2m32s
Code check / Check code (pull_request) Successful in 2m1s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-29 10:36:06 +05:00
Renovate Bot 254f56fe17 chore(deps): update debian docker tag to trixie-20260824
Code check / Check code (push) Successful in 2m12s
2026-08-28 05:55:59 +00:00
Renovate Bot b0e8345517 fix(deps): update python dependencies
Code check / Check code (pull_request) Successful in 2m29s
Code check / Check code (push) Successful in 3m15s
2026-08-28 05:55:09 +00:00
Renovate Bot 897daf983b chore(deps): update https://git.linux-gaming.ru/actions/checkout digest to d23441a
Code check / Check code (push) Successful in 1m27s
2026-08-27 19:30:30 +00:00
Renovate Bot 2effcf5505 chore(deps): update archlinux:base-devel docker digest to 68bfc3b
Package Build - AppImage, Arch, Fedora, Debian / changes (pull_request) Successful in 16s
Package Build - AppImage, Arch, Fedora, Debian / Build AppImage (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (43) (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (44) (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (45) (pull_request) Skipped
Package Build - AppImage, Arch, Fedora, Debian / Build Fedora RPM (rawhide) (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 (pull_request) Successful in 2m15s
Code check / Check code (push) Successful in 2m42s
2026-08-27 16:47:03 +00:00
Boria138 5aaa7cbc7e fix: register PPDB files as plain text
Code check / Check code (push) Successful in 1m53s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-27 21:11:29 +05:00
Codeberg Translateandadem4ik 6a502711ad Translated using Weblate (Russian)
Currently translated at 100.0% (757 of 757 strings)

Co-authored-by: adem4ik <adem4ik@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-08-27 07:22:20 +00:00
Boria138 4889ad1f48 chore: disable automatic PPDB downloads by default
Code check / Check code (push) Successful in 3m14s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-27 12:21:46 +05:00
Boria138 2d5336bc51 fix: skip invalid Steam cover images
Code check / Check code (push) Successful in 4m37s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-27 11:19:34 +05:00
Boria138 a7fe71558e fix: correct dynamic theme module typing
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-27 11:07:11 +05:00
Boria138 b9de7e40fe chore: added Fedora 45
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-27 11:02:18 +05:00
Boria138 9ad69007ec perf: cache themes for faster live switching
Code check / Check code (push) Failing after 2m25s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-26 18:27:06 +05:00
Boria138 6af8b31a90 feat: apply themes without restarnings
Code check / Check code (push) Successful in 4m21s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-26 18:18:59 +05:00
Boria138 18ae09f4b0 bump to 1.4.1
Code check / Check code (push) Successful in 2m38s
Build AppImage, Arch, Fedora and Debian Packages / Build AppImage (push) Successful in 7m15s
Build AppImage, Arch, Fedora and Debian Packages / Build Arch Package (push) Successful in 2m56s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (43) (push) Successful in 1m48s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (rawhide) (push) Successful in 3m27s
Build AppImage, Arch, Fedora and Debian Packages / Build Debian Package (push) Successful in 8m19s
Build AppImage, Arch, Fedora and Debian Packages / Create and Publish Release (push) Successful in 16s
Build AppImage, Arch, Fedora and Debian Packages / Build Fedora RPM (44) (push) Successful in 1m59s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-08-26 13:06:56 +05:00
Boria138 ba56c92726 Revert "chore: update SLR"
This reverts commit ae1d991286.
2026-08-26 13:03:20 +05:00
Codeberg TranslateandBoria138 b55ffc9d09 Translated using Weblate (Russian)
Currently translated at 100.0% (757 of 757 strings)

Co-authored-by: Boria138 <boria138@noreply.codeberg.org>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-08-24 10:25:55 +00:00
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
246 changed files with 23428 additions and 9100 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
+11 -9
View File
@@ -17,7 +17,7 @@ on:
env:
# Common version, will be used for tagging the release
VERSION: 1.3.1
VERSION: 1.4.1
PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -32,10 +32,10 @@ jobs:
options: --privileged --device /dev/fuse
steps:
- name: Prepare container
run: |
pacman-key --init
pacman -Sy --noconfirm archlinux-keyring
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync patchelf
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7278cbb7c3692fce7241d427614ef7984192f138 # v3
- name: Install CI dependencies
run: pacman -S --noconfirm --needed nodejs npm
- uses: https://git.linux-gaming.ru/actions/checkout@v4
@@ -119,7 +119,7 @@ jobs:
strategy:
matrix:
fedora_version: [42, 43, 44, rawhide]
fedora_version: [43, 44, 45, 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
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
name: Check code
runs-on: ubuntu-latest
container:
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
image: archlinux:base-devel@sha256:4894f5a268c696fad671966f383175a13faf433c9d9c88cdd4e32eaa2d18838b
steps:
- name: Install system dependencies
run: |
@@ -30,7 +30,7 @@ jobs:
pacman -S --noconfirm --needed git nodejs npm mesa libarchive sdl3 pyside6 qt6-svg qt6-imageformats uv
pacman -Scc --noconfirm
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Sync dependencies into venv
run: uv sync --all-extras --dev
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
stats:
runs-on: ubuntu-latest
steps:
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
+2 -2
View File
@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Set up Python
uses: https://git.linux-gaming.ru/actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: https://git.linux-gaming.ru/actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version-file: "pyproject.toml"
+22 -20
View File
@@ -26,7 +26,7 @@ jobs:
arch: ${{ steps.check.outputs.arch }}
debian: ${{ steps.check.outputs.debian }}
steps:
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: github.event_name == 'pull_request'
with:
fetch-depth: 0
@@ -80,19 +80,19 @@ jobs:
needs: changes
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
image: archlinux:base-devel@sha256:4894f5a268c696fad671966f383175a13faf433c9d9c88cdd4e32eaa2d18838b
options: --privileged --device /dev/fuse
steps:
- name: Prepare container
run: |
pacman-key --init
pacman -Sy --noconfirm archlinux-keyring
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync patchelf
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7ff2bdc2fe97f9a4c6a4007f5f42fb4941f49fee # v4
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Install CI dependencies
run: pacman -S --noconfirm --needed nodejs npm
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: github.event_name == 'pull_request'
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: github.event_name == 'workflow_dispatch'
with:
ref: ${{ env.BUILD_BRANCH }}
@@ -126,7 +126,7 @@ jobs:
strategy:
matrix:
fedora_version: [42, 43, 44, rawhide]
fedora_version: [43, 44, 45, 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: |
@@ -147,11 +149,11 @@ jobs:
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout PR
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: github.event_name == 'pull_request'
- name: Checkout branch
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: github.event_name == 'workflow_dispatch'
with:
ref: ${{ env.BUILD_BRANCH }}
@@ -183,7 +185,7 @@ jobs:
needs: changes
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
image: archlinux:base-devel@sha256:4894f5a268c696fad671966f383175a13faf433c9d9c88cdd4e32eaa2d18838b
steps:
- name: Prepare container
@@ -211,11 +213,11 @@ jobs:
chown user -R ..
- name: Checkout PR
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: github.event_name == 'pull_request'
- name: Checkout branch
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: github.event_name == 'workflow_dispatch'
with:
ref: ${{ env.BUILD_BRANCH }}
@@ -249,7 +251,7 @@ jobs:
needs: changes
if: needs.changes.outputs.debian == 'true' || github.event_name == 'workflow_dispatch'
container:
image: debian:trixie-20260623@sha256:d07d1b51c39f51188e60be9b64e6bf769fa94e187f092bc32b91305cfa34ba5a
image: debian:trixie-20260918@sha256:9cc080028c43b27d2074d63a5f9caf7166d731494965616c1a6d2827a004585c
options: --privileged
steps:
@@ -257,15 +259,15 @@ 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
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: github.event_name == 'pull_request'
- name: Checkout branch
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: github.event_name == 'workflow_dispatch'
with:
ref: ${{ env.BUILD_BRANCH }}
+2 -2
View File
@@ -10,10 +10,10 @@ jobs:
runs-on: ubuntu-latest
container: ghcr.io/renovatebot/renovate:latest@sha256:47421f2f3f82048e969b5195dd808d5ac2c5f69d43d192191c4a3dca390afe06
steps:
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Set up Node.js
uses: https://git.linux-gaming.ru/actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
uses: https://git.linux-gaming.ru/actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 20
+2
View File
@@ -43,3 +43,5 @@ portprotonqt/locales/*/LC_MESSAGES/*.mo
# AI
.codex
CODE_QUALITY_PLAN.md
.repowise
+1 -1
View File
@@ -1 +1 @@
3.10
3.11
+149 -22
View File
@@ -1,7 +1,7 @@
# PortProtonQt — AI Agent Guidelines
**Project:** PortProtonQt — GUI for PortProton, Steam, Epic Games Store
**Language:** Python 3.10+
**Project:** PortProtonQt — Game library manager and launcher for Linux
**Language:** Python 3.11+
**Platform:** Linux (POSIX)
**License:** GPL-3.0
**Build:** Meson + uv
@@ -78,6 +78,31 @@ Example:
---
## Commit Classification
Use Conventional Commit prefixes according to the primary purpose of the change:
| Prefix | Use for |
|--------|---------|
| `feat` | A new user-facing capability or supported workflow |
| `fix` | A correction for a confirmed defect or regression in existing behavior |
| `chore` | Maintenance, integration adjustments, filtering, metadata, or routine upkeep that is neither a feature nor a confirmed bug fix |
| `refactor` | Internal restructuring with no intended behavior change |
| `perf` | A measurable performance improvement |
| `test` | Test-only changes |
| `docs` | Documentation-only changes |
| `build` | Build system or dependency changes |
| `ci` | CI or automation configuration changes |
| `style` | Formatting-only changes with no behavior change |
| `revert` | Reverting an earlier commit |
- Classify by intent, not merely by whether code behavior changed.
- Do not default to `fix` for every behavioral correction or edge-case filter.
- Use `fix` only when the task identifies a bug/regression or the defect is clearly demonstrated.
- If `feat`, `fix`, and `chore` are ambiguous, prefer `chore` unless the task clearly establishes a new capability or confirmed defect.
---
## Forbidden Patterns
```python
@@ -277,6 +302,9 @@ Do not delete or disable caching or downloaders to satisfy this rule. Caching is
- No heavy operations in paintEvent
- Use methods instead of lambda for complex logic
- Use QThread for long tasks
- Retain every background worker that can outlive its caller in an owner field or collection until it finishes
- Register window-owned workers and retained worker collections in the application shutdown path; cancel when supported and wait before interpreter exit
- Add or update shutdown regression tests when adding or changing a background worker, including executor-backed workers
---
@@ -304,7 +332,7 @@ Do not assume conventions. Never invent behavior.
```bash
# Setup
uv python install 3.10
uv python install 3.11
uv sync
source .venv/bin/activate
pre-commit install --install-hooks
@@ -387,13 +415,16 @@ tests/
├── test_base_config.py # BaseConfig read/write, caching, versioning
├── test_cli.py # normalize_launch_path, URL/resolution parsing
├── test_debug_env_utils.py # Debug environment helpers, runtime variables
├── test_easyterm.py # Terminal final output, exit status, keyboard and gamepad closing
├── test_input_manager.py # Gamepad input navigation and focus regressions
├── test_sound_manager.py # UI sound playback, widget events, gamepad connection, theme sound files
├── test_main_window.py # Main window data processing and callback regressions
├── test_store_games.py # GOG and EGS library, launch, install, and stop regressions
├── test_portproton_config.py # exec_line parsing, launcher tail, extensions
├── 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 +432,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 +459,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 +475,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 | — |
---
@@ -463,6 +497,7 @@ pre-commit run pytest
- [ ] Prefer dedicated functions for subprocess calls when practical within task scope
- [ ] No shared mutable global state (except logger and explicit cache/session infrastructure)
- [ ] No blocking calls introduced in the UI thread
- [ ] Background workers have explicit ownership and shutdown cleanup with regression coverage
- [ ] **No hardcoded styles or constants (use theme constants)**
- [ ] **New constants added to theme files only**
@@ -874,21 +909,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 +1073,6 @@ PortProtonQt/
---
**Last updated:** 2026-07-20
**Version:** 1.2
**Last updated:** 2026-08-11
**Version:** 1.3
**Status:** Release
+129
View File
@@ -5,21 +5,129 @@
## [Unreleased]
### Added
- Добавлены выбор и установка DLC при установке игр GOG
- При установке GOG в папку с уже имеющейся игрой добавлены автоматический импорт, проверка файлов и загрузка недостающих или повреждённых файлов
- Добавлена возможность приостанавливать и возобновлять запущенную игру через системный трей
- Добавлена настройка отключения проверки i586-зависимостей в ALT Linux
- Добавлена настройка принудительного применения английского языка
- Добавлено редактирование названия и обложки игр GOG по ID
- Добавлена интеграция Epic Games Store с авторизацией, библиотекой, установкой, импортом, восстановлением и удалением игр
- Добавлена поддержка сборок Proton CachyOS WineLand
- Добавлена кнопка возврата на рабочий стол в системной вкладке при указании команды в `PORTPROTONQT_RETURN_TO_DESKTOP_SCRIPT`
- Добавлены режимы библиотеки `horizontal` и `horizontal_top` с циклической навигацией и плиткой открытия полной сетки в топ-режиме
- В панель библиотеки добавлен выбор типа сетки
- В виджет с исчезающей полосой прокрутки добавлена поддержка горизонтальной ориентации
- Добавлен режим библиотеки `vertical` с табличным отображением названия, последнего запуска, проведённого времени и источника игры
- При открытии карточки с отсутствующим исполняемым файлом добавлен выбор: удалить карточку или указать другой файл
- Добавлен отдельный раздел настройки шрифтов MangoHud с масштабом, размером и выбором шрифта
- Добавлен графический редактор глобальных настроек игр из `user.conf`
### Changed
- После завершения загрузки показывается системное уведомление, а строка результата открывает карточку игры мышью или кнопкой A геймпада
- Улучшено определение вылетов игр: учитывается код завершения команды Wine/Proton и время работы игровых процессов, включая дочерний движок, без учёта времени завершения скрипта запуска
- Каталоги пользовательских данных игр переведены на единый формат `источник-ID`
- Данные приложения организованы в `$XDG_DATA_HOME/PortProtonQt` (по умолчанию `~/.local/share/PortProtonQt`): кеш перенесён из `.cache` в `cache`, данные лаунчеров — в `launcher`, загружаемые бинарники — в `bin`, чтобы временный кеш AnyLinux не вызывал повторную загрузку при каждом запуске AppImage
- Минимальная поддерживаемая версия Python повышена с 3.10 до 3.11
- Поиск в библиотеке теперь находит совпадения в любой части названия и показывает одноимённые игры из разных источников
- Тема теперь применяется без перезапуска приложения
- Добавлена сборка Fedora 45
- Автоматическая загрузка PPDB с linux-gaming.ru теперь отключена по умолчанию
- Кнопка «Открыть PPDB» переименована в «Редактировать PPDB», чтобы не путать открытие файла с его запуском
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
- Обновлён перевод на Испанский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/es/`
- В AppImage добавлен Bash для предотвращения конфликтов Vulkan со Steam Runtime при использовании системного GameMode
- Удалена ассоциация PortProtonQt с исполняемыми файлами Linux
- MIME-тип образов MDS заменён на используемый libMirage `application/x-mds`
- Открытие карточек и тихий запуск унифицированы
- `minazuki` добавлен в список конфликтующих с GameMode процессов
- Встроенный терминал после ошибки остаётся открытым и закрывается клавишей Enter или кнопкой A геймпада
- В список советов при вылете добавлено отключение Optiscaler
### Fixed
- Исправлены создание и восстановление `.desktop`-файлов с переносами строк в названии игры
- Исправлено появление дубля карточки при добавлении игры
- Исправлено отсутствие новых `.exe` в поиске библиотеки до обновления сетки
- Исправлено обновление названия на детальной странице после редактирования ярлыка без повторного открытия страницы
- Исправлено отображение дополнений и наборов GOG как отдельных игр в библиотеке
- Исправлено отсутствие установленных демоверсий, модов, приложений и бета-версий Steam в библиотеке
- Исправлена синтаксическая ошибка в `functions_helper`, препятствовавшая запуску PortProton с Bash 5.1
- Исправлено управление интерфейсом при одновременном подключении нескольких геймпадов
- Исправлена коллизия пользовательских данных автоустановок с одинаковыми именами исходных исполняемых файлов
- Исправлен поиск скриптов и локалей через `XDG_DATA_DIRS` в NixOS
- Исправлена проверка и установка недостающих i586-зависимостей в ALT Linux перед запуском игр
- Исправлено игнорирование проверки 32-битных пакетов при запуске игр GOG
- Исправлено отсутствие иконки у окна встроенного терминала
- Исправлена потеря финального вывода при ошибке встроенного терминала
- Исправлено открытие карточек GOG через URI при старте приложения
- Исправлен сброс состояния кнопки записи лога при переключении детальной страницы в компактный режим
- Исправлено отображение значка PortProtonQt в системном трее при медленной инициализации приложения
- Исправлено отсутствие вкладок Wi-Fi и Bluetooth при запуске до инициализации системных адаптеров
- Исправлена ошибка, из-за которой не работал поиск по настройкам при отсутствии доступных параметров MangoHud или Gamescope
- Фильтр установленных игр теперь переключается без повторной загрузки библиотеки
- Некорректные изображения в кэше Steam теперь пропускаются при поиске обложек
- Исправлено открытие `.ppdb` на разделах с битом исполнения у всех файлов
- Исправлено создание резервной копии префикса с `libarchive-c 5.1`
- Исправлено завершение приложения при активных фоновых операциях
### Contributors
- @rogerio-mattos
- @Rubin13
- @adem4ik
- @gallegonovato
---
## [1.4.1] - 2026-08-26
### Changed
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
### Fixed
- Произведён откат Steam Runtime Sniper до версии V3 из-за несовместимости GStreamer внутри Proton 10.x с SLR 4
### 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
- Вертикальная навигация геймпадом в сетках настроек больше не переходит между столбцами
- Удалена сборка для Fedora 42
- Версия Windows по умолчанию изменена с 10 на 11
- Обновлены все иконки подсказок управления
- API и ссылки PPDB переведены с `ppdb.linux-gaming.ru` на `linux-gaming.ru`
- Блок игровой статистики на полной детальной странице помещён в тематизируемый контейнер, как в компактном режиме
- Кэш EXE-иконок унифицирован для карточек и детальных страниц
@@ -33,6 +141,18 @@
- `sherrydmn` добавлен в список конфликтующих с GameMode процессов
### Fixed
- Настройки прокси теперь передаются `curl` при загрузке файлов скриптами PortProton
- Устранена повторная перестройка индексов Steam и PPDB для каждой карточки в экономном режиме
- Скрытие бейджей больше не скрывает ленту источника игры на карточках библиотеки
- Обновление сетки библиотеки теперь загружает только источник, выбранный одиночным фильтром
- Исправлено обрезание кнопок настроек PortProtonQt в узких окнах
- Исправлено удаление даты последнего запуска при удалении ярлыка игры
- Исправлено извлечение иконок для лаунчеров, запускающих целевой EXE через соседний `.bat` или `.cmd`
- Исправлено зависание интерфейса при проверке RPM-пакетов перед запуском игры
- Исправлено обрезание подсказок управления при переносе на вторую строку
- Исправлены подлагивания интерфейса при загрузке списка WINE/Proton и подсчёте размеров установленных версий
- Исправлена перекраска иконок встроенного файлового менеджера через `ICON_COLORS`
- Исправлено применение длительности fade-анимации к появлению обложки при анимациях `slide` и `bounce` детальной страницы
- Исправлено игнорирование параметров темы для размеров, отступов, скруглений и тени карточек в сеточном и списочном режимах
- Исправлены медленная загрузка превью и повторные сетевые запросы при прокрутке магазина тем
- Исправлен непоследовательный порядок навигации по карточкам игр с помощью D-pad
@@ -43,14 +163,23 @@
- Исправлено состояние кнопки остановки при переключении детальной страницы на компактный вид
- Исправлено зависание интерфейса после завершения игры
- Исправлено определение основного аккаунта Steam по самому новому `Timestamp` в `loginusers.vdf` с резервным поиском по `AutoLogin` или единственному профилю `userdata` и поддержкой `MostRecent` для совместимости со старыми версиями Steam
- Распаковка WINE/Proton больше не меняет рабочий каталог приложения и отклоняет пути из архива, выходящие за каталог установки
### Contributors
- @Dervart
- @Rubin13
- @rogerio-mattos
- @Vekt0R_null
---
## [1.3.2] - 2026-08-10
### Fixed
- Исправлено определение SteamID по `AutoLogin` или единственному профилю `userdata`, если в `loginusers.vdf` отсутствует `MostRecent`
---
## [1.3.1] - 2026-07-18
### Fixed
+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;
-436
View File
@@ -15,439 +15,3 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
We make use of various projects, some of which require their license to be preserved. Here's their respective licenses:
-----------------------------------------------------------------------------------------------------------------------
===============
= icoextract: =
===============
MIT License
Copyright (c) 2015-2016 Fadhil Mandaga
Copyright (c) 2019-2025 James Lu <james@overdrivenetworks.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
===============
= portproton: =
===============
MIT License
Copyright (c) 2025 Mikhail Tergoev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
===============================
= HowLongToBeat-Python-API : =
===============================
MIT License
Copyright (c) 2020 JaeguKim
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
===============================
= mdf2iso : =
===============================
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Library General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Library General
Public License instead of this License.
=====================================================================
===========================
= Kenney Interface Sounds =
===========================
Interface Sounds (1.0)
Created/distributed by Kenney (www.kenney.nl)
License: Creative Commons Zero, CC0
http://creativecommons.org/publicdomain/zero/1.0/
This content is free to use in personal, educational and commercial projects.
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
+9 -6
View File
@@ -5,14 +5,14 @@
<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-12.8k-green?style=flat-square" alt="Total Downloads"></a>
<p align="center">A modern and convenient interface for managing and launching games from PortProton, Steam, Epic Games Store, and GOG. It brings libraries together in one place and simplifies launching Windows games on Linux.</p>
</div>
### Installation (devel)
```sh
uv python install 3.10
uv python install 3.11
uv sync
source .venv/bin/activate # For bash/zsh
# or
@@ -29,7 +29,7 @@ Launch using the `portprotonqt` command or via the shortcut in your application
### Development
To automatically prepare the environment (install Python 3.10, dependencies, pre-commit hooks, and generate translations), run the following script:
To automatically prepare the environment (install Python 3.11, dependencies, pre-commit hooks, and generate translations), run the following script:
```sh
./dev-scripts/prepare_env.sh
@@ -65,13 +65,16 @@ PortProtonQt uses code and dependencies from the following projects:
- [Icoextract](https://github.com/jlu5/icoextract) — icon extraction library, [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE) license.
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — library for interacting with HowLongToBeat, [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md) license.
- [iat](https://sourceforge.net/projects/iat.berlios) — library for converting mdf and nrg to iso, GPLv3 license.
- [Iso9660 Analyzer Tool (IAT)](https://sourceforge.net/projects/iat.berlios/) — library for converting mdf and nrg to iso, GPLv2 license.
- [heroic-gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl) — GOG downloading module, [GPLv3](https://github.com/Heroic-Games-Launcher/heroic-gogdl/blob/main/LICENSE) license.
- [Legendary](https://github.com/Heroic-Games-Launcher/legendary) — Epic Games launcher alternative, [GPLv3](https://github.com/Heroic-Games-Launcher/legendary/blob/master/LICENSE) license.
- [pyte](https://github.com/selectel/pyte) — ANSI escape code parser, [LGPLv3](https://github.com/selectel/pyte?tab=LGPL-3.0-1-ov-file) license.
- [gjs-osk](https://github.com/Vishram1123/gjs-osk) — base for virtual keyboard layout data, GPLv3 license.
- [omikuji](https://github.com/omikuji-launcher/omikuji) — inspiration and source material for the procedural detail page backgrounds, [GPLv3](https://github.com/omikuji-launcher/omikuji/blob/master/LICENSE) license.
- [Bottles](https://github.com/bottlesdevs/Bottles) — source of the compatibility analysis rules, GPL-3.0-only license.
See the [LICENSE](LICENSE) file for the full text of the licenses.
PortProtonQt is licensed under the terms in [LICENSE](LICENSE). See
[THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES) for third-party licenses.
> [!WARNING]
> **Be careful!** If you are using a theme not from the official repository or a trusted source, make sure its `styles.py` file does not contain malicious or unwanted code. Since `styles.py` is a regular Python file, it can contain any instructions. Always check the contents of third-party themes before use.
+9 -6
View File
@@ -5,14 +5,14 @@
<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-12.8k-green?style=flat-square" alt="Всего загрузок"></a>
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam, Epic Games Store и GOG. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.</p>
</div>
### Установка (devel)
```sh
uv python install 3.10
uv python install 3.11
uv sync
source .venv/bin/activate # For bash/zsh
# or
@@ -29,7 +29,7 @@ source .venv/bin/activate.fish # For fish
### Разработка
Для автоматической подготовки окружения (установка Python 3.10, зависимостей, pre-commit хуков и генерация переводов) выполните скрипт:
Для автоматической подготовки окружения (установка Python 3.11, зависимостей, pre-commit хуков и генерация переводов) выполните скрипт:
```sh
./dev-scripts/prepare_env.sh
@@ -65,13 +65,16 @@ PortProtonQt использует код и зависимости от след
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
- [iat](https://sourceforge.net/projects/iat.berlios) — библиотека для конвертации mdf и nrg в iso, лицензия GPLv3
- [Iso9660 Analyzer Tool (IAT)](https://sourceforge.net/projects/iat.berlios/) — библиотека для конвертации mdf и nrg в iso, лицензия GPLv2
- [heroic-gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl) — модуль загрузки игр GOG, лицензия [GPLv3](https://github.com/Heroic-Games-Launcher/heroic-gogdl/blob/main/LICENSE).
- [Legendary](https://github.com/Heroic-Games-Launcher/legendary) — альтернатива Epic Games Launcher, лицензия [GPLv3](https://github.com/Heroic-Games-Launcher/legendary/blob/master/LICENSE).
- [pyte](https://github.com/selectel/pyte) — разбор ANSI escape-кодов, лицензия [LGPLv3](https://github.com/selectel/pyte?tab=LGPL-3.0-1-ov-file)
- [gjs-osk](https://github.com/Vishram1123/gjs-osk) — основа данных раскладок виртуальной клавиатуры, лицензия GPLv3.
- [omikuji](https://github.com/omikuji-launcher/omikuji) — источник вдохновения и материалов для процедурных фонов детальной страницы, лицензия [GPLv3](https://github.com/omikuji-launcher/omikuji/blob/master/LICENSE).
- [Bottles](https://github.com/bottlesdevs/Bottles) — источник правил анализа совместимости, лицензия GPL-3.0-only.
Полный текст лицензий см. в файле [LICENSE](LICENSE).
Условия лицензии PortProtonQt приведены в файле [LICENSE](LICENSE). Лицензии
сторонних проектов приведены в файле [THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES).
> [!WARNING]
> **Будьте осторожны!** Если вы берёте тему не из официального репозитория или надёжного источника, убедитесь, что в её файле `styles.py` нет вредоносного или нежелательного кода. Поскольку `styles.py` — это обычный Python-файл, он может содержать любые инструкции. Всегда проверяйте содержимое чужих тем перед использованием.
+1278
View File
File diff suppressed because it is too large. Load diff
+1 -4
View File
@@ -52,7 +52,6 @@ while [ $# -gt 0 ]; do
done
ARCH="$(uname -m)"
EXTRA_PACKAGES="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/get-debloated-pkgs.sh"
if [ "$LOCAL_MODE" = true ]; then
echo "Using local PKGBUILD-git from repository..."
@@ -104,9 +103,7 @@ makepkg -si --noconfirm
echo "Installing debloated packages..."
echo "---------------------------------------------------------------"
wget --retry-connrefused --tries=30 "$EXTRA_PACKAGES" -O ./get-debloated-pkgs.sh
chmod +x ./get-debloated-pkgs.sh
./get-debloated-pkgs.sh --add-common --prefer-nano
get-debloated-pkgs --add-common --prefer-nano
if [ "$LOCAL_MODE" = true ]; then
# For git version, we use portprotonqt-git
+17 -9
View File
@@ -2,7 +2,6 @@
set -eu
SHARUN="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/quick-sharun.sh"
ARCH="$(uname -m)"
VERSION="$(cat ~/version)"
export ARCH VERSION
@@ -20,16 +19,12 @@ export OPTIMIZE_LAUNCH=1
# Adjust comp settings to bypass oom-killer
export DWARFS_COMP="zstd:level=15 -S22 -B5"
# DEPLOY ALL LIBS
wget --retry-connrefused --tries=30 "$SHARUN" -O ./quick-sharun
chmod +x ./quick-sharun
# Add udev rules
mkdir -p ./AppDir/etc/udev/rules.d
cp /usr/lib/udev/rules.d/60-portprotonqt.rules ./AppDir/etc/udev/rules.d
# Add PortProton scripts
# Copy manual because im to lazzy for wait ./quick-sharun strace 208 .ppdb
# Copy manually to avoid tracing 208 .ppdb files with quick-sharun
mkdir -p ./AppDir/share
cp -r /usr/share/portproton ./AppDir/share
@@ -39,15 +34,28 @@ pacman -Rdd --noconfirm libpipewire qt6-multimedia-ffmpeg
# Deploy dependencies
# Qt libs have to be passed manually due to the app being a python script
./quick-sharun \
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/bash \
/usr/bin/update-desktop-database \
/usr/bin/update-mime-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*
# quick-sharun removes MIME package XML files during dependency deployment
mkdir -p ./AppDir/share/mime/packages
cp /usr/share/mime/packages/ru.linux_gaming.PortProtonQt.xml \
./AppDir/share/mime/packages
# DEPLOY_PYTHON copies the distro's complete Python installation. Remove
# build/development content and PySide bindings not imported by PortProtonQt.
@@ -90,4 +98,4 @@ done
rm -rf "$PYSIDE_DIR"/QtAsyncio
# Turn AppDir into AppImage
./quick-sharun --make-appimage
quick-sharun --make-appimage
+2 -2
View File
@@ -1,8 +1,8 @@
pkgname=('portprotonqt')
pkgver=1.3.1
pkgver=1.4.1
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.1
%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.1
%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.
@@ -11,4 +11,4 @@ Terminal=false
Icon=ru.linux_gaming.PortProtonQt
StartupWMClass=ru.linux_gaming.PortProtonQt
Categories=Game;Utility;
MimeType=x-scheme-handler/portproton;x-scheme-handler/portprotonqt;application/x-portproton-prefix-backup;application/x-portproton-autoinstall;application/x-ms-dos-exec;application/x-ms-dos-executable;application/x-msdos-program;application/vnd.microsoft.portable-executable;application/x-ms-dos-batch;application/x-bat;application/x-msi;text/x-ms-regedit;application/x-executable;application/x-dosexec;application/x-iso9660-image;application/x-cd-image;application/x-raw-disk-image;application/x-mdf;application/x-mds-disk;application/x-nrg;
MimeType=x-scheme-handler/portproton;x-scheme-handler/portprotonqt;application/x-portproton-prefix-backup;application/x-portproton-autoinstall;application/x-ms-dos-exec;application/x-ms-dos-executable;application/x-msdos-program;application/vnd.microsoft.portable-executable;application/x-ms-dos-batch;application/x-bat;application/x-msi;text/x-ms-regedit;application/x-dosexec;application/x-iso9660-image;application/x-cd-image;application/x-raw-disk-image;application/x-mdf;application/x-mds;application/x-nrg;
@@ -16,5 +16,5 @@ Icon=ru.linux_gaming.PortProtonQt
StartupWMClass=ru.linux_gaming.PortProtonQt
Categories=Game;
NoDisplay=true
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-executable;application/x-dosexec;application/vnd.microsoft.portable-executable;
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-dosexec;application/vnd.microsoft.portable-executable;
StartupNotify=true
@@ -16,5 +16,5 @@ Icon=ru.linux_gaming.PortProtonQt
StartupWMClass=ru.linux_gaming.PortProtonQt
Categories=Game;
NoDisplay=true
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-executable;application/x-dosexec;application/vnd.microsoft.portable-executable;
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-dosexec;application/vnd.microsoft.portable-executable;
StartupNotify=true
@@ -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,18 @@
</developer>
<releases>
<release version="1.3.1" date="2026-07-17">
<release version="1.4.1" date="2026-08-26">
<description></description>
</release>
<release version="1.4.0" date="2026-08-23">
<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>
@@ -10,4 +10,16 @@
<comment xml:lang="ru">Скрипт автоустановки PortProton</comment>
<glob pattern="*.ppai"/>
</mime-type>
<mime-type type="application/x-portproton-ppdb">
<comment>PortProton configuration</comment>
<comment xml:lang="ru">Конфигурация PortProton</comment>
<sub-class-of type="text/plain"/>
<glob pattern="*.ppdb" weight="100"/>
</mime-type>
<mime-type type="application/x-portproton-user-conf">
<comment>PortProton user configuration</comment>
<comment xml:lang="ru">Пользовательская конфигурация PortProton</comment>
<sub-class-of type="text/plain"/>
<glob pattern="user.conf" weight="100"/>
</mime-type>
</mime-info>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

@@ -23,6 +23,29 @@ python_module () {
python3 -m portprotonqt.scripts_utils.$python3_module "$@"
}
pw_alt_i586_dependencies () {
local action="${1:-check}" installed list package
installed="$(rpm -qa --qf '%{NAME}\n')" || return 2
list=""
for package in glibc-nss glibc-gconv-modules libnm libvulkan1 libfreetype \
$(grep '^libnss-' <<< "$installed" | grep -v '^libnss-fallback$') \
$(grep '^xorg-dri-' <<< "$installed"); do
if grep -Fqx "$package" <<< "$installed" \
&& ! grep -Fqx "i586-$package" <<< "$installed" ; then
list+=" i586-$package"
fi
done
if [[ "$action" == "check" ]] ; then
[[ -z "$list" ]]
return $?
fi
[[ -z "$list" ]] && return 0
apt-get update && apt-get install -y $list
}
python_term () {
print_info "Running: easyterm $@"
python_module easyterm -e "$@"
@@ -135,8 +158,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 +484,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 +527,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 +536,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 +588,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
@@ -1084,6 +1087,7 @@ EOF
-e '/export PW_SOUND_DRIVER_USE=/d' \
"$USER_CONF"
fi
chmod 644 "${USER_CONF}"
# shellcheck source=/dev/null
source "$USER_CONF"
@@ -2001,7 +2005,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
@@ -2194,8 +2198,8 @@ manage_user_conf_value () {
local var_name="$2"
local var_value="$3"
if [[ -z "${action}" ]] || [[ -z "${var_name}" ]]; then
print_error "Action and variable name must be provided"
if [[ -z "${action}" ]]; then
print_error "Action must be provided"
return 1
fi
@@ -2206,6 +2210,11 @@ manage_user_conf_value () {
case "$action" in
get)
if [[ -z "${var_name}" ]]; then
grep -E '^export [A-Za-z_][A-Za-z0-9_]*=' "${USER_CONF}" 2>/dev/null \
| sed -E 's/^export //' || true
return 0
fi
if grep -q "^export ${var_name}=" "${USER_CONF}" ; then
# Extract the value, handling both quoted and unquoted values
local value=$(grep "^export ${var_name}=" "${USER_CONF}" | sed -E 's/^export [^=]+=("?)(.*)\1$/\2/')
@@ -2217,6 +2226,10 @@ manage_user_conf_value () {
fi
;;
set)
if [[ -z "${var_name}" ]]; then
print_error "Variable name must be provided"
return 1
fi
if [[ -z "${var_value}" ]]; then
print_error "Variable value must be provided for set action"
return 1
@@ -2227,6 +2240,10 @@ manage_user_conf_value () {
return 0
;;
delete)
if [[ -z "${var_name}" ]]; then
print_error "Variable name must be provided"
return 1
fi
if grep -q "^export ${var_name}=" "${USER_CONF}" ; then
sed -i "/^export ${var_name}=/d" "${USER_CONF}"
print_info "Removed ${var_name} from USER.conf"
@@ -2311,7 +2328,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 +2341,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
}
@@ -2938,6 +2955,9 @@ start_portproton () {
if [[ "${PW_USE_NVAPI_AND_DLSS}" == "1" ]] ; then
export DXVK_ENABLE_NVAPI="1"
if [[ -f "${WINEDIR}/share/dxvk-nvapi-vkreflex-layer/implicit_layer.d/VkLayer_DXVK_NVAPI_reflex.json" ]] ; then
PW_VKREFLEX=1
fi
if echo "$LSPCI_VGA" | grep -i -q 'nvidia' ; then
FIND_NVNGX="$(dirname $(find /usr/* -type f -name "nvngx.dll" 2>/dev/null | head -n 1 | awk '{print $1}'))"
if [[ -n "$FIND_NVNGX" ]] ; then
@@ -2949,6 +2969,7 @@ start_portproton () {
fi
else
export DXVK_ENABLE_NVAPI="0"
unset PW_VKREFLEX DXVK_NVAPI_VKREFLEX
fi
if [[ "${PW_HEAP_DELAY_FREE}" == 1 ]]
@@ -3014,7 +3035,7 @@ start_portproton () {
elif [[ "$PW_USE_GAMEMODE" = "1" ]] \
&& [[ -n "$DBUS_SESSION_BUS_ADDRESS" ]]
then
if pw_is_process_active ananicy ananicy-cpp scx scx_loader falcond sherrydmn ; then
if pw_is_process_active ananicy ananicy-cpp scx scx_loader falcond sherrydmn minazuki ; then
export GAMEMODERUN=0
export PW_GAMEMODERUN_SLR=""
local current_power_profile
@@ -3226,7 +3247,7 @@ start_portproton () {
# DGVOODOO2 enable:
if [[ "${PW_DGVOODOO2}" == "1" ]] \
&& [[ "${PW_USE_SPECIALK}" != "1" ]] \
&& [[ "${PW_USE_D7VK}" != "1" ]] \
&& [[ "${PW_USE_D7VK}" != "1" ]]
then
if [[ ! -f "${PW_VULKAN_DIR}/dgvoodoo2-${DGV2_VER}/dgVoodoo.conf" ]] ; then
print_info "Download and install DGVOODOO2 v.${DGV2_VER}"
@@ -3796,6 +3817,7 @@ start_portproton () {
}
pw_run () {
local pw_game_exit_code
if [[ "$PW_USE_TERMINAL" == 1 ]] \
&& [[ "${PRESSURE_VESSEL_TERMINAL:-}" != "tty" ]]
then
@@ -3862,6 +3884,7 @@ pw_run () {
${PW_GAMEMODERUN_SLR} \
${PW_ADD_VAR_SLR} \
"${WINELOADER}" "$@" ${proxy_launch_parameters} &>>"${PW_LOG_FILE}"
pw_game_exit_code=$?
else
if [[ "$PW_USE_TERMINAL" == 1 ]] ; then
pw_init_runtime
@@ -3882,6 +3905,7 @@ pw_run () {
${PW_GAMEMODERUN_SLR} \
${PW_ADD_VAR_SLR} \
"${WINELOADER}" "$@" ${proxy_launch_parameters} &>>"${PW_LOG_FILE}"
pw_game_exit_code=$?
print_info "Update prefix log:"
[[ -f "${PW_TMPFS_PATH}/update_pfx_log" ]] && cat "${PW_TMPFS_PATH}/update_pfx_log"
echo
@@ -3911,6 +3935,7 @@ pw_run () {
${PW_INHIBIT_SLR} \
${PW_TASKSET_SLR} \
"${WINELOADER}" "$@" ${proxy_launch_parameters} &>>"${PW_LOG_FILE}"
pw_game_exit_code=$?
wait_wineserver
else
print_info "Update prefix log:"
@@ -3931,9 +3956,12 @@ pw_run () {
${PW_INHIBIT_SLR} \
${PW_TASKSET_SLR} \
"${WINELOADER}" "$@" ${proxy_launch_parameters}
pw_game_exit_code=$?
wait_wineserver
fi
fi
printf 'PORTPROTONQT_GAME_EXIT_CODE=%s\n' "$pw_game_exit_code"
return "$pw_game_exit_code"
}
export -f pw_run
@@ -4021,6 +4049,10 @@ steamplay_launch () {
else
export PW_VK_LAYER_PATH="${PW_PLUGINS_PATH}/portable/share/vulkan/implicit_layer.d"
fi
if [[ "${PW_VKREFLEX}" == "1" ]] ; then
export DXVK_NVAPI_VKREFLEX="1"
export PW_VK_LAYER_PATH="${PW_VK_LAYER_PATH:+${PW_VK_LAYER_PATH}:}${WINEDIR}/share/dxvk-nvapi-vkreflex-layer/implicit_layer.d"
fi
pw_mangohud_check
pw_vkbasalt_check
@@ -4089,6 +4121,7 @@ steamplay_launch () {
fi
print_info "$proton_runner in use."
printf '%s\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'
pw_launch_wrapper \
${PW_RUN_GAMESCOPE} \
env \
@@ -4098,6 +4131,7 @@ steamplay_launch () {
${PW_GAMEMODERUN_SLR} \
${PW_TASKSET_SLR} \
"${proton_runner}" waitforexitandrun "${PW_EXE_FILE}" "$@"
printf 'PORTPROTONQT_GAME_EXIT_CODE=%s\n' "$?"
if [[ $PW_LOG != 1 ]] && [[ -n $START_PW_TIME_IN_GAME ]] ; then
debug_timer --end -s "PW_TIME_IN_GAME"
PW_TIME_IN_GAME=$(( PW_TIME_IN_GAME / 1000 ))
@@ -4114,6 +4148,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 +4248,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.
+20 -14
View File
@@ -6,6 +6,19 @@ export url_git="https://git.linux-gaming.ru/CastroFidel/PortWINE"
########################################################################
$PW_DEBUG
if [[ "$(realpath "$0")" == "/usr/share/portproton/scripts/start.sh" ]] ; then
PORT_SCRIPTS_PATH="/usr/share/portproton/scripts/"
else
PORT_SCRIPTS_PATH="$(dirname "$(realpath "$0")")"
fi
if [[ "${1:-}" == "cli" ]] \
&& [[ "${2:-}" == "--alt-i586-dependencies" ]] ; then
source "$PORT_SCRIPTS_PATH/functions_helper"
pw_alt_i586_dependencies "${3:-}"
exit $?
fi
if [[ $(id -u) = 0 ]] \
&& [[ ! -e "/userdata/system/batocera.conf" ]]
then
@@ -13,13 +26,6 @@ then
exit 1
fi
if [[ "$(realpath "$0")" == "/usr/share/portproton/scripts/start.sh" ]] ; then
PORT_SCRIPTS_PATH="/usr/share/portproton/scripts/"
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 +61,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 +154,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 +162,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 +170,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"
@@ -267,7 +273,7 @@ case "$1" in
--launch Launches the application immediately, requires the path to the .exe file
--edit-db After the variable, the path to the .exe file is required and then the variables.
(List their variables and values for example PW_MANGOHUD=1 PW_VKBASALT=0, etc.)
--get-user-conf Get a value from user.conf file, requires variable name
--get-user-conf Get one or all values from user.conf file
--set-user-conf Set a value in user.conf file, requires variable name and value
--del-user-conf Delete a value from user.conf file, requires variable name
--list-db List all available database variables
@@ -472,7 +478,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
;;
+1 -1
View File
@@ -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"
+228
View File
@@ -0,0 +1,228 @@
#include <SDL3/SDL.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#define AXIS_ACTIVATION_THRESHOLD 16000
typedef struct {
SDL_Gamepad *gamepad;
SDL_JoystickID instance_id;
int sdl_type;
bool buttons[SDL_GAMEPAD_BUTTON_COUNT];
bool axes_active[SDL_GAMEPAD_AXIS_COUNT];
} GamepadEntry;
typedef struct {
GamepadEntry *entries;
int count;
SDL_JoystickID active_instance_id;
} PortProtonGamepad;
static bool has_gamepad(const PortProtonGamepad *gamepads,
SDL_JoystickID instance_id)
{
for (int index = 0; index < gamepads->count; index++) {
if (gamepads->entries[index].instance_id == instance_id) {
return true;
}
}
return false;
}
static bool add_gamepad(PortProtonGamepad *gamepads,
SDL_JoystickID instance_id)
{
SDL_Gamepad *gamepad = SDL_OpenGamepad(instance_id);
if (gamepad == NULL) {
return false;
}
GamepadEntry *entries = realloc(
gamepads->entries, sizeof(*entries) * (gamepads->count + 1));
if (entries == NULL) {
SDL_CloseGamepad(gamepad);
return SDL_SetError("Failed to allocate gamepad handle");
}
gamepads->entries = entries;
GamepadEntry entry = {0};
entry.gamepad = gamepad;
entry.instance_id = instance_id;
entry.sdl_type = SDL_GetGamepadTypeForID(instance_id);
entries[gamepads->count] = entry;
gamepads->count++;
return true;
}
static void remove_disconnected(PortProtonGamepad *gamepads)
{
int active_count = 0;
for (int index = 0; index < gamepads->count; index++) {
GamepadEntry entry = gamepads->entries[index];
if (SDL_GamepadConnected(entry.gamepad)) {
gamepads->entries[active_count++] = entry;
} else {
if (gamepads->active_instance_id == entry.instance_id) {
gamepads->active_instance_id = 0;
}
SDL_CloseGamepad(entry.gamepad);
}
}
gamepads->count = active_count;
}
static void update_gamepads(PortProtonGamepad *gamepads)
{
SDL_PumpEvents();
SDL_UpdateGamepads();
remove_disconnected(gamepads);
int count = 0;
SDL_JoystickID *instance_ids = SDL_GetGamepads(&count);
if (instance_ids == NULL) {
return;
}
for (int index = 0; index < count; index++) {
if (!has_gamepad(gamepads, instance_ids[index])) {
add_gamepad(gamepads, instance_ids[index]);
}
}
SDL_free(instance_ids);
}
static bool update_entry_activity(GamepadEntry *entry)
{
bool activated = false;
for (int button = 0; button < SDL_GAMEPAD_BUTTON_COUNT; button++) {
bool pressed = SDL_GetGamepadButton(entry->gamepad, button);
activated |= pressed && !entry->buttons[button];
entry->buttons[button] = pressed;
}
for (int axis = 0; axis < SDL_GAMEPAD_AXIS_COUNT; axis++) {
int value = SDL_GetGamepadAxis(entry->gamepad, axis);
bool active = abs(value) >= AXIS_ACTIVATION_THRESHOLD;
activated |= active && !entry->axes_active[axis];
entry->axes_active[axis] = active;
}
return activated;
}
static const GamepadEntry *get_active_gamepad(const PortProtonGamepad *gamepads)
{
if (gamepads == NULL) {
return NULL;
}
for (int index = 0; index < gamepads->count; index++) {
if (gamepads->entries[index].instance_id == gamepads->active_instance_id) {
return &gamepads->entries[index];
}
}
return NULL;
}
PortProtonGamepad *portproton_gamepad_find(void)
{
SDL_ClearError();
if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) {
return NULL;
}
PortProtonGamepad *result = calloc(1, sizeof(*result));
if (result == NULL) {
SDL_SetError("Failed to allocate gamepad collection");
return NULL;
}
update_gamepads(result);
if (result->count == 0) {
free(result);
SDL_ClearError();
return NULL;
}
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;
}
for (int index = 0; index < gamepad->count; index++) {
SDL_CloseGamepad(gamepad->entries[index].gamepad);
}
free(gamepad->entries);
free(gamepad);
}
bool portproton_gamepad_connected(const PortProtonGamepad *gamepad)
{
return gamepad != NULL && gamepad->count > 0;
}
void portproton_gamepad_update(PortProtonGamepad *gamepad)
{
if (gamepad != NULL) {
update_gamepads(gamepad);
for (int index = 0; index < gamepad->count; index++) {
if (update_entry_activity(&gamepad->entries[index])) {
gamepad->active_instance_id = gamepad->entries[index].instance_id;
}
}
}
}
int portproton_gamepad_get_button(const PortProtonGamepad *gamepad, int button)
{
const GamepadEntry *active = get_active_gamepad(gamepad);
if (active == NULL) {
return 0;
}
return SDL_GetGamepadButton(active->gamepad, button);
}
int16_t portproton_gamepad_get_axis(const PortProtonGamepad *gamepad, int axis)
{
const GamepadEntry *active = get_active_gamepad(gamepad);
if (active == NULL) {
return 0;
}
return SDL_GetGamepadAxis(active->gamepad, axis);
}
uint32_t portproton_gamepad_get_active_instance_id(
const PortProtonGamepad *gamepad)
{
return gamepad != NULL ? gamepad->active_instance_id : 0;
}
const char *portproton_gamepad_get_name(const PortProtonGamepad *gamepad)
{
if (gamepad == NULL || gamepad->count == 0) {
return "";
}
const char *name = SDL_GetGamepadName(gamepad->entries[0].gamepad);
return name != NULL ? name : "";
}
uint32_t portproton_gamepad_get_instance_id(const PortProtonGamepad *gamepad)
{
return gamepad != NULL && gamepad->count > 0
? gamepad->entries[0].instance_id : 0;
}
int portproton_gamepad_get_type(const PortProtonGamepad *gamepad)
{
const GamepadEntry *active = get_active_gamepad(gamepad);
if (active != NULL) {
return active->sdl_type;
}
return gamepad != NULL && gamepad->count > 0
? gamepad->entries[0].sdl_type : 0;
}
void portproton_gamepad_shutdown(void)
{
SDL_QuitSubSystem(SDL_INIT_GAMEPAD);
}
File renamed without changes.
+18
View File
@@ -1,3 +1,21 @@
portprotonqt (1.4.1) unstable; urgency=medium
* New upstream release.
-- Ivan Mazhukin <vanyamajukin@outlook.com> Wed, 26 Aug 2026 13:03:46 +0500
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,
+12
View File
@@ -19,6 +19,18 @@ for file in "$@"; do
echo "$file: bash -n syntax check failed"
status=1
fi
# Bash 5.1 requires a separator before then after a conditional command.
if ! awk '
/\]\][ \t]+then([ \t;]|$)/ || (continued && /^[ \t]*then([ \t;]|$)/) {
printf "%s:%d: Bash 5.1 requires a newline or semicolon before then\n", FILENAME, NR
failed = 1
}
{ continued = /\]\][ \t]*\\$/ }
END { exit failed }
' "$file"; then
status=1
fi
done
exit "$status"
+26 -19
View File
@@ -1,58 +1,65 @@
#!/bin/bash
set -euo pipefail
GITHUB_REPO="Boria138/PortProtonQt"
GITHUB_REPOS=("linux-gaming-ru/PortProtonQt")
GITEA_REPO="Linux-Gaming/PortProtonQt"
GITEA_URL="https://git.linux-gaming.ru"
jq_format='def format_downloads: if . < 1000 then tostring else ((. / 100 | round) / 10 | tostring) + "k" end;'
jq_total='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | .download_count] | add // 0'
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 -sfL "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"
echo "Total downloads: $GH_COUNT"
GH_DISPLAY=$(jq -nr --argjson count "$GH_COUNT" "$jq_format"'$count | format_downloads')
echo "Total downloads: $GH_DISPLAY"
echo
echo "Per release:"
echo "$GH_RAW" | jq -r '.[] | "\n \(.tag_name) (\(.published_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count)")'
echo "$GH_RAW" | jq -r "$jq_format"'.[] | "\n \(.tag_name) (\(.published_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count | format_downloads)")'
echo
echo "--- Gitea (${GITEA_REPO}) ---"
GIT_RAW=$(curl -sf "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/releases?limit=50")
GIT_RAW=$(curl -sfL "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/releases?limit=50")
GIT_COUNT=$(echo "$GIT_RAW" | jq "$jq_total")
GIT_RELEASES=$(echo "$GIT_RAW" | jq 'length')
echo "Releases: $GIT_RELEASES"
echo "Total downloads: $GIT_COUNT"
GIT_DISPLAY=$(jq -nr --argjson count "$GIT_COUNT" "$jq_format"'$count | format_downloads')
echo "Total downloads: $GIT_DISPLAY"
echo
echo "Per release:"
echo "$GIT_RAW" | jq -r '.[] | "\n \(.tag_name) (\(.created_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count)")'
echo "$GIT_RAW" | jq -r "$jq_format"'.[] | "\n \(.tag_name) (\(.created_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count | format_downloads)")'
echo
TOTAL=$((GH_COUNT + GIT_COUNT))
BADGE_TOTAL=$(jq -nr --argjson total "$TOTAL" "$jq_format"'$total | format_downloads')
echo "========================"
echo "GitHub total: $GH_COUNT"
echo "Gitea total: $GIT_COUNT"
echo "Grand total: $TOTAL"
echo "GitHub total: $GH_DISPLAY"
echo "Gitea total: $GIT_DISPLAY"
echo "Grand total: $BADGE_TOTAL"
echo
echo "--- Top downloads (by file) ---"
jq_filter_top='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | group_by(.name) | map({name: .[0].name, total: (map(.download_count) | add)}) | sort_by(-.total) | .[:15] | .[] | " \(.total)\t\(.name)"'
jq_filter_top='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | group_by(.name) | map({name: .[0].name, total: (map(.download_count) | add)}) | sort_by(-.total) | .[:15] | .[] | " \(.total | format_downloads)\t\(.name)"'
echo "GitHub:"
echo "$GH_RAW" | jq -r "$jq_filter_top"
echo "$GH_RAW" | jq -r "$jq_format $jq_filter_top"
echo
echo "Gitea:"
echo "$GIT_RAW" | jq -r "$jq_filter_top"
echo "$GIT_RAW" | jq -r "$jq_format $jq_filter_top"
echo
echo "--- Top downloads (by type) ---"
jq_type='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | map({type: (.name | split(".") | last | if test("^AppImage$") then "AppImage" elif test("^deb$") then "deb" elif test("^rpm$") then "rpm" elif test("^zst$") then "pkg.tar.zst" else "other" end), downloads: .download_count}) | group_by(.type) | map({type: .[0].type, total: (map(.downloads) | add)}) | sort_by(-.total) | .[] | " \(.type): \(.total)"'
jq_type='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | map({type: (.name | split(".") | last | if test("^AppImage$") then "AppImage" elif test("^deb$") then "deb" elif test("^rpm$") then "rpm" elif test("^zst$") then "pkg.tar.zst" else "other" end), downloads: .download_count}) | group_by(.type) | map({type: .[0].type, total: (map(.downloads) | add)}) | sort_by(-.total) | .[] | " \(.type): \(.total | format_downloads)"'
echo "GitHub:"
echo "$GH_RAW" | jq -r "$jq_type"
echo "$GH_RAW" | jq -r "$jq_format $jq_type"
echo
echo "Gitea:"
echo "$GIT_RAW" | jq -r "$jq_type"
echo "$GIT_RAW" | jq -r "$jq_format $jq_type"
if [[ "${CI:-}" != "true" ]]; then
echo
@@ -60,9 +67,9 @@ if [[ "${CI:-}" != "true" ]]; then
exit 0
fi
sed -i "s|-[0-9]*-green?style=flat-square|-$TOTAL-green?style=flat-square|g" README.md README.ru.md
sed -i -E "s|-[0-9]+(\.[0-9]+)?k?-green\?style=flat-square|-$BADGE_TOTAL-green?style=flat-square|g" README.md README.ru.md
echo
echo "Badge updated: $TOTAL"
echo "Badge updated: $BADGE_TOTAL"
git diff --quiet README.md README.ru.md && { echo "No changes to commit"; exit 0; }
+6 -6
View File
@@ -17,13 +17,13 @@ if ! command -v uv &> /dev/null; then
exit 1
fi
# 2. Check if Python 3.10 is already installed via uv
# We look for cpython-3.10 and ensure it doesn't say "<download available>"
if uv python list | grep "cpython-3.10" | grep -qv "<download available>"; then
echo "✅ Python 3.10 is already available."
# 2. Check if Python 3.11 is already installed via uv
# We look for cpython-3.11 and ensure it doesn't say "<download available>"
if uv python list | grep "cpython-3.11" | grep -qv "<download available>"; then
echo "✅ Python 3.11 is already available."
else
echo "🐍 Installing Python 3.10..."
uv python install 3.10
echo "🐍 Installing Python 3.11..."
uv python install 3.11
fi
# 3. Sync dependencies
-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()
+2 -2
View File
@@ -44,7 +44,7 @@ Translations submitted online are periodically reviewed and merged into the main
1. Run:
```bash
uv python install 3.10
uv python install 3.11
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --create-new <locale_code>
@@ -64,7 +64,7 @@ python dev-scripts/l10n.py
If you’ve added new strings to the code:
```bash
uv python install 3.10
uv python install 3.11
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --update-all
@@ -44,7 +44,7 @@
1. Выполните:
```bash
uv python install 3.10
uv python install 3.11
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --create-new <код_локали>
@@ -64,7 +64,7 @@ python dev-scripts/l10n.py
Если вы добавили новые строки в код:
```bash
uv python install 3.10
uv python install 3.11
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --update-all
+109 -2
View File
@@ -14,6 +14,7 @@
- [Detail Page Layout Mode](#detail-page-layout-mode)
- [Detail Page Background Mode](#detail-page-background-mode)
- [Background Configuration](#background-configuration)
- [Library Background](#library-background)
- [Preloader](#preloader)
- [Source Corner (Ribbon)](#source-corner-ribbon)
- [Terminal Color Schemes](#terminal-color-schemes)
@@ -234,14 +235,36 @@ NAV_BUTTON_STYLE = f"""
You can control the game library card layout directly from the theme via `styles.py`:
```python
# "grid" (default) or "list"
# "grid", "list", "vertical", "horizontal", or "horizontal_top"
LIBRARY_LAYOUT_MODE = "grid"
```
- `grid`: multi-column card grid (classic behavior).
- `list`: horizontal row-style cards (launcher-style list).
- `vertical`: a full-width table with game title, last launch, time spent, and
library columns.
- `horizontal`: one horizontally scrolling row containing every game.
- `horizontal_top`: the first `horizontalTopGameLimit` games from the current
filter and sort order, followed by a tile composed from the next games that
opens the complete grid.
Card geometry is controlled by `GAME_CARD_LIST` and `GAME_CARD_GRID`:
The `horizontal_top` preview tile is configured with theme-level constants:
```python
horizontalTopGameLimit = 10 # Games shown before the tile.
fullLibraryTileGameCount = 4 # Cover images shown in the tile.
fullLibraryTileSize = (180, 180) # Tile width and height.
fullLibraryTileColumns = 2 # Cover columns inside the tile.
fullLibraryTileRows = 2 # Cover rows inside the tile.
fullLibraryTileRadius = 10 # Tile corner radius in pixels.
```
`fullLibraryTileColumns * fullLibraryTileRows` should be at least
`fullLibraryTileGameCount`. The tile opens the complete library grid when
activated.
Card geometry is controlled by `GAME_CARD_LIST`, `GAME_CARD_GRID`, and
`GAME_CARD_HORIZONTAL`:
```python
GAME_CARD_LIST = {
@@ -258,6 +281,7 @@ GAME_CARD_LIST = {
}
GAME_CARD_GRID = {
"card_animation_type": "gradient",
"extra_margin": 20,
"spacing": 5,
"cover_aspect_ratio": 1.5,
@@ -265,12 +289,48 @@ GAME_CARD_GRID = {
"cover_radius": 15,
"border_radius": 18,
}
GAME_CARD_HORIZONTAL = {
"card_orientation": "vertical",
"card_animation_type": "scale",
"extra_margin": 20,
"spacing": 5,
"cover_aspect_ratio": 0.56,
"card_height_ratio": 0.78,
"cover_radius": 15,
"layout_margins": (20, 20, 20, 20),
"layout_spacing": 20,
}
```
Set `card_orientation` to `"vertical"` to reuse the grid card proportions in
the horizontal strip. Omit it to keep the horizontal block proportions.
Values are logical pixels. `cover_radius` applies to static and animated covers;
`border_radius` applies to the painted focus and hover border. Grid cover width
continues to follow the library card-size setting.
Card animation keys can be overridden independently in `GAME_CARD_LIST`,
`GAME_CARD_GRID`, or `GAME_CARD_HORIZONTAL`: `card_animation_type`, border
widths, pulse settings, gradient angles and colors, fill/stripe/glow colors and
opacity, scale values, animation durations, and easing curves. For example,
`horizontal_top` can define its own `scale` type, scale values, duration, and
easing while the complete grid uses separately configured `gradient` values.
Keys omitted from a layout continue to use `GAME_CARD_ANIMATION` as fallback;
`card_animation_type` defaults to `gradient`.
```python
GAME_CARD_HORIZONTAL = {
"card_animation_type": "scale",
"default_scale": 1.0,
"hover_scale": 1.055,
"focus_scale": 1.075,
"scale_anim_duration": 135,
"scale_easing_curve": "OutCubic",
"scale_easing_curve_out": "InOutCubic",
}
```
Card shadows use the shared `shadow_blur_radius` and `shadow_offset` theme
constants in both grid and list layouts.
@@ -452,6 +512,53 @@ Existing themes may continue using `DETAIL_PAGE_GRADIENT`,
`DETAIL_PAGE_GRADIENT_FY`, and `DETAIL_PAGE_WAVES`. Legacy values override the
corresponding nested settings.
## Library Background
The library can use the cover of the currently hovered or focused card as its
background. This feature is opt-in: if `LIBRARY_BACKGROUND` is not defined, the
library keeps the theme's regular background unchanged.
`LIBRARY_BACKGROUND` selects the effect and can override detail-page
background settings locally. Its `backgrounds` dictionary is merged with
`DETAIL_PAGE_BACKGROUNDS`, so only the values that should differ need to be
specified:
```python
LIBRARY_BACKGROUND = {
"margins": (0, 0, 0, 0),
"mode": "waves", # gradient, blur, waves, aurora, metaballs, leaf, veins, diagnostics
"backgrounds": {
"animation_interval_ms": 30,
"animation_speed": 0.03,
"blur": {
"render_resolution": (1280, 720),
"radius": 64,
},
"gradient": {
"stops": None,
"type": "linear",
"x1": 0, "y1": 0, "x2": 1, "y2": 1,
},
"waves": {
"layer_count": 4,
"wave_amplitude_ratio": 0.06,
"wave_frequency": 2.0,
"layer_spacing_ratio": 0.04,
"base_opacity": 0.45,
"opacity_decay": 0.85,
"animation_speed": 0.03,
"animation_interval_ms": 30,
},
},
}
```
The `blur` settings control the intermediate image size and blur radius.
`margins` are applied to the background layer layout. Animated modes use the
same effect-specific dictionaries documented in `DETAIL_PAGE_BACKGROUNDS`;
copy the corresponding dictionary there when overriding `aurora`, `leaf`,
`metaballs`, `veins`, or `diagnostics`.
---
The `PRELOADER` dictionary controls the loading indicator style and animation. If not defined, the default spinner is used.
+111 -2
View File
@@ -14,6 +14,7 @@
- [Режим отображения детальной страницы](#режим-отображения-детальной-страницы)
- [Режим фона детальной страницы](#режим-фона-детальной-страницы)
- [Настройка фона](#настройка-фона)
- [Фон библиотеки](#фон-библиотеки)
- [Прелоадер](#прелоадер)
- [Уголок источника (Лента)](#уголок-источника-лента)
- [Цветовые схемы терминала](#цветовые-схемы-терминала)
@@ -234,14 +235,36 @@ NAV_BUTTON_STYLE = f"""
Вы можете управлять типом отображения карточек библиотеки прямо из темы через `styles.py`:
```python
# "grid" (по умолчанию) или "list"
# "grid", "list", "vertical", "horizontal" или "horizontal_top"
LIBRARY_LAYOUT_MODE = "grid"
```
- `grid`: многоколоночная сетка карточек (классическое поведение).
- `list`: горизонтальные карточки-строки (формат лаунчера).
- `vertical`: полноширинная таблица с названием игры, последним запуском,
временем в игре и библиотекой.
- `horizontal`: одна горизонтально прокручиваемая лента со всеми играми,
- `horizontal_top`: первые `horizontalTopGameLimit` игр из текущих результатов
фильтрации и сортировки, затем плитка из следующих игр для открытия полной
сетки.
Геометрия карточек управляется через `GAME_CARD_LIST` и `GAME_CARD_GRID`:
Плитка перехода в режиме `horizontal_top` настраивается константами темы:
```python
horizontalTopGameLimit = 10 # Игр показывается до плитки.
fullLibraryTileGameCount = 4 # Обложек показывается в плитке.
fullLibraryTileSize = (180, 180) # Ширина и высота плитки.
fullLibraryTileColumns = 2 # Столбцов обложек внутри плитки.
fullLibraryTileRows = 2 # Строк обложек внутри плитки.
fullLibraryTileRadius = 10 # Радиус скругления плитки в пикселях.
```
Произведение `fullLibraryTileColumns * fullLibraryTileRows` должно быть не
меньше `fullLibraryTileGameCount`. При активации плитка открывает полную сетку
библиотеки.
Геометрия карточек управляется через `GAME_CARD_LIST`, `GAME_CARD_GRID` и
`GAME_CARD_HORIZONTAL`:
```python
GAME_CARD_LIST = {
@@ -258,6 +281,7 @@ GAME_CARD_LIST = {
}
GAME_CARD_GRID = {
"card_animation_type": "gradient",
"extra_margin": 20,
"spacing": 5,
"cover_aspect_ratio": 1.5,
@@ -265,13 +289,50 @@ GAME_CARD_GRID = {
"cover_radius": 15,
"border_radius": 18,
}
GAME_CARD_HORIZONTAL = {
"card_orientation": "vertical",
"card_animation_type": "scale",
"extra_margin": 20,
"spacing": 5,
"cover_aspect_ratio": 0.56,
"card_height_ratio": 0.78,
"cover_radius": 15,
"layout_margins": (20, 20, 20, 20),
"layout_spacing": 20,
}
```
Укажите `card_orientation = "vertical"`, чтобы использовать в горизонтальной
ленте вертикальные пропорции карточек из сетки. Без параметра сохраняются
пропорции горизонтальных блоков.
Значения задаются в логических пикселях. `cover_radius` применяется к
статическим и анимированным обложкам, а `border_radius` — к отрисовываемой
обводке при фокусе и наведении. Ширина обложки в сеточном режиме по-прежнему
определяется настройкой размера карточек библиотеки.
Параметры анимации карточек можно отдельно переопределить в
`GAME_CARD_LIST`, `GAME_CARD_GRID` или `GAME_CARD_HORIZONTAL`: тип анимации,
ширину обводки, pulse, углы и цвета градиента, цвета и прозрачность
fill/stripe/glow, масштаб, длительности и easing-кривые. Например,
`horizontal_top` может задавать собственные тип `scale`, значения масштаба,
длительность и easing, а полная сетка — отдельные параметры `gradient`.
Отсутствующие в режиме параметры берутся из `GAME_CARD_ANIMATION`, а
`card_animation_type` по умолчанию использует `gradient`.
```python
GAME_CARD_HORIZONTAL = {
"card_animation_type": "scale",
"default_scale": 1.0,
"hover_scale": 1.055,
"focus_scale": 1.075,
"scale_anim_duration": 135,
"scale_easing_curve": "OutCubic",
"scale_easing_curve_out": "InOutCubic",
}
```
Тень карточек использует общие константы темы `shadow_blur_radius` и
`shadow_offset` как в сеточном, так и в списочном режиме.
@@ -452,6 +513,54 @@ DETAIL_PAGE_BACKGROUNDS = {
`DETAIL_PAGE_GRADIENT_FY` и `DETAIL_PAGE_WAVES`. Старые значения
переопределяют соответствующие вложенные настройки.
## Фон библиотеки
Библиотека может использовать обложку карточки, на которой находится курсор
или фокус, как фон. Функция включается только при наличии
`LIBRARY_BACKGROUND`; если словарь не определён, обычный фон темы библиотеки
остаётся без изменений.
`LIBRARY_BACKGROUND` выбирает эффект и может локально переопределять настройки
фона детальной страницы. Словарь `backgrounds` объединяется с
`DETAIL_PAGE_BACKGROUNDS`, поэтому достаточно указать только отличающиеся
значения:
```python
LIBRARY_BACKGROUND = {
"margins": (0, 0, 0, 0),
"mode": "waves", # gradient, blur, waves, aurora, metaballs, leaf, veins, diagnostics
"backgrounds": {
"animation_interval_ms": 30,
"animation_speed": 0.03,
"blur": {
"render_resolution": (1280, 720),
"radius": 64,
},
"gradient": {
"stops": None,
"type": "linear",
"x1": 0, "y1": 0, "x2": 1, "y2": 1,
},
"waves": {
"layer_count": 4,
"wave_amplitude_ratio": 0.06,
"wave_frequency": 2.0,
"layer_spacing_ratio": 0.04,
"base_opacity": 0.45,
"opacity_decay": 0.85,
"animation_speed": 0.03,
"animation_interval_ms": 30,
},
},
}
```
Настройки `blur` задают размер промежуточного изображения и радиус размытия.
`margins` применяются к слою фона. Анимированные режимы используют те же
словаря эффектов, что и `DETAIL_PAGE_BACKGROUNDS`; для переопределения
`aurora`, `leaf`, `metaballs`, `veins` или `diagnostics` скопируйте нужный
словарь из описания выше.
---
Словарь `PRELOADER` управляет стилем и анимацией индикатора загрузки. Если не определён, используется стандартный спиннер.
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.1',
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@
+11 -2
View File
@@ -30,7 +30,16 @@ class DetailBackgroundAnimations:
if effect not in self.effects:
self.remove(page)
return False
config = theme.DETAIL_PAGE_BACKGROUNDS
config = getattr(theme, "DETAIL_PAGE_BACKGROUNDS", {})
animated = not mode.startswith("static_")
if not isinstance(config, dict) or not isinstance(config.get(effect), dict):
self.remove(page)
return False
if animated and not all(
key in config for key in ("animation_interval_ms", "animation_speed")
):
self.remove(page)
return False
self.remove(page)
state = {
"effect": effect,
@@ -47,7 +56,7 @@ class DetailBackgroundAnimations:
self._paint(page, state)
page.paintEvent = paint_event
if not mode.startswith("static_"):
if animated:
self._start_timer(page, state)
return True
+80 -34
View File
@@ -1,4 +1,5 @@
from math import radians, sin
from typing import Any
import warnings
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, Qt
@@ -22,6 +23,22 @@ class GameCardAnimations:
self.pulse_anim: QPropertyAnimation | None = None
self._isPulseAnimationConnected = False
def _animation_type(self) -> str:
layout_config = getattr(self.game_card, "card_layout_cfg", {})
return layout_config.get("card_animation_type", "gradient")
def _config_value(self, key: str) -> Any:
layout_config = getattr(self.game_card, "card_layout_cfg", {})
if key in layout_config:
return layout_config[key]
return self.theme.GAME_CARD_ANIMATION[key]
def _optional_config_value(self, key: str, default: Any) -> Any:
layout_config = getattr(self.game_card, "card_layout_cfg", {})
if key in layout_config:
return layout_config[key]
return self.theme.GAME_CARD_ANIMATION.get(key, default)
def _disconnect_pulse_animation(self) -> None:
if not self._isPulseAnimationConnected or self.thickness_anim is None:
return
@@ -49,20 +66,20 @@ class GameCardAnimations:
def _restart_gradient_animation(self) -> None:
self._stop_animation("gradient_anim")
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
self.gradient_anim.setDuration(self._config_value("gradient_anim_duration"))
self.gradient_anim.setStartValue(self._config_value("gradient_start_angle"))
self.gradient_anim.setEndValue(self._config_value("gradient_end_angle"))
self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start()
def _easing_curve(self, easing_key: str) -> QEasingCurve:
easing_type = QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION[easing_key]]
easing_type = QEasingCurve.Type[self._config_value(easing_key)]
return QEasingCurve(easing_type)
def _restart_scale_animation(self, end_value: float, easing_key: str) -> None:
self._stop_animation("scale_anim")
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setDuration(self._config_value("scale_anim_duration"))
self.scale_anim.setEasingCurve(self._easing_curve(easing_key))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(end_value)
@@ -89,18 +106,47 @@ class GameCardAnimations:
self._stop_animation("pulse_anim")
self._isPulseAnimationConnected = False
def refresh_theme(self, theme: Any) -> None:
"""Recreate animation state for a live theme change."""
self.cleanup()
self.theme = theme
animation_type = self._animation_type()
self.game_card._gradientAngle = self._config_value("gradient_start_angle")
if self.game_card._hovered:
self.game_card._borderWidth = self._config_value("hover_border_width")
active_scale = self._config_value("hover_scale")
elif self.game_card._focused:
self.game_card._borderWidth = self._config_value("focus_border_width")
active_scale = self._config_value("focus_scale")
else:
self.game_card._borderWidth = self._config_value("default_border_width")
active_scale = self._config_value("default_scale")
self.game_card._scale = (
active_scale
if animation_type in {"scale", "scale_fill"}
else self._config_value("default_scale")
)
self.setup_animations()
if (self.game_card._hovered or self.game_card._focused) and animation_type in {
"gradient", "glow",
}:
self._restart_gradient_animation()
if self.game_card._hovered or self.game_card._focused:
self.start_pulse_animation()
self.game_card.update()
def setup_animations(self):
"""Initialize animation properties based on theme."""
self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
self.thickness_anim.setDuration(self._config_value("thickness_anim_duration"))
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
animation_type = self._animation_type()
if animation_type in {"gradient", "glow"}:
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
self.gradient_anim.setDuration(self._config_value("gradient_anim_duration"))
elif animation_type in {"scale", "scale_fill"}:
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setDuration(self._config_value("scale_anim_duration"))
def start_pulse_animation(self):
"""Start pulse animation for border width when hovered or focused."""
@@ -110,11 +156,11 @@ class GameCardAnimations:
self._stop_animation("pulse_anim")
self.pulse_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
self.pulse_anim.setDuration(self._config_value("pulse_anim_duration"))
self.pulse_anim.setLoopCount(0)
self.pulse_anim.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"])
self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
self.pulse_anim.setKeyValueAt(0, self._config_value("pulse_min_border_width"))
self.pulse_anim.setKeyValueAt(0.5, self._config_value("pulse_max_border_width"))
self.pulse_anim.setKeyValueAt(1, self._config_value("pulse_min_border_width"))
self.pulse_anim.start()
def handle_enter_event(self):
@@ -126,10 +172,10 @@ class GameCardAnimations:
if not self.thickness_anim:
self.setup_animations()
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
animation_type = self._animation_type()
self._start_thickness_animation(
self.theme.GAME_CARD_ANIMATION["hover_border_width"],
self._config_value("hover_border_width"),
"thickness_easing_curve",
True,
)
@@ -137,21 +183,21 @@ class GameCardAnimations:
if animation_type in {"gradient", "glow"}:
self._restart_gradient_animation()
elif animation_type in {"scale", "scale_fill"}:
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["hover_scale"], "scale_easing_curve")
self._restart_scale_animation(self._config_value("hover_scale"), "scale_easing_curve")
def handle_leave_event(self):
"""Handle mouse leave event animations."""
self.game_card._hovered = False
self.game_card.hoverChanged.emit(self.game_card.name, False)
if not self.game_card._focused:
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
animation_type = self._animation_type()
if animation_type in {"gradient", "glow"}:
self._stop_animation("gradient_anim")
elif animation_type in {"scale", "scale_fill"}:
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["default_scale"], "scale_easing_curve_out")
self._restart_scale_animation(self._config_value("default_scale"), "scale_easing_curve_out")
self._stop_animation("pulse_anim")
self._start_thickness_animation(
self.theme.GAME_CARD_ANIMATION["default_border_width"],
self._config_value("default_border_width"),
"thickness_easing_curve_out",
False,
)
@@ -165,10 +211,10 @@ class GameCardAnimations:
if not self.thickness_anim:
self.setup_animations()
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
animation_type = self._animation_type()
self._start_thickness_animation(
self.theme.GAME_CARD_ANIMATION["focus_border_width"],
self._config_value("focus_border_width"),
"thickness_easing_curve",
True,
)
@@ -176,21 +222,21 @@ class GameCardAnimations:
if animation_type in {"gradient", "glow"}:
self._restart_gradient_animation()
elif animation_type in {"scale", "scale_fill"}:
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["focus_scale"], "scale_easing_curve")
self._restart_scale_animation(self._config_value("focus_scale"), "scale_easing_curve")
def handle_focus_out_event(self):
"""Handle focus out event animations."""
self.game_card._focused = False
self.game_card.focusChanged.emit(self.game_card.name, False)
if not self.game_card._hovered:
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
animation_type = self._animation_type()
if animation_type in {"gradient", "glow"}:
self._stop_animation("gradient_anim")
elif animation_type in {"scale", "scale_fill"}:
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["default_scale"], "scale_easing_curve_out")
self._restart_scale_animation(self._config_value("default_scale"), "scale_easing_curve_out")
self._stop_animation("pulse_anim")
self._start_thickness_animation(
self.theme.GAME_CARD_ANIMATION["default_border_width"],
self._config_value("default_border_width"),
"thickness_easing_curve_out",
False,
)
@@ -203,39 +249,39 @@ class GameCardAnimations:
pen = QPen()
pen.setWidth(self.game_card._borderWidth)
fill_brush = QBrush(Qt.BrushStyle.NoBrush)
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
animation_type = self._animation_type()
if (self.game_card._hovered or self.game_card._focused) and animation_type == "gradient":
center = self.game_card.rect().center()
gradient = QConicalGradient(center, self.game_card._gradientAngle)
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
for stop in self._config_value("gradient_colors"):
gradient.setColorAt(stop["position"], QColor(stop["color"]))
pen.setBrush(QBrush(gradient))
elif (self.game_card._hovered or self.game_card._focused) and animation_type in {"fill", "scale_fill"}:
fill_color_value = self.theme.GAME_CARD_ANIMATION.get(
fill_color_value = self._optional_config_value(
"fill_color",
getattr(self.theme, "color_accent", self.theme.color_text),
)
fill_alpha = int(self.theme.GAME_CARD_ANIMATION.get("fill_alpha", 90))
fill_alpha = int(self._optional_config_value("fill_alpha", 90))
fill_color = QColor(fill_color_value)
fill_color.setAlpha(max(0, min(255, fill_alpha)))
fill_brush = QBrush(fill_color)
pen.setColor(QColor(0, 0, 0, 0))
elif (self.game_card._hovered or self.game_card._focused) and animation_type == "stripe":
stripe_color_value = self.theme.GAME_CARD_ANIMATION.get(
stripe_color_value = self._optional_config_value(
"stripe_color",
getattr(self.theme, "color_accent", self.theme.color_text),
)
stripe_alpha = int(self.theme.GAME_CARD_ANIMATION.get("stripe_alpha", 255))
stripe_alpha = int(self._optional_config_value("stripe_alpha", 255))
stripe_color = QColor(stripe_color_value)
stripe_color.setAlpha(max(0, min(255, stripe_alpha)))
pen.setColor(stripe_color)
elif (self.game_card._hovered or self.game_card._focused) and animation_type == "glow":
glow_color_value = self.theme.GAME_CARD_ANIMATION.get(
glow_color_value = self._optional_config_value(
"stripe_color",
getattr(self.theme, "color_accent", self.theme.color_text),
)
glow_base_alpha = int(self.theme.GAME_CARD_ANIMATION.get("glow_base_alpha", 120))
glow_pulse_alpha = int(self.theme.GAME_CARD_ANIMATION.get("glow_pulse_alpha", 80))
glow_base_alpha = int(self._optional_config_value("glow_base_alpha", 120))
glow_pulse_alpha = int(self._optional_config_value("glow_pulse_alpha", 80))
glow_wave = 0.5 + 0.5 * sin(radians(self.game_card._gradientAngle))
glow_alpha = max(0, min(255, glow_base_alpha + int(glow_pulse_alpha * glow_wave)))
glow_color = QColor(glow_color_value)
+170 -42
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.1"
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()
@@ -291,6 +312,7 @@ def main():
autoinstall_path = None
theme_store_id = None
gog_launch_uri = None
egs_launch_uri = None
resolution_from_args = None
if args.resolution:
resolution_from_args = parse_resolution(args.resolution)
@@ -304,14 +326,11 @@ def main():
prefix_name, backup_dir = args.create_backup
backup_request = (prefix_name, os.path.abspath(os.path.expanduser(backup_dir)))
ipc_message = "backup:{}:{}".format(quote(prefix_name, safe=""), quote(backup_request[1], safe=""))
elif is_silent_launch and not args.log and args.file_or_url and is_launch_file(args.file_or_url):
run_silent_tray(app, start_sh, normalize_launch_path(args.file_or_url))
sys.exit(app.exec())
elif is_restore_prefix_request(args):
restore_prefix_path = normalize_launch_path(args.file_or_url)
ipc_message = f"restore:{quote(restore_prefix_path, safe='')}"
elif args.file_or_url and is_launch_file(args.file_or_url):
launch_action = "log" if args.log else "open"
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
ipc_message = f"{launch_action}:{normalize_launch_path(args.file_or_url)}"
elif args.file_or_url and is_autoinstall_file(args.file_or_url):
autoinstall_path = normalize_launch_path(args.file_or_url)
@@ -320,7 +339,18 @@ def main():
app_id = args.file_or_url.removeprefix("gog://launch/")
if app_id.isdigit():
gog_launch_uri = args.file_or_url
ipc_message = f"gog:{app_id}"
if args.log:
ipc_message = f"log:{quote(args.file_or_url, safe='')}"
else:
ipc_message = f"silent:{quote(args.file_or_url, safe='')}" if is_silent_launch else f"gog:{app_id}"
elif args.file_or_url and args.file_or_url.startswith("egs://launch/"):
app_id = args.file_or_url.removeprefix("egs://launch/")
if app_id and "/" not in app_id:
egs_launch_uri = args.file_or_url
if args.log:
ipc_message = f"log:{quote(args.file_or_url, safe='')}"
else:
ipc_message = f"silent:{quote(args.file_or_url, safe='')}" if is_silent_launch else f"egs:{app_id}"
elif args.file_or_url:
theme_store_id = parse_portprotonqt_theme_url(args.file_or_url)
if theme_store_id is not None:
@@ -328,11 +358,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,
@@ -387,7 +419,7 @@ def main():
logger.warning("Failed to persist PORT_DATA_PATH in PortProtonQt config")
# Check if we have a portproton:// URL or launch file to handle
if args.file_or_url and not restore_prefix_path and not gog_launch_uri:
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri:
if is_portproton_url(args.file_or_url):
# Parse the portproton:// URL to get the full download URL
download_url = parse_portproton_url(args.file_or_url)
@@ -471,13 +503,61 @@ def main():
logger.warning(f"Invalid resolution format: {args.resolution}, expected WIDTHxHEIGHT (e.g. 1920x1080)")
launch_path = exe_path or autoinstall_path
silent_game_request = is_silent_launch and not args.log and bool(
exe_path or gog_launch_uri or egs_launch_uri
)
window = MainWindow(app_name=__app_name__, version=version, launch_exe=launch_path, resolution=window_resolution, show_system_tab=args.ppqtos)
window.silent_launch_mode = silent_game_request
if silent_game_request:
window._initialize_tray(__app_name__)
assert window.tray_manager is not None
if exe_path:
window.tray_manager.tray_icon.hide()
else:
window.tray_manager.set_minimal_mode()
def open_store_game_card(source: str, app_id: str, log_mode: bool = False) -> None:
exec_line = f"{source}://launch/{app_id}"
if log_mode:
window._pending_log_exe = getattr(window, f"{source}_api").get_launch_target(app_id)
def open_card(games: list[tuple]) -> None:
game = next((item for item in games if item[5] == exec_line), None)
if game is None:
return
window.openGameDetailPage({
"name": game[0], "description": game[1], "cover_path": game[2],
"appid": game[3], "controller_support": game[4],
"exec_line": game[5], "last_launch": game[6],
"formatted_playtime": game[7], "protondb_tier": game[8],
"anticheat_status": game[9], "playtime_seconds": game[11],
"game_source": game[12], "anticheat_slug": game[13],
"ppdb_id": game[14], "ppdb_rating": game[15],
"protondb_appid": game[16],
})
loader = getattr(window, f"_load_{source}_games_async")
loader(open_card)
def handle_game_request(target: str, silent: bool, log_mode: bool = False) -> None:
if is_launch_file(target):
path = normalize_launch_path(target)
if silent:
run_silent_tray(app, start_sh, path)
return
window.handle_launch_exe(path, log_mode=log_mode)
return
source, app_id = target.split("://launch/", 1)
if silent:
window.toggleGame(target)
return
open_store_game_card(source, app_id, log_mode)
# Handle launch file if provided
if exe_path:
# Defer the call until after the window is shown
def handle_launch_exe():
window.handle_launch_exe(exe_path, log_mode=args.log)
def handle_launch_exe() -> None:
handle_game_request(exe_path, is_silent_launch and not args.log, args.log)
QTimer.singleShot(0, handle_launch_exe)
elif autoinstall_path:
def handle_autoinstall():
@@ -492,9 +572,13 @@ def main():
window._perform_restore(restore_prefix_path)
QTimer.singleShot(0, handle_restore_prefix)
elif gog_launch_uri:
def handle_gog_launch():
window.toggleGame(gog_launch_uri)
def handle_gog_launch() -> None:
handle_game_request(gog_launch_uri, is_silent_launch, args.log)
QTimer.singleShot(0, handle_gog_launch)
elif egs_launch_uri:
def handle_egs_launch() -> None:
handle_game_request(egs_launch_uri, is_silent_launch, args.log)
QTimer.singleShot(0, handle_egs_launch)
# --- Handle incoming connections ---
def handle_new_connection():
@@ -513,20 +597,24 @@ def main():
msg.startswith("show")
or msg.startswith("open:")
or msg.startswith("log:")
or msg.startswith("silent:")
or msg.startswith("restore:")
or msg.startswith("backup:")
or msg.startswith("theme:")
or msg.startswith("autoinstall:")
or msg.startswith("gog:")
or msg.startswith("egs:")
):
# Ensure the window is visible and not minimized
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
window.show()
window.raise_()
window.activateWindow()
if not msg.startswith("silent:"):
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
window.show()
window.raise_()
window.activateWindow()
# Ensure window is in active state for systems with strict focus policies
window.setWindowState(window.windowState() | Qt.WindowState.WindowActive)
# Ensure active state for strict focus policies
window.setWindowState(
window.windowState() | Qt.WindowState.WindowActive
)
if ":fullscreen" in msg:
logger.info("Switching to fullscreen via IPC")
@@ -538,12 +626,17 @@ def main():
display_config.set_fullscreen(False)
window.showNormal()
if msg.startswith("open:") or msg.startswith("log:"):
if msg.startswith("silent:"):
target = unquote(msg[7:].strip())
handle_game_request(target, True)
elif msg.startswith("open:") or msg.startswith("log:"):
log_mode = msg.startswith("log:")
launch_path = msg.split(":", 1)[1].strip()
launch_path = unquote(msg.split(":", 1)[1].strip())
if launch_path and is_launch_file(launch_path):
logger.info("Opening launch file via IPC: %s", launch_path)
window.handle_launch_exe(launch_path, log_mode=log_mode)
handle_game_request(launch_path, False, log_mode)
elif log_mode and launch_path.startswith(("gog://launch/", "egs://launch/")):
handle_game_request(unquote(launch_path), False, True)
else:
logger.warning("Invalid launch file via IPC: %s", launch_path)
elif msg.startswith("restore:"):
@@ -556,9 +649,15 @@ def main():
elif msg.startswith("gog:"):
app_id = msg[4:].strip()
if app_id.isdigit():
window.toggleGame(f"gog://launch/{app_id}")
handle_game_request(f"gog://launch/{app_id}", False)
else:
logger.warning("Invalid GOG app id via IPC: %s", app_id)
elif msg.startswith("egs:"):
app_id = msg[4:].strip()
if app_id and "/" not in app_id:
handle_game_request(f"egs://launch/{app_id}", False)
else:
logger.warning("Invalid Epic app id via IPC: %s", app_id)
elif msg.startswith("backup:"):
parts = msg.split(":", 2)
if len(parts) == 3:
@@ -601,16 +700,20 @@ def main():
and getattr(window.input_manager, "gamepad", None) is not None
)
launch_minimized = (
display_config.get_start_minimized()
and not args.fullscreen
and not launch_auto_fullscreen
and window_resolution is None
and exe_path is None
and backup_request is None
and restore_prefix_path is None
and autoinstall_path is None
and theme_store_id is None
and gog_launch_uri is None
silent_game_request
or (
display_config.get_start_minimized()
and not args.fullscreen
and not launch_auto_fullscreen
and window_resolution is None
and exe_path is None
and backup_request is None
and restore_prefix_path is None
and autoinstall_path is None
and theme_store_id is None
and gog_launch_uri is None
and egs_launch_uri is None
)
)
if launch_minimized:
logger.info("Launching in tray")
@@ -675,6 +778,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."""
@@ -741,6 +865,10 @@ def main():
if start_sh:
worker = InitialCommandWorker(start_sh)
window.initialCommandWorker = worker
worker.finished.connect(
lambda: setattr(window, "initialCommandWorker", None)
)
worker.start()
else:
logger.warning("PortProton start command not available, skipping initial command")
+55 -26
View File
@@ -6,19 +6,21 @@ 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
logger = get_logger(__name__)
APP_ID = "ru.linux_gaming.PortProtonQt"
MIME_PACKAGE_NAME = f"{APP_ID}.xml"
DESKTOP_DATABASE_TIMEOUT = 10
EXECUTABLE_MODE = 0o755
WINDOWS_MIME_TYPES = (
"application/x-ms-dos-executable;application/x-msdos-program;"
"application/x-ms-dos-exec;application/x-executable;"
"application/x-ms-dos-exec;"
"application/x-dosexec;application/vnd.microsoft.portable-executable;"
)
INTEGRATION_MODES = ("log", "silent")
@@ -53,10 +55,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 +70,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 +81,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 +94,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,21 +136,33 @@ 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
def _install_mime_package(appdir: Path, data_home: Path) -> None:
source = _find_appimage_asset(
appdir,
(
f"share/mime/packages/{MIME_PACKAGE_NAME}",
f"usr/share/mime/packages/{MIME_PACKAGE_NAME}",
),
)
if not source:
raise FileNotFoundError("AppImage MIME metadata is incomplete")
mime_dir = data_home / "mime"
packages_dir = mime_dir / "packages"
packages_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, packages_dir / MIME_PACKAGE_NAME)
subprocess.run(
["update-mime-database", str(mime_dir)],
check=True,
timeout=DESKTOP_DATABASE_TIMEOUT,
)
def integrate_appimage() -> Path:
"""Install the running AppImage and its desktop handlers for this user."""
source = Path(os.environ["APPIMAGE"]).expanduser()
@@ -156,18 +174,27 @@ 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)
# Remove after the legacy AppImage migration period.
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)
_install_mime_package(appdir, data_home)
_set_default_mime_handlers()
try:
subprocess.run(
@@ -177,6 +204,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
+28 -31
View File
@@ -14,7 +14,8 @@ import requests
from PySide6.QtCore import QThread, Signal
from portprotonqt.downloader import get_requests_session
from portprotonqt.config import CACHE_DIR, ui_config
from portprotonqt.config import ui_config
from portprotonqt.config.base import XDG_DATA_HOME
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
@@ -28,15 +29,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
@@ -59,7 +60,7 @@ def _get_appimage_arch() -> str:
def _cached_tool_path() -> Path:
return CACHE_DIR / "bin" / "appimageupdatetool"
return XDG_DATA_HOME / "PortProtonQt" / "bin" / "appimageupdatetool"
def _get_appimageupdatetool_url() -> str:
@@ -129,16 +130,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 +313,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 +331,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
+46 -8
View File
@@ -14,6 +14,7 @@ from typing import Any
import orjson
import pefile
import psutil
from portprotonqt.debug_utils import (
get_portproton_env,
@@ -28,7 +29,7 @@ from portprotonqt.scripts_utils.graphics_detector import analyze_executable
logger = get_logger(__name__)
CRASH_THRESHOLD_SECONDS = 5
CRASH_THRESHOLD_SECONDS = 7
COMPATIBILITY_ALWAYS_REPORT_ENV = "PORTPROTONQT_COMPATIBILITY_ALWAYS_REPORT"
PE_MACHINE_AMD64 = 0x8664
COMPATIBILITY_SCAN_BYTES = 4_194_304
@@ -98,16 +99,46 @@ class CompatibilityLaunch:
duration: float
def is_suspected_crash(duration: float, stopped_by_user: bool, executable: str) -> bool:
"""Return whether an executable closed too soon after launch."""
def is_suspected_crash(
duration: float, stopped_by_user: bool, executable: str, exit_code: int | None = None
) -> bool:
"""Prefer Wine's command status; use short runtime only when it is unknown."""
always_report = os.getenv(COMPATIBILITY_ALWAYS_REPORT_ENV) == "1"
return (
(always_report or (not stopped_by_user and duration < CRASH_THRESHOLD_SECONDS))
(always_report or (not stopped_by_user and (
exit_code != 0 if exit_code is not None else duration < CRASH_THRESHOLD_SECONDS
)))
and executable.lower().endswith((".exe", ".msi"))
and os.path.isfile(executable)
)
def get_running_game_processes(executable: str, started: float) -> dict[tuple[int, float], str]:
"""Find new Windows processes in the game's directory, including engine children."""
directory = os.path.dirname(os.path.abspath(executable)).lower() + "/"
running = {}
for process in psutil.process_iter(attrs=["pid", "name", "cmdline", "cwd", "create_time", "status"]):
info = process.info
created = info.get("create_time")
if created is None or created < started or info.get("status") == psutil.STATUS_ZOMBIE:
continue
arguments = info.get("cmdline") or []
command = (arguments[0] if arguments else info.get("name") or "").replace("\\", "/")
if not command.lower().endswith((".exe", ".msi")):
continue
if command.lower().startswith("z:/"):
command = command[2:]
if not command.startswith("/"):
cwd = info.get("cwd")
if not cwd:
continue
command = os.path.join(cwd, os.path.basename(command) if ":" in command else command)
command = os.path.normpath(command)
if command.lower().startswith(directory):
running[(info["pid"], created)] = command
return running
def _read_imports(pe: pefile.PE) -> set[str]:
imports = set()
for directory_name in ("DIRECTORY_ENTRY_IMPORT", "DIRECTORY_ENTRY_DELAY_IMPORT"):
@@ -288,9 +319,12 @@ def _analyze_pe(executable: str) -> tuple[str, dict[str, list[str]]]:
try:
optional_header = getattr(pe, "OPTIONAL_HEADER", None)
data_directories = getattr(optional_header, "DATA_DIRECTORY", [])
clr = data_directories[
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR"]
descriptor_index = pefile.DIRECTORY_ENTRY[
"IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR"
]
if not isinstance(descriptor_index, int):
raise IndexError
clr = data_directories[descriptor_index]
if clr.VirtualAddress and ".NET Framework" not in findings.setdefault("Runtimes", []):
findings["Runtimes"].append(".NET Framework")
except (AttributeError, IndexError):
@@ -420,6 +454,8 @@ def _compatibility_suggestions(
)
if uses_directx_8_11 and environment.get("PW_VULKAN_USE") == "0":
suggestions.append("Switch from WineD3D to DXVK for DirectX 8-11.")
if environment.get("PW_USE_OPTISCALER") == "1":
suggestions.append("Temporarily disable OptiScaler in the game settings and retry to check for a conflict.")
if _uses_integrated_gpu(environment):
suggestions.append("Use the discrete GPU.")
runtimes = findings.get("Runtimes", [])
@@ -428,6 +464,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 +540,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",
+22 -1
View File
@@ -1,6 +1,7 @@
"""Base configuration class for PortProtonQt."""
import os
import configparser
import shutil
from pathlib import Path
from portprotonqt.logger import get_logger
from portprotonqt.config.validators import validate_string, validate_int, validate_bool, ValidationError
@@ -24,7 +25,27 @@ THEMES_DIRS = [
]
# Cache paths
CACHE_DIR = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")) / "PortProtonQt"
CACHE_DIR = XDG_DATA_HOME / "PortProtonQt" / "cache"
LEGACY_CACHE_DIR = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")) / "PortProtonQt"
# Remove after the legacy cache migration period.
def _migrate_legacy_cache() -> None:
"""Move the legacy XDG cache into persistent application data."""
if not LEGACY_CACHE_DIR.is_dir():
return
try:
if not CACHE_DIR.exists():
CACHE_DIR.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(LEGACY_CACHE_DIR), str(CACHE_DIR))
return
shutil.copytree(LEGACY_CACHE_DIR, CACHE_DIR, dirs_exist_ok=True)
shutil.rmtree(LEGACY_CACHE_DIR)
except OSError as error:
logger.warning("Failed to migrate legacy cache directory: %s", error)
_migrate_legacy_cache()
# Module-level cache storage
_config_cache: dict[str, configparser.ConfigParser] = {}
+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")
+66 -9
View File
@@ -164,13 +164,19 @@ def get_portproton_scripts_path() -> str | None:
if appimage_path:
appimage_root = Path(sys.executable).resolve().parent.parent
prefixes.append(("AppImage executable", appimage_root))
if os.getenv("FLATPAK_ID"):
prefixes.append(("Flatpak package", Path("/app")))
prefixes.append(("system package", Path("/usr")))
xdg_scripts_dirs = [
("XDG data", Path(data_dir) / "portproton" / "scripts")
for data_dir in os.getenv(
"XDG_DATA_DIRS", "/usr/local/share:/usr/share"
).split(os.pathsep)
if data_dir
]
scripts_dirs = (
("repository", Path.cwd() / "build-aux" / "share" / "portproton" / "scripts"),
*[(source, prefix / "share" / "portproton" / "scripts") for source, prefix in prefixes],
*xdg_scripts_dirs,
)
for source, scripts_dir in scripts_dirs:
if (scripts_dir / "start.sh").is_file():
@@ -223,7 +229,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 +289,12 @@ 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:
# Remove after the legacy shortcut migration period.
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 +313,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"),
@@ -434,7 +455,42 @@ def migrate_legacy_shortcut(portproton_path: str, desktop_dir: str | None = None
def parse_desktop_entry(file_path: str) -> configparser.SectionProxy | None:
"""Read and parse a .desktop file using configparser."""
cp = configparser.ConfigParser(interpolation=None)
cp.read(file_path, encoding="utf-8")
content = ""
try:
content = Path(file_path).read_text(encoding="utf-8")
cp.read_string(content, source=file_path)
except configparser.ParsingError:
lines = []
for line in content.splitlines():
stripped = line.strip()
if stripped and "=" not in line and not stripped.startswith(("[", "#", ";")):
if not lines or "=" not in lines[-1]:
return None
lines[-1] = f"{lines[-1]} {stripped}"
continue
lines.append(line)
repaired_content = "\n".join(lines) + ("\n" if content.endswith("\n") else "")
try:
cp.read_string(repaired_content, source=file_path)
except configparser.Error as error:
logger.warning("Failed to parse desktop file %s: %s", file_path, error)
return None
desktop_path = Path(file_path)
temp_path = desktop_path.with_name(f".{desktop_path.name}.tmp")
try:
temp_path.write_text(repaired_content, encoding="utf-8")
os.chmod(temp_path, desktop_path.stat().st_mode)
os.replace(temp_path, desktop_path)
logger.warning("Repaired malformed desktop file %s", file_path)
except OSError as error:
logger.warning("Failed to save repaired desktop file %s: %s", file_path, error)
try:
temp_path.unlink(missing_ok=True)
except OSError as cleanup_error:
logger.debug("Failed to remove temporary file %s: %s", temp_path, cleanup_error)
except (configparser.Error, OSError, UnicodeError) as error:
logger.warning("Failed to parse desktop file %s: %s", file_path, error)
return None
if "Desktop Entry" not in cp:
return None
return cp["Desktop Entry"]
@@ -532,8 +588,8 @@ def create_desktop_file(
) -> tuple[str, str, str] | None:
"""Create desktop entry content, destination path and icon path for a game."""
portproton_path = get_portproton_location()
is_gog_uri = re.fullmatch(r"gog://launch/\d+", exe_path) is not None
if not is_gog_uri and not os.path.isfile(exe_path):
is_store_uri = re.fullmatch(r"(?:gog|egs)://launch/[^/]+", exe_path) is not None
if not is_store_uri and not os.path.isfile(exe_path):
logger.error("Executable not found: %s", exe_path)
return None
if not portproton_path:
@@ -542,11 +598,12 @@ def create_desktop_file(
if not game_name:
game_name = os.path.splitext(os.path.basename(exe_path))[0]
game_name = " ".join(game_name.splitlines()).strip()
base_path = os.path.join(portproton_path, "data")
icon_name = _sanitize_icon_name(game_name)
has_icon_source = bool(icon_source and os.path.isfile(icon_source))
icon_path = (
"applications-games" if is_gog_uri and not has_icon_source
"applications-games" if is_store_uri and not has_icon_source
else os.path.join(base_path, "img", f"{icon_name}.png")
)
desktop_path = os.path.join(portproton_path, f"{game_name}.desktop")
+56 -1
View File
@@ -348,6 +348,31 @@ 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_force_english(self) -> bool:
"""Get forced English language setting."""
cp = self._read_config()
if cp is None:
return False
try:
return cp.getboolean(self._section, "force_english", fallback=False)
except (configparser.Error, ValueError) as e:
logger.warning("Error reading force_english: %s", e)
return False
def set_force_english(self, enabled: bool) -> None:
"""Set forced English language setting."""
validate_bool(enabled, "force_english")
self._save_value("force_english", enabled, "bool")
def get_badge_view_mode(self) -> str:
"""Get badge view mode ('detailed' or 'compact')."""
mode = self._get_str("badge_view_mode", "detailed")
@@ -358,6 +383,27 @@ class UIConfig(BaseConfig):
validate_string(mode, "badge_view_mode", min_len=1, max_len=20)
self._save_value("badge_view_mode", mode, "str")
def get_library_layout_mode(self, theme_mode: str = "grid") -> str:
"""Get the selected library layout or the theme default."""
modes = {"grid", "list", "vertical", "horizontal", "horizontal_top"}
mode = self._get_str("library_layout_mode", "theme")
return mode if mode in modes else theme_mode.lower()
def set_library_layout_mode(self, mode: str) -> None:
"""Set the library layout mode."""
validate_string(mode, "library_layout_mode", min_len=1, max_len=20)
self._save_value("library_layout_mode", mode, "str")
def get_horizontal_card_orientation(self, theme_mode: str) -> str:
"""Get the horizontal card orientation or the theme default."""
mode = self._get_str("horizontal_card_orientation", "theme")
return mode if mode in {"horizontal", "vertical"} else theme_mode
def set_horizontal_card_orientation(self, mode: str) -> None:
"""Set the horizontal card orientation."""
validate_string(mode, "horizontal_card_orientation", min_len=1, max_len=20)
self._save_value("horizontal_card_orientation", mode, "str")
def get_economy_mode(self) -> bool:
"""Get economy mode setting."""
return self._get_download_bool("economy_mode", False)
@@ -421,7 +467,7 @@ class UIConfig(BaseConfig):
def get_auto_download_ppdb(self) -> bool:
"""Get PPDB auto-download setting."""
return self._get_download_bool("auto_download_ppdb", True)
return self._get_download_bool("auto_download_ppdb", False)
def set_auto_download_ppdb(self, enabled: bool) -> None:
"""Set PPDB auto-download setting."""
@@ -464,6 +510,15 @@ class UIConfig(BaseConfig):
validate_bool(enabled, "crash_reports_enabled")
self._save_value("crash_reports_enabled", enabled, "bool")
def get_disable_alt_i586_dependency_check(self) -> bool:
"""Get ALT i586 dependency check setting."""
return self._get_bool("disable_alt_i586_dependency_check", False)
def set_disable_alt_i586_dependency_check(self, enabled: bool) -> None:
"""Set ALT i586 dependency check setting."""
validate_bool(enabled, "disable_alt_i586_dependency_check")
self._save_value("disable_alt_i586_dependency_check", enabled, "bool")
def load_theme_metainfo(theme_name: str) -> dict:
"""Load theme metadata from metainfo.ini."""
+227 -57
View File
@@ -2,6 +2,7 @@ import os
import glob
import shutil
import tempfile
from typing import TYPE_CHECKING
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplication, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
@@ -34,6 +35,9 @@ from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
logger = get_logger(__name__)
if TYPE_CHECKING:
from portprotonqt.game_card import GameCard
class ContextMenuSignals(QObject):
"""Signals for thread-safe UI updates from worker threads."""
show_warning_dialog = Signal(str, str)
@@ -456,6 +460,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,16 +471,16 @@ 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
is_uninstalled_gog = (
game_card.game_source == "gog"
and game_card.exec_line.startswith("gog://install/")
is_uninstalled_store_game = (
game_card.game_source in ("gog", "egs")
and game_card.exec_line.startswith(("gog://install/", "egs://install/"))
)
is_running = self._is_game_running(game_card)
if not is_uninstalled_gog:
if not is_uninstalled_store_game:
action_text = _("Stop Game") if is_running else _("Launch Game")
action_icon = "stop" if is_running else "play"
launch_action = menu.addAction(self._get_safe_icon(action_icon), action_text)
@@ -543,6 +548,83 @@ class ContextMenuManager:
import_action.triggered.connect(
lambda: self.parent._import_gog_game(game)
)
edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
edit_action.triggered.connect(
lambda: self._edit_id_shortcut(
game_card.name,
game_card.cover_path,
("gog", str(game_card.appid)),
)
)
if game_card.game_source == "egs":
app_id = str(game_card.appid)
if game_card.exec_line.startswith("egs://launch/"):
desktop_dir = QStandardPaths.writableLocation(
QStandardPaths.StandardLocation.DesktopLocation
)
desktop_path = self._get_shortcut_path(game_card.name, desktop_dir)
desktop_exists = os.path.exists(desktop_path)
desktop_action = menu.addAction(
self._get_safe_icon("delete" if desktop_exists else "desktop"),
_("Remove from Desktop") if desktop_exists else _("Add to Desktop"),
)
desktop_action.triggered.connect(
lambda: self.remove_from_desktop(game_card.name)
if desktop_exists
else self.add_to_desktop(game_card.name, game_card.exec_line)
)
menu_path = self._get_menu_shortcut_path(game_card.name)
menu_exists = os.path.exists(menu_path)
menu_action = menu.addAction(
self._get_safe_icon("delete" if menu_exists else "menu"),
_("Remove from Menu") if menu_exists else _("Add to Menu"),
)
menu_action.triggered.connect(
lambda: self.remove_from_menu(game_card.name)
if menu_exists
else self.add_to_menu(game_card.name, game_card.exec_line)
)
update_action = menu.addAction(
self._get_safe_icon("update"), _("Update")
)
update_action.triggered.connect(
lambda: self.parent._update_egs_game(app_id)
)
repair_action = menu.addAction(
self._get_safe_icon("update"), _("Repair")
)
repair_action.triggered.connect(
lambda: self.parent._repair_egs_game(app_id)
)
eos_enabled = self.parent._is_egs_overlay_enabled(app_id)
eos_action = menu.addAction(
self._get_safe_icon("update"),
_("Disable EOS Overlay")
if eos_enabled else _("Enable EOS Overlay"),
)
eos_action.triggered.connect(
lambda: self.parent._enable_egs_overlay(app_id)
)
delete_action = menu.addAction(
self._get_safe_icon("delete"), _("Delete")
)
delete_action.triggered.connect(
lambda: self.parent._delete_egs_game(app_id)
)
else:
import_action = menu.addAction(
self._get_safe_icon("folder"), _("Import")
)
import_action.triggered.connect(
lambda: self.parent._import_egs_game(app_id)
)
edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
edit_action.triggered.connect(
lambda: self._edit_id_shortcut(
game_card.name, game_card.cover_path, ("egs", app_id)
)
)
if game_card.game_source == "steam":
desktop_dir = QStandardPaths.writableLocation(
@@ -632,7 +714,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 +979,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
@@ -941,7 +1025,7 @@ class ContextMenuManager:
if not resolved_exec_line:
return
shortcut_paths = self._get_installed_shortcut_paths(game_name)
exe_path = self._parse_exe_path(resolved_exec_line, game_name)
exe_path = extract_exec_target_path(resolved_exec_line)
if not self._remove_file(
desktop_path,
_("Failed to delete .desktop file: {error}"),
@@ -994,15 +1078,15 @@ class ContextMenuManager:
"""Add the game desktop entry to the XDG applications directory."""
if not self._check_portproton():
return
is_gog_uri = str(exec_line).startswith("gog://launch/")
is_store_uri = str(exec_line).startswith(("gog://launch/", "egs://launch/"))
desktop_path = self._get_desktop_path(game_name)
if not is_gog_uri and not os.path.exists(desktop_path):
if not is_store_uri and not os.path.exists(desktop_path):
self.signals.show_warning_dialog.emit(
_("Error"),
_("No .desktop file found for '{game_name}'").format(game_name=game_name)
)
return
if not is_gog_uri:
if not is_store_uri:
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
@@ -1010,9 +1094,9 @@ class ContextMenuManager:
if not exec_line:
return
icon_source = None
if is_gog_uri:
app_id = str(exec_line).rsplit("/", 1)[-1]
icon_source = self.parent.gog_api.get_launch_target(app_id)
if is_store_uri:
source, app_id = exec_line.split("://", 1)[0], str(exec_line).rsplit("/", 1)[-1]
icon_source = getattr(self.parent, f"{source}_api").get_launch_target(app_id)
shortcut = create_desktop_file(exec_line, game_name, icon_source)
if not shortcut:
return
@@ -1065,16 +1149,16 @@ class ContextMenuManager:
if not self._check_portproton():
return
is_gog_uri = str(exec_line).startswith("gog://launch/")
is_store_uri = str(exec_line).startswith(("gog://launch/", "egs://launch/"))
desktop_path = self._get_desktop_path(game_name)
if not is_gog_uri and not os.path.exists(desktop_path):
if not is_store_uri and not os.path.exists(desktop_path):
self.signals.show_warning_dialog.emit(
_("Error"),
_("No .desktop file found for '{game_name}'").format(game_name=game_name)
)
return
# Ensure icon exists
if not is_gog_uri:
if not is_store_uri:
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
@@ -1082,9 +1166,9 @@ class ContextMenuManager:
if not exec_line:
return
icon_source = None
if is_gog_uri:
app_id = str(exec_line).rsplit("/", 1)[-1]
icon_source = self.parent.gog_api.get_launch_target(app_id)
if is_store_uri:
source, app_id = exec_line.split("://", 1)[0], str(exec_line).rsplit("/", 1)[-1]
icon_source = getattr(self.parent, f"{source}_api").get_launch_target(app_id)
shortcut = create_desktop_file(exec_line, game_name, icon_source)
if not shortcut:
return
@@ -1218,29 +1302,34 @@ class ContextMenuManager:
):
self._update_desktop_database()
def _edit_steam_shortcut(self, game_name: str, appid: int | str, cover_path: str) -> None:
appid_str = str(appid).strip()
if not appid_str.isdigit():
logger.warning("Invalid Steam appid for shortcut editing: %s", appid)
def _edit_id_shortcut(self, game_name: str, cover_path: str,
store: tuple[str, str]) -> None:
source, app_id = store
app_id = str(app_id).strip()
invalid_path = app_id in {".", ".."} or os.path.basename(app_id) != app_id
if not app_id or invalid_path or (source == "steam" and not app_id.isdigit()):
logger.warning("Invalid %s game ID for shortcut editing: %s", source, app_id)
return
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
game_name=game_name, cover_path=cover_path, steam_appid=appid_str)
game_name=game_name, cover_path=cover_path, steam_appid=app_id)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
new_name = dialog.nameEdit.text().strip()
if not new_name:
return
selected_cover = dialog.coverEdit.text().strip()
if not os.path.isfile(selected_cover) and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path):
if (not os.path.isfile(selected_cover) and dialog.last_cover_path
and os.path.isfile(dialog.last_cover_path)):
selected_cover = dialog.last_cover_path
xdg_data_home = os.getenv(
"XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")
)
game_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", appid_str)
default_data_home = os.path.join(os.path.expanduser("~"), ".local", "share")
xdg_data_home = os.getenv("XDG_DATA_HOME", default_data_home)
game_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data",
f"{source}-{app_id}")
try:
os.makedirs(game_dir, exist_ok=True)
clean_name = new_name.replace("\r", " ").replace("\n", " ").strip()
with open(os.path.join(game_dir, "metadata.txt"), "w", encoding="utf-8") as metadata_file:
metadata_path = os.path.join(game_dir, "metadata.txt")
with open(metadata_path, "w", encoding="utf-8") as metadata_file:
metadata_file.write(f"name={clean_name}\n")
if selected_cover and os.path.isfile(selected_cover):
extension = os.path.splitext(selected_cover)[1].lower()
@@ -1252,22 +1341,90 @@ class ContextMenuManager:
shutil.copyfile(selected_cover, target_cover)
cover_path = target_cover
except OSError as e:
self.signals.show_warning_dialog.emit(
_("Error"), _("Failed to save custom data: {error}").format(error=str(e))
)
message = _("Failed to save custom data: {error}").format(error=str(e))
self.signals.show_warning_dialog.emit(_("Error"), message)
return
for game in self.game_library_manager.games:
if str(game[3]) != appid_str or game[12] != "steam":
if str(game[3]) != app_id or game[12] != source:
continue
updated_game = list(game)
updated_game[0] = new_name
updated_game[2] = cover_path
self.game_library_manager.replace_game_incremental(
game[0], game[5], tuple(updated_game)
)
self.game_library_manager.replace_game_incremental(game[0], game[5], tuple(updated_game))
QTimer.singleShot(0, self.game_library_manager.load_visible_images)
return
def handle_missing_executable(self, game_card: "GameCard") -> None:
"""Offer removal or shortcut repair when the launch file is missing."""
msg_box = QMessageBox(self.parent)
msg_box.setIcon(QMessageBox.Icon.Question)
msg_box.setWindowTitle(_("Executable not found"))
msg_box.setText(_("Executable not found: {0}").format(game_card.missing_executable_path))
delete_button = msg_box.addButton(_("Delete from PortProton"), QMessageBox.ButtonRole.DestructiveRole)
edit_button = msg_box.addButton(_("Select another executable"), QMessageBox.ButtonRole.ActionRole)
msg_box.addButton(QMessageBox.StandardButton.Cancel)
for button in msg_box.buttons():
button.ensurePolished()
button.setMinimumWidth(button.sizeHint().width())
msg_box.exec()
if msg_box.clickedButton() == delete_button:
self.delete_game(game_card.name, game_card.exec_line)
elif msg_box.clickedButton() == edit_button:
self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)
def _relocate_shortcut_data(self, old_exe: str, new_exe: str) -> list[tuple[str, str]]:
"""Move path-specific data, refusing to overwrite another game's data."""
if os.path.abspath(old_exe) == os.path.abspath(new_exe):
return []
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
custom_root = os.path.join(data_home, "PortProtonQt", "custom_data")
old_folder = resolve_custom_data_dir(custom_root, old_exe)
new_folder = os.path.join(custom_root, get_custom_data_dir_name(new_exe))
paths = [(old_folder, new_folder), (old_exe + ".ppdb", new_exe + ".ppdb")]
moves = [(source, target) for source, target in paths if os.path.exists(source)]
for _source, target in moves:
if os.path.lexists(target):
raise FileExistsError(_("File already exists: {file_name}").format(file_name=target))
completed = []
try:
for source, target in moves:
shutil.move(source, target)
completed.append((source, target))
except OSError:
for source, target in reversed(completed):
shutil.move(target, source)
raise
return completed
def _relocate_shortcut_statistics(self, old_exe: str, new_exe: str) -> None:
"""Keep playtime and last launch associated with a repaired shortcut."""
from portprotonqt.time_utils import get_last_launch_path, get_statistics_path
old_name = os.path.splitext(os.path.basename(old_exe))[0]
new_name = os.path.splitext(os.path.basename(new_exe))[0]
last_launch_path = get_last_launch_path()
records = (
(get_statistics_path(), os.path.normpath(old_exe).replace(" ", "#@_@#"), new_exe.replace(" ", "#@_@#")),
(last_launch_path, old_name, new_name),
)
for path, old_key, new_key in records:
if not os.path.isfile(path):
continue
try:
with open(path, encoding="utf-8") as stream:
lines = stream.readlines()
updated = []
for line in lines:
parts = line.rsplit(maxsplit=1) if path == last_launch_path else line.split(maxsplit=1)
if len(parts) == 2 and parts[0] == old_key:
line = f"{new_key} {parts[1].rstrip()}\n"
updated.append(line)
if updated != lines:
with open(path, "w", encoding="utf-8") as stream:
stream.writelines(updated)
except OSError as error:
logger.warning("Failed to relocate statistics for %s: %s", old_exe, error)
def edit_game_shortcut(self, game_name, exec_line, cover_path, appid=None):
"""
Opens a dialog allowing the user to edit a game shortcut in edit mode to modify an existing .desktop file.
@@ -1278,7 +1435,7 @@ class ContextMenuManager:
cover_path: The path to the game's cover image.
"""
if appid is not None:
self._edit_steam_shortcut(game_name, appid, cover_path)
self._edit_id_shortcut(game_name, cover_path, ("steam", str(appid)))
return
if not self._check_portproton():
return
@@ -1286,7 +1443,7 @@ class ContextMenuManager:
if not exec_line:
return
old_exec_line = exec_line
exe_path = self._parse_exe_path(exec_line, game_name)
exe_path = extract_exec_target_path(exec_line)
if not exe_path:
return
dialog = AddGameDialog(
@@ -1317,6 +1474,27 @@ class ContextMenuManager:
return
old_path = self._get_desktop_path(game_name)
moved_paths = []
try:
moved_paths = self._relocate_shortcut_data(exe_path, new_exe_path)
with open(new_desktop_path, "w", encoding="utf-8") as f:
f.write(desktop_entry)
os.chmod(new_desktop_path, 0o755)
except OSError as e:
for source, target in reversed(moved_paths):
shutil.move(target, source)
self.signals.show_warning_dialog.emit(
_("Error"),
_("Failed to save .desktop file: {error}").format(error=str(e))
)
return
for source, target in moved_paths:
if new_cover_path.startswith(source + os.sep):
new_cover_path = target + new_cover_path[len(source):]
if dialog.last_cover_path and dialog.last_cover_path.startswith(source + os.sep):
dialog.last_cover_path = target + dialog.last_cover_path[len(source):]
if exe_path != new_exe_path:
self._relocate_shortcut_statistics(exe_path, new_exe_path)
if game_name != new_name and os.path.exists(old_path):
self._remove_file(
old_path,
@@ -1324,17 +1502,6 @@ class ContextMenuManager:
_("Removed old .desktop file for '{game_name}'"),
game_name
)
try:
with open(new_desktop_path, "w", encoding="utf-8") as f:
f.write(desktop_entry)
os.chmod(new_desktop_path, 0o755)
except OSError as e:
self.signals.show_warning_dialog.emit(
_("Error"),
_("Failed to save .desktop file: {error}").format(error=str(e))
)
return
saved_entry = parse_desktop_entry(new_desktop_path)
updated_exec_line = new_exe_path
if saved_entry:
@@ -1395,8 +1562,8 @@ class ContextMenuManager:
old_menu_path = self._get_menu_shortcut_path(game_name)
new_menu_path = self._get_menu_shortcut_path(new_name)
desktop_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
old_desktop_path = os.path.join(desktop_dir, f"{game_name}.desktop")
new_desktop_path_target = os.path.join(desktop_dir, f"{new_name}.desktop")
old_desktop_path = self._get_shortcut_path(game_name, desktop_dir)
new_desktop_path_target = self._get_shortcut_path(new_name, desktop_dir)
if game_name != new_name:
if os.path.exists(old_menu_path):
@@ -1407,19 +1574,22 @@ class ContextMenuManager:
self.remove_from_steam(game_name, new_exe_path, "portproton")
if dialog.add_to_menu_checkbox.isChecked():
if not os.path.exists(new_menu_path):
if exe_path != new_exe_path or not os.path.exists(new_menu_path):
self.add_to_menu(new_name, updated_exec_line)
elif os.path.exists(new_menu_path):
self.remove_from_menu(new_name)
if dialog.add_to_desktop_checkbox.isChecked():
if not os.path.exists(new_desktop_path_target):
if exe_path != new_exe_path or not os.path.exists(new_desktop_path_target):
self.add_to_desktop(new_name, updated_exec_line)
elif os.path.exists(new_desktop_path_target):
self.remove_from_desktop(new_name)
is_in_steam = is_game_in_steam(new_name)
if dialog.add_to_steam_checkbox.isChecked():
if is_in_steam and exe_path != new_exe_path:
self.remove_from_steam(new_name, old_exec_line, "portproton")
is_in_steam = False
if not is_in_steam:
self.add_to_steam(new_name, updated_exec_line, new_cover_path)
elif is_in_steam:
@@ -1503,7 +1673,7 @@ class ContextMenuManager:
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
exe_path = self._parse_exe_path(exec_line, game_name)
exe_path = extract_exec_target_path(exec_line)
if not exe_path:
return
logger.debug("Removing game '%s' from Steam", game_name)
+103 -7
View File
@@ -1,7 +1,7 @@
import os
from PySide6.QtWidgets import QLabel, QPushButton, QStyle, QStyleOptionButton, QWidget, QLayout, QLayoutItem, QScrollArea, QGraphicsOpacityEffect, QComboBox
from PySide6.QtCore import Qt, Signal, QRect, QRectF, QSize, Property, QPropertyAnimation, QEasingCurve, QTimer, QEvent
from PySide6.QtGui import QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor
from PySide6.QtGui import QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
from PySide6.QtSvg import QSvgRenderer
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config import ui_config
@@ -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)
@@ -542,7 +547,8 @@ class AutoSizeButton(QPushButton):
self.current_theme_name = selected_theme
self.theme = self.theme_manager.apply_theme(selected_theme)
if padding is None:
self._uses_theme_padding = padding is None
if self._uses_theme_padding:
padding = getattr(self.theme, 'autoSizeButtonPadding', 20)
self._pad_top, self._pad_bottom, self._pad_left, self._pad_right = self._normalize_padding(padding)
@@ -569,6 +575,18 @@ class AutoSizeButton(QPushButton):
self.adjustFontSize()
def refresh_theme(self, theme: object) -> None:
"""Refresh sizing values supplied by the active theme."""
self.theme = theme
if not self._uses_theme_padding:
return
padding = getattr(theme, "autoSizeButtonPadding", 20)
self._pad_top, self._pad_bottom, self._pad_left, self._pad_right = (
self._normalize_padding(padding)
)
self.adjustFontSize()
self.updateGeometry()
def _extract_icon_name(self, icon_path):
if isinstance(icon_path, str):
base = os.path.basename(icon_path)
@@ -671,7 +689,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,21 +855,33 @@ 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._h_scrollbar = self.horizontalScrollBar()
self._v_scrollbar.installEventFilter(self)
self._h_scrollbar.installEventFilter(self)
self._opacity_effect = QGraphicsOpacityEffect(self._v_scrollbar)
self._opacity_effect.setOpacity(0.0)
self._v_scrollbar.setGraphicsEffect(self._opacity_effect)
self._h_opacity_effect = QGraphicsOpacityEffect(self._h_scrollbar)
self._h_opacity_effect.setOpacity(0.0)
self._h_scrollbar.setGraphicsEffect(self._h_opacity_effect)
self._fade_animation = QPropertyAnimation(self._opacity_effect, b"opacity")
self._fade_animation.setDuration(self.fade_duration_ms)
self._fade_animation.setEasingCurve(QEasingCurve.Type.InOutCubic)
self._h_fade_animation = QPropertyAnimation(self._h_opacity_effect, b"opacity")
self._h_fade_animation.setDuration(self.fade_duration_ms)
self._h_fade_animation.setEasingCurve(QEasingCurve.Type.InOutCubic)
self._is_visible = False
self._scroll_needed = False
self._h_is_visible = False
self._h_scroll_needed = False
self._apply_visible_style()
@@ -859,7 +889,12 @@ class AutoHideScrollArea(QScrollArea):
self._hide_timer.setSingleShot(True)
self._hide_timer.timeout.connect(self._start_fade_out)
self._h_hide_timer = QTimer(self)
self._h_hide_timer.setSingleShot(True)
self._h_hide_timer.timeout.connect(self._start_horizontal_fade_out)
self._v_scrollbar.valueChanged.connect(self._on_scroll)
self._h_scrollbar.valueChanged.connect(self._on_horizontal_scroll)
self.viewport().installEventFilter(self)
initial_widget = self.widget()
@@ -870,6 +905,25 @@ class AutoHideScrollArea(QScrollArea):
def _apply_visible_style(self) -> None:
self._v_scrollbar.setStyleSheet(self.theme.SCROLL_STYLE)
self._h_scrollbar.setStyleSheet(self.theme.SCROLL_STYLE)
def _start_horizontal_fade_in(self) -> None:
if not self._h_scroll_needed:
return
self._h_fade_animation.stop()
self._h_fade_animation.setStartValue(self._h_opacity_effect.opacity())
self._h_fade_animation.setEndValue(1.0)
self._h_fade_animation.start()
self._h_is_visible = True
def _start_horizontal_fade_out(self) -> None:
if not self._h_scroll_needed:
return
self._h_fade_animation.stop()
self._h_fade_animation.setStartValue(self._h_opacity_effect.opacity())
self._h_fade_animation.setEndValue(0.0)
self._h_fade_animation.start()
self._h_is_visible = False
def _start_fade_in(self) -> None:
if not self._scroll_needed:
@@ -894,17 +948,28 @@ class AutoHideScrollArea(QScrollArea):
self._opacity_effect.setOpacity(opacity)
self._is_visible = opacity == 1.0
def _set_horizontal_opacity_immediately(self, opacity: float) -> None:
self._h_fade_animation.stop()
self._h_opacity_effect.setOpacity(opacity)
self._h_is_visible = opacity == 1.0
def _update_scroll_needed(self) -> None:
widget = self.widget()
if widget is None:
self._scroll_needed = False
self._h_scroll_needed = False
self._set_opacity_immediately(0.0)
self._set_horizontal_opacity_immediately(0.0)
self._hide_timer.stop()
self._h_hide_timer.stop()
return
content_height = widget.sizeHint().height()
content_width = widget.sizeHint().width()
viewport_height = self.viewport().height()
viewport_width = self.viewport().width()
self._scroll_needed = content_height > viewport_height
self._h_scroll_needed = content_width > viewport_width
if not self._scroll_needed:
self._set_opacity_immediately(0.0)
@@ -913,23 +978,46 @@ class AutoHideScrollArea(QScrollArea):
if not self._is_visible:
self._set_opacity_immediately(0.0)
if not self._h_scroll_needed:
self._set_horizontal_opacity_immediately(0.0)
self._h_hide_timer.stop()
elif not self._h_is_visible:
self._set_horizontal_opacity_immediately(0.0)
def _on_scroll(self, value: int) -> None:
if not self._scroll_needed:
return
self._start_fade_in()
self._hide_timer.start(self.hide_delay_ms)
def _on_horizontal_scroll(self, value: int) -> None:
if not self._h_scroll_needed:
return
self._start_horizontal_fade_in()
self._h_hide_timer.start(self.hide_delay_ms)
def enterEvent(self, event):
if self._scroll_needed:
self._start_fade_in()
self._hide_timer.start(self.hide_delay_ms)
if self._h_scroll_needed:
self._start_horizontal_fade_in()
self._h_hide_timer.start(self.hide_delay_ms)
def leaveEvent(self, event):
if self._scroll_needed and self._is_visible:
self._hide_timer.start(self.hide_delay_ms)
if self._h_scroll_needed and self._h_is_visible:
self._h_hide_timer.start(self.hide_delay_ms)
def wheelEvent(self, event: QWheelEvent) -> None:
if self._v_scrollbar.maximum() == 0 and self._h_scrollbar.maximum() > 0:
self._h_scrollbar.event(event)
return
super().wheelEvent(event)
def eventFilter(self, obj, event):
if not hasattr(self, "_v_scrollbar"):
if not hasattr(self, "_v_scrollbar") or not hasattr(self, "_h_scrollbar"):
return super().eventFilter(obj, event)
if event.type() == QEvent.Type.Resize:
@@ -942,6 +1030,14 @@ class AutoHideScrollArea(QScrollArea):
elif event.type() == QEvent.Type.Leave:
if self._scroll_needed and self._is_visible:
self._hide_timer.start(self.hide_delay_ms)
elif obj == self._h_scrollbar:
if event.type() == QEvent.Type.Enter:
if self._h_scroll_needed:
self._start_horizontal_fade_in()
self._h_hide_timer.start(self.hide_delay_ms)
elif event.type() == QEvent.Type.Leave:
if self._h_scroll_needed and self._h_is_visible:
self._h_hide_timer.start(self.hide_delay_ms)
return super().eventFilter(obj, event)
def setWidget(self, widget: QWidget | None) -> None:
+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)
+121 -15
View File
@@ -86,6 +86,9 @@ class DetailPageManager:
self._gog_size_requests: dict[
str, tuple[QProcess, list[list[Callable[[], QLabel | None]]]]
] = {}
self._egs_size_requests: dict[
str, tuple[QProcess, list[list[Callable[[], QLabel | None]]]]
] = {}
def openGameDetailPage(self, game_data: dict) -> None:
"""Open detailed game information page."""
@@ -310,6 +313,7 @@ class DetailPageManager:
game_info_layout.addLayout(first_row)
self._setup_gog_size_data(game_data, game_info_layout)
self._setup_egs_size_data(game_data, game_info_layout)
hltb_layout = QHBoxLayout()
hltb_layout.setSpacing(10)
@@ -365,6 +369,75 @@ class DetailPageManager:
)
process.start()
def _setup_egs_size_data(
self, game_data: dict, game_info_layout: QVBoxLayout
) -> None:
exec_line = str(game_data.get("exec_line", ""))
if str(game_data.get("game_source", "")).lower() != "egs":
return
if not exec_line.startswith("egs://install/"):
return
row = QHBoxLayout()
values = []
for title_text in (_("Download"), _("Size")):
title = QLabel(title_text.upper())
title.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE)
value = QLabel("—")
value.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE)
row.addWidget(title)
row.addWidget(value)
row.addSpacing(30)
values.append(weakref.ref(value))
game_info_layout.addLayout(row)
app_id = exec_line.rsplit("/", 1)[-1]
api = self.main_window.egs_api
cached = api.get_cached_download_sizes(app_id)
if cached is not None:
self._set_gog_size_values(values, cached)
return
request = self._egs_size_requests.get(app_id)
if request is not None:
request[1].append(values)
return
try:
command = api.build_command(["info", app_id, "--json", "--platform", "Windows"])
except (FileNotFoundError, OSError) as error:
logger.warning("Failed to get EGS download size: %s", error)
return
process = QProcess(self.main_window)
process.setProgram(command[0])
process.setArguments(command[1:])
environment = QProcessEnvironment.systemEnvironment()
environment.insert("LEGENDARY_CONFIG_PATH", str(api.config_dir))
process.setProcessEnvironment(environment)
self._egs_size_requests[app_id] = (process, [values])
process.finished.connect(
lambda code, _status: self._finish_egs_size_request(app_id, code)
)
process.start()
def _finish_egs_size_request(self, app_id: str, code: int) -> None:
request = self._egs_size_requests.pop(app_id, None)
if request is None:
return
process, value_groups = request
if code != 0:
logger.warning("legendary info exited with code %s", code)
process.deleteLater()
return
try:
sizes = self.main_window.egs_api.parse_download_sizes(
bytes(process.readAllStandardOutput().data())
)
except (ValueError, TypeError) as error:
logger.warning("Failed to parse EGS download size: %s", error)
process.deleteLater()
return
self.main_window.egs_api.save_download_sizes(app_id, sizes)
for value_refs in value_groups:
self._set_gog_size_values(value_refs, sizes)
process.deleteLater()
def _finish_gog_size_request(self, app_id: str, code: int) -> None:
request = self._gog_size_requests.pop(app_id, None)
if request is None:
@@ -531,23 +604,32 @@ class DetailPageManager:
current_exe = self._get_current_exe(exec_line)
play_button = self._create_play_button(exec_line, current_exe)
if str(game_source).lower() == "gog" and exec_line.startswith("gog://install/"):
source = str(game_source).lower()
install_uri = exec_line.startswith(f"{source}://install/")
if source in {"gog", "egs"} and install_uri:
play_button.setText(_("Install"))
play_button.setIcon(
self.main_window.theme_manager.get_icon("update", as_path=True)
)
buttons_layout.addWidget(play_button)
if str(game_source).lower() == "steam" and appid:
if source in {"steam", "gog", "egs"} and appid:
edit_button = self._make_action_button(
_("Edit Shortcut"),
self.main_window.theme_manager.get_icon("edit", as_path=True),
)
edit_button.clicked.connect(
lambda: self.main_window.context_menu_manager.edit_game_shortcut(
game_name, exec_line, cover_path, appid
if source == "steam":
edit_button.clicked.connect(
lambda: self.main_window.context_menu_manager.edit_game_shortcut(
game_name, exec_line, cover_path, appid
)
)
else:
edit_button.clicked.connect(
lambda: self.main_window.context_menu_manager._edit_id_shortcut(
game_name, cover_path, (source, str(appid))
)
)
)
buttons_layout.addWidget(edit_button)
elif self._has_game_shortcut(game_name):
edit_button = self._make_action_button(
@@ -570,7 +652,7 @@ class DetailPageManager:
)
buttons_layout.addWidget(open_folder_button)
elif (
str(game_source).lower() != "gog"
str(game_source).lower() not in {"gog", "egs"}
and not self._has_game_shortcut(game_name)
):
add_button = self._make_action_button(
@@ -588,21 +670,26 @@ class DetailPageManager:
if str(game_source).lower() == "portproton":
self._add_portproton_buttons(buttons_layout, exec_line)
elif (
str(game_source).lower() == "gog"
source in {"gog", "egs"}
and appid
and exec_line.startswith("gog://launch/")
and exec_line.startswith(f"{source}://launch/")
):
gog_exe = self.main_window.gog_api.get_launch_target(str(appid))
if not gog_exe:
store_api = (
self.main_window.gog_api if source == "gog"
else self.main_window.egs_api
)
store_exe = store_api.get_launch_target(str(appid))
if not store_exe:
return buttons_layout
self.main_window.gog_api.ensure_launch_parameters(str(appid))
if source == "gog":
store_api.ensure_launch_parameters(str(appid))
settings_button = self._make_action_button(
_("Settings"),
self.main_window.theme_manager.get_icon("settings", as_path=True),
)
settings_button.clicked.connect(
lambda: self.main_window.open_exe_settings(
gog_exe, game_source="gog",
store_exe, game_source=source,
)
)
buttons_layout.addWidget(settings_button)
@@ -610,7 +697,9 @@ class DetailPageManager:
_("Create Log"),
self.main_window.theme_manager.get_icon("edit", as_path=True),
)
log_button.clicked.connect(lambda: self.toggleDebugLog(gog_exe, log_button))
log_button.clicked.connect(
lambda: self.toggleDebugLog(store_exe, log_button)
)
buttons_layout.addWidget(log_button)
self._debug_log_button = log_button
folder_button = self._make_action_button(
@@ -618,7 +707,7 @@ class DetailPageManager:
self.main_window.theme_manager.get_icon("search", as_path=True),
)
folder_button.clicked.connect(
lambda: self._open_executable_folder(gog_exe)
lambda: self._open_executable_folder(store_exe)
)
buttons_layout.addWidget(folder_button)
elif str(game_source).lower() == "steam" and appid:
@@ -833,17 +922,32 @@ class DetailPageManager:
return_tab_index = getattr(self, "_return_to_tab_index", 0)
current_page = self._current_detail_page
running_button = self.main_window.current_running_button
debug_log_button = self._debug_log_button
replace_running_button = (
current_page is not None
and running_button is not None
and current_page.isAncestorOf(running_button)
)
replace_debug_log_button = (
current_page is not None
and debug_log_button is not None
and current_page.isAncestorOf(debug_log_button)
)
self._remove_current_detail_page()
if source_kind == "autoinstall":
self.openAutoInstallDetailPage(source_data)
else:
self.openGameDetailPage(source_data)
self._return_to_tab_index = return_tab_index
if (
replace_debug_log_button
and self.debug_log_manager.is_running
and self._debug_log_button is not None
):
self._debug_log_button.setText(_("Stop Log"))
icon = self.main_window.theme_manager.get_icon("stop", as_path=True)
if icon:
self._debug_log_button.setIcon(icon)
if replace_running_button and self._current_detail_page is not None:
play_button = self._find_play_button(self._current_detail_page)
if play_button is not None:
@@ -1344,6 +1448,8 @@ class DetailPageManager:
resolved_exe_path = self.main_window.resolve_launch_file_path(exe_path)
if resolved_exe_path is None:
return
if not self.main_window._check_alt_i586_dependencies_before_launch():
return
start_command = get_portproton_start_command()
if not start_command:
+76 -20
View File
@@ -4,9 +4,9 @@ import os
import re
from weakref import WeakKeyDictionary
from collections.abc import Callable
from PySide6.QtWidgets import QWidget, QApplication, QLabel, QGraphicsOpacityEffect
from PySide6.QtWidgets import QWidget, QApplication, QLabel, QGraphicsBlurEffect, QGraphicsOpacityEffect, QGraphicsPixmapItem, QGraphicsScene
from PySide6.QtCore import QTimer, Qt, QObject, Signal, QPropertyAnimation, QByteArray
from PySide6.QtGui import QPixmap
from PySide6.QtGui import QBrush, QImage, QPainter, QPalette, QPixmap
from portprotonqt.image_utils import get_animated_cover_pixmap
from portprotonqt.image_utils import load_pixmap_async, round_corners, set_animated_cover
@@ -85,7 +85,7 @@ def _set_animated_cover(
detail_page, image_label = cover_widgets
if not set_animated_cover(image_label, cover_path, cover_size[0], cover_size[1], 10):
return False
_setup_palette_stylesheet(detail_page, get_animated_cover_pixmap(image_label), main_window)
setup_cover_background(detail_page, get_animated_cover_pixmap(image_label), main_window)
_reveal_cover(image_label, main_window.theme)
return True
@@ -99,7 +99,7 @@ def _on_pixmap_ready(pixmap: QPixmap, detail_page: QWidget, image_label: QLabel,
image_label.setPixmap(rounded)
_reveal_cover(image_label, main_window.theme)
logger.debug("Pixmap set for imageLabel")
_setup_palette_stylesheet(detail_page, pixmap, main_window)
setup_cover_background(detail_page, pixmap, main_window)
except RuntimeError:
logger.warning("Detail page already deleted, skipping pixmap update")
@@ -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())
@@ -129,25 +138,75 @@ def _reveal_cover(image_label: QLabel, theme) -> None:
animation.start()
def _setup_palette_stylesheet(detail_page: QWidget, pixmap: QPixmap | None, main_window) -> None:
def setup_cover_background(
detail_page: QWidget, pixmap: QPixmap | None, main_window, theme=None
) -> None:
"""Setup palette-based stylesheet after image load."""
if pixmap is None:
return
active_theme = theme or main_window.theme
if getattr(active_theme, "DETAIL_PAGE_BG_MODE", "gradient") == "blur":
_apply_blurred_cover(detail_page, pixmap, active_theme)
return
def on_palette_ready(palette: list) -> None:
_on_palette_ready(palette, detail_page, main_window)
_on_palette_ready(palette, detail_page, main_window, active_theme)
num_colors = getattr(main_window.theme, 'DETAIL_PAGE_PALETTE_COLORS', 5)
num_colors = getattr(active_theme, 'DETAIL_PAGE_PALETTE_COLORS', 5)
if num_colors < 2:
num_colors = 2
main_window.getColorPalette_from_pixmap(pixmap, num_colors=num_colors, callback=on_palette_ready)
def _on_palette_ready(palette: list, detail_page: QWidget, main_window) -> None:
def remove_cover_background(widget: QWidget) -> None:
"""Remove a shared cover-driven background and stop its animation."""
_remove_wave_background(widget)
_detail_backgrounds.remove(widget)
widget.setStyleSheet("")
widget.setAutoFillBackground(False)
if isinstance(widget, QLabel):
widget.clear()
def _on_palette_ready(
palette: list, detail_page: QWidget, main_window, theme
) -> None:
"""Handle palette ready callback."""
if not _is_detail_page_valid(detail_page):
return
_apply_palette_stylesheet(detail_page, palette, main_window)
_apply_palette_stylesheet(detail_page, palette, main_window, theme)
def _apply_blurred_cover(widget: QWidget, pixmap: QPixmap, theme) -> None:
config = theme.DETAIL_PAGE_BACKGROUNDS.get("blur", {})
width, height = config.get("render_resolution", (1280, 720))
scaled = pixmap.scaled(
width, height, Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation,
)
source = scaled.copy(
(scaled.width() - width) // 2, (scaled.height() - height) // 2,
width, height,
)
item = QGraphicsPixmapItem(source)
effect = QGraphicsBlurEffect()
effect.setBlurRadius(config.get("radius", 64))
item.setGraphicsEffect(effect)
scene = QGraphicsScene()
scene.addItem(item)
image = QImage(source.size(), QImage.Format.Format_ARGB32_Premultiplied)
image.fill(Qt.GlobalColor.transparent)
painter = QPainter(image)
scene.render(painter)
painter.end()
background = QPixmap.fromImage(image).scaled(widget.size())
if isinstance(widget, QLabel):
widget.setPixmap(background)
return
palette = widget.palette()
palette.setBrush(QPalette.ColorRole.Window, QBrush(background))
widget.setPalette(palette)
widget.setAutoFillBackground(True)
def _build_palette_stops(dark_palette: list) -> str:
@@ -219,14 +278,16 @@ def _resolve_gradient_config(theme: object) -> dict:
return config
def _apply_palette_stylesheet(detail_page: QWidget, palette: list, main_window) -> None:
def _apply_palette_stylesheet(
detail_page: QWidget, palette: list, main_window, theme
) -> None:
"""Apply palette-based stylesheet to detail page."""
try:
dark_palette = [
main_window.darkenColor(color, factor=200) for color in palette
]
gradient_config = _resolve_gradient_config(main_window.theme)
stops = _resolve_gradient_stops(main_window.theme, dark_palette)
gradient_config = _resolve_gradient_config(theme)
stops = _resolve_gradient_stops(theme, dark_palette)
gradient_type = gradient_config.get("type", "linear")
if gradient_type == 'radial':
@@ -259,7 +320,7 @@ def _apply_palette_stylesheet(detail_page: QWidget, palette: list, main_window)
"""
detail_page.setStyleSheet(stylesheet)
_setup_wave_background(detail_page, dark_palette, main_window.theme)
_setup_wave_background(detail_page, dark_palette, theme)
detail_page.update()
logger.debug("Stylesheet updated with palette")
except RuntimeError:
@@ -278,7 +339,7 @@ def _setup_wave_background(detail_page: QWidget, dark_palette: list, theme) -> N
return
_detail_backgrounds.remove(detail_page)
if bg_mode == "gradient":
if bg_mode not in {"waves", "static_waves"}:
_remove_wave_background(detail_page)
return
@@ -644,11 +705,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):
+8 -23
View File
@@ -84,6 +84,7 @@ def create_content_frame(
) -> tuple[QFrame, QBoxLayout]:
"""Create content frame."""
content_frame = QFrame()
content_frame.setProperty("theme_style_name", "DETAIL_CONTENT_FRAME_STYLE")
content_frame.setStyleSheet(theme.DETAIL_CONTENT_FRAME_STYLE)
content_frame_layout = QBoxLayout(QBoxLayout.Direction.LeftToRight, content_frame)
content_frame_layout.setContentsMargins(20, 20, 20, 20)
@@ -139,7 +140,7 @@ def _add_source_corner(
) -> None:
"""Add source corner badge to cover frame."""
source = game_source.lower()
if source not in ("steam", "gog", "portproton") or theme_manager is None:
if source not in ("steam", "gog", "egs", "portproton") or theme_manager is None:
return
icon_name = f"badge_{source}"
@@ -182,6 +183,7 @@ def create_compact_detail_header(
header_widget.setFixedHeight(cover_frame.height())
title_frame = QFrame(header_widget)
title_frame.setProperty("theme_style_name", "COMPACT_DETAILS_WIDGET_STYLE")
title_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
title_frame.setMaximumHeight(cover_frame.height())
title_frame.setStyleSheet(theme.COMPACT_DETAILS_WIDGET_STYLE)
@@ -192,6 +194,7 @@ def create_compact_detail_header(
title_layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
title_label = QLabel(title)
title_label.setWordWrap(True)
title_label.setProperty("theme_style_name", "COMPACT_DETAIL_PAGE_TITLE_STYLE")
title_label.setStyleSheet(theme.COMPACT_DETAIL_PAGE_TITLE_STYLE)
title_layout.addWidget(title_label, alignment=Qt.AlignmentFlag.AlignVCenter)
header_layout.addWidget(title_frame, stretch=1)
@@ -201,6 +204,7 @@ def create_compact_detail_header(
def create_compact_layout_panel(parent: QWidget, theme, content_layout: QLayout) -> QWidget:
"""Create compact panel for an existing layout."""
panel = QWidget(parent)
panel.setProperty("theme_style_name", "COMPACT_PLAYTIME_WIDGET_STYLE")
panel.setStyleSheet(theme.COMPACT_PLAYTIME_WIDGET_STYLE)
panel.setLayout(content_layout)
content_layout.setContentsMargins(
@@ -218,6 +222,7 @@ def create_compact_description_panel(
) -> QWidget:
"""Create compact description block."""
desc_widget = QWidget(parent)
desc_widget.setProperty("theme_style_name", "COMPACT_DETAILS_WIDGET_STYLE")
desc_widget.setStyleSheet(theme.COMPACT_DETAILS_WIDGET_STYLE)
desc_layout = QVBoxLayout(desc_widget)
desc_layout.setContentsMargins(
@@ -323,28 +328,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,
@@ -419,6 +402,7 @@ def create_details_widget(
) -> QWidget:
"""Create details widget with title, description, and optional content."""
details_widget = QWidget()
details_widget.setProperty("theme_style_name", "DETAILS_WIDGET_STYLE")
details_widget.setStyleSheet(main_window.theme.DETAILS_WIDGET_STYLE)
details_layout = QVBoxLayout(details_widget)
@@ -461,6 +445,7 @@ def create_details_widget(
def _add_details_header(layout: QVBoxLayout, theme, title: str) -> None:
"""Add title and divider line to details layout."""
title_label = QLabel(title)
title_label.setProperty("theme_style_name", "DETAIL_PAGE_TITLE_STYLE")
title_label.setStyleSheet(theme.DETAIL_PAGE_TITLE_STYLE)
layout.addWidget(title_label)
+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)
+233 -86
View File
@@ -2,6 +2,7 @@
import os
import platform
import re
import shutil
import tempfile
import urllib.parse
@@ -11,8 +12,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 +23,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 +33,42 @@ 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')
def sort_wine_entries(entries: list[dict], source_name: str) -> list[dict]:
"""Sort Wine entries, alternating CachyOS and WineLand releases."""
if source_name.lower() != 'proton_cachyos':
return sorted(entries, key=version_sort_key)
release_groups = []
for is_wineland in (False, True):
family_entries = sorted(
(
entry for entry in entries
if ('proton-cachyos-wineland-' in entry.get('name', '').lower())
== is_wineland
),
key=version_sort_key,
)
groups = []
for entry in family_entries:
release_name = re.sub(
r'-(?:x86_64|arm64|aarch64)(?:_v\d+|-wow64)?$',
'', entry.get('name', '').lower(),
)
if not groups or groups[-1][0] != release_name:
groups.append((release_name, []))
groups[-1][1].append(entry)
release_groups.append(groups)
sorted_entries = []
for index in range(max(map(len, release_groups), default=0)):
for groups in release_groups:
if index < len(groups):
sorted_entries.extend(groups[index][1])
return sorted_entries
class ProtonManager(DraggableDialog):
@@ -47,6 +87,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 +104,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 +297,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 +344,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, sort_wine_entries(filtered_entries, source_name)))
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}")
@@ -285,14 +418,13 @@ class ProtonManager(DraggableDialog):
def filter_entries_by_cpu_level(self, entries, source_name):
is_arm = platform.machine().lower().startswith(('arm', 'aarch64'))
if not is_arm:
entries = [
entry for entry in entries
if 'aarch64' not in entry.get('name', '').lower()
and '-arm' not in entry.get('name', '').lower()
and 'aarch64' not in entry.get('url', '').lower()
and '-arm' not in entry.get('url', '').lower()
]
filtered_entries = []
for entry in entries:
entry_info = f"{entry.get('name', '')} {entry.get('url', '')}".lower()
entry_is_arm = 'aarch64' in entry_info or '-arm' in entry_info
if entry_is_arm == is_arm:
filtered_entries.append(entry)
entries = filtered_entries
if source_name.lower() != 'proton_cachyos':
return entries
if self.cpu_level >= 4:
@@ -341,12 +473,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', '')
@@ -356,19 +482,29 @@ class ProtonManager(DraggableDialog):
if url_filename:
entry['filename'] = url_filename
all_entries.append(entry)
all_entries.sort(key=version_sort_key)
table.setRowCount(len(all_entries))
all_entries = sort_wine_entries(all_entries, source_name)
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 +642,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 +716,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 +730,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 +1081,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 +1165,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 +1181,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 +1201,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 +1297,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
+241 -55
View File
@@ -45,6 +45,7 @@ from portprotonqt.dialogs.settings_vkbasalt import VKBASALT_ENV_KEYS, VkBasaltSe
from portprotonqt.localization import _, format_setting_name_for_display
from portprotonqt.logger import get_logger
from portprotonqt.preloader import Preloader
from portprotonqt.qt_utils import get_screen_info, get_system_dpi_for_wine
from portprotonqt.settings_manager import (
ADVANCED_SETTING_KEYS,
get_available_prefix_options,
@@ -65,8 +66,11 @@ TOGGLE_BOOL_KEYS = {
'PW_VKBASALT',
'PW_VKBASALT_USER_CONF',
}
DEFAULT_WINE_SETTING_KEYS = {
'PW_WINE_USE': 'PW_DEFAULT_WINE_USE',
'PW_PREFIX_NAME': 'PW_DEFAULT_PREFIX_NAME',
'PW_VULKAN_USE': 'PW_DEFAULT_VULKAN_USE',
}
def _normalize_prefix_directories(prefixes_dir):
if not os.path.isdir(prefixes_dir):
return
@@ -135,13 +139,15 @@ class ExeSettingsDialog(
):
"""Dialog for configuring executable-specific settings."""
def __init__(self, parent=None, theme=None, exe_path=None, appid=None, game_source=None):
def __init__(self, parent=None, theme=None, exe_path=None, appid=None, game_source=None,
user_conf=False):
super().__init__(parent)
self.theme = theme if theme else theme_manager.apply_theme(ui_config.get_theme())
self.exe_path = exe_path
self.appid = appid
self.game_source = str(game_source).lower() if game_source else ""
if not self.exe_path and not self.appid:
self.user_conf = user_conf
if not self.user_conf and not self.exe_path and not self.appid:
return
self.portproton_path = get_portproton_location()
if self.portproton_path is None:
@@ -154,12 +160,21 @@ class ExeSettingsDialog(
self.dist_options = []
self.lg_dist_aliases = {}
self.plugins_ver = ""
self.prefix_options = []
if self.portproton_path:
scripts_path = get_portproton_scripts_path()
if scripts_path:
var_path = os.path.join(scripts_path, "var")
self.lg_dist_aliases = read_lg_dist_versions_from_var(var_path)
try:
with open(var_path, encoding="utf-8") as var_file:
for line in var_file:
if line.startswith('export PW_PLUGINS_VER='):
self.plugins_ver = line.split('=', 1)[1].strip().strip('"\'')
break
except OSError as exc:
logger.warning("Failed to read PW_PLUGINS_VER: %s", exc)
system_wine_label = "" if self.game_source == "steam" else _('System WINE')
self.dist_options = get_available_wine_options(
self.portproton_path, system_wine_label, self.game_source == "steam"
@@ -185,7 +200,7 @@ class ExeSettingsDialog(
self.logical_core_options = []
self._gamepad_tooltip_map = {}
self.setWindowTitle(_("Exe Settings"))
self.setWindowTitle(_("Global Game Settings") if self.user_conf else _("Exe Settings"))
self.setModal(True)
self.resize(1100, 720)
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
@@ -194,6 +209,16 @@ class ExeSettingsDialog(
self.toggle_settings = get_toggle_settings()
self.setup_ui()
if self.user_conf:
self.open_ppdb_button.clicked.disconnect()
self.open_ppdb_button.setText(_("Edit user.conf"))
self.open_ppdb_button.clicked.connect(self.open_user_conf)
self.clear_ppdb_button.clicked.disconnect()
self.clear_ppdb_button.setText(_("Clear All"))
self.clear_ppdb_button.clicked.connect(self.clear_user_conf)
self.settings_table.horizontalHeader().setSectionResizeMode(
1, QHeaderView.ResizeMode.ResizeToContents
)
app = QApplication.instance()
if isinstance(app, QApplication):
app.focusChanged.connect(self._on_focus_changed)
@@ -211,7 +236,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 +248,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()
@@ -439,7 +468,7 @@ class ExeSettingsDialog(
button_layout = QHBoxLayout()
self.apply_button = AutoSizeButton(_("Apply"), icon=ThemeManager().get_icon("apply", as_path=True))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=ThemeManager().get_icon("cancel", as_path=True))
self.open_ppdb_button = AutoSizeButton(_("Open PPDB"), icon=ThemeManager().get_icon("folder", as_path=True))
self.open_ppdb_button = AutoSizeButton(_("Edit PPDB"), icon=ThemeManager().get_icon("folder", as_path=True))
self.clear_ppdb_button = AutoSizeButton(_("Clear PPDB"), icon=ThemeManager().get_icon("delete", as_path=True))
self.apply_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
@@ -468,7 +497,10 @@ class ExeSettingsDialog(
process = QProcess(self)
process.finished.connect(self.on_show_ppdb_finished)
args = self._get_process_args(["cli", "--show-ppdb", f"{self.exe_path}"])
command = ["cli", "--get-user-conf"] if self.user_conf else [
"cli", "--show-ppdb", f"{self.exe_path}"
]
args = self._get_process_args(command)
process.start(args[0], args[1:])
def on_show_ppdb_finished(self, exit_code, exit_status):
@@ -480,6 +512,13 @@ class ExeSettingsDialog(
self.logical_core_options = []
self.locale_options = []
if self.user_conf and (
exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit
):
QMessageBox.warning(self, _("Error"), _("Failed to load global game settings."))
self.reject()
return
if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore')
for line in output.splitlines():
@@ -500,6 +539,7 @@ class ExeSettingsDialog(
if (
key in self.toggle_settings
or key in ADVANCED_SETTING_KEYS
or key in DEFAULT_WINE_SETTING_KEYS.values()
or key in MANGOHUD_ENV_KEYS
or key in GAMESCOPE_ENV_KEYS
or key in VKBASALT_ENV_KEYS
@@ -511,6 +551,8 @@ class ExeSettingsDialog(
self.current_settings[key] = val
except ValueError:
continue
if self.user_conf and self.plugins_ver:
self.current_settings['PW_PLUGINS_VER'] = self.plugins_ver
if self.game_source == "steam":
self.blocked_keys.update({
@@ -535,17 +577,18 @@ class ExeSettingsDialog(
self.current_settings[key] = ''
for key in VKBASALT_ENV_KEYS:
self.current_settings[key] = ''
else:
elif not self.user_conf:
self.current_settings.setdefault('PW_MANGOHUD', '0')
self.current_settings.setdefault('PW_VKBASALT', '0')
for key in self.blocked_keys:
self.current_settings[key] = '0'
current_wine_version = self.current_settings.get('PW_WINE_USE')
wine_key = 'PW_DEFAULT_WINE_USE' if self.user_conf else 'PW_WINE_USE'
current_wine_version = self.current_settings.get(wine_key)
if current_wine_version in self.lg_dist_aliases:
self.current_settings['PW_WINE_USE'] = self.lg_dist_aliases[current_wine_version]
current_wine_version = self.current_settings['PW_WINE_USE']
self.current_settings[wine_key] = self.lg_dist_aliases[current_wine_version]
current_wine_version = self.current_settings[wine_key]
if (
current_wine_version
and current_wine_version not in self.dist_options
@@ -556,7 +599,7 @@ class ExeSettingsDialog(
self.original_values = self.current_settings.copy()
for key in set(self.toggle_settings.keys()):
self.original_values.setdefault(key, '0')
self.original_values.setdefault(key, '' if self.user_conf else '0')
self.populate_table()
self.populate_advanced()
@@ -584,6 +627,14 @@ class ExeSettingsDialog(
if not QDesktopServices.openUrl(QUrl.fromLocalFile(db_path)):
QMessageBox.critical(self, _("Error"), _("Failed to open PPDB file:\n") + db_path)
def open_user_conf(self) -> None:
user_conf = os.path.join(cast(str, self.portproton_path), "data", "user.conf")
if not os.path.exists(user_conf):
QMessageBox.critical(self, _("Error"), _("Failed to open file:\n") + user_conf)
return
if not QDesktopServices.openUrl(QUrl.fromLocalFile(user_conf)):
QMessageBox.critical(self, _("Error"), _("Failed to open file:\n") + user_conf)
def clear_ppdb_file(self):
"""Remove the PPDB file and reload settings."""
if not self.exe_path:
@@ -616,6 +667,40 @@ class ExeSettingsDialog(
self.load_current_settings()
def clear_user_conf(self) -> None:
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Icon.Question)
msg_box.setWindowTitle(_("Confirm Clear"))
msg_box.setText(_("Are you sure you want to clear settings? This action cannot be undone."))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
if msg_box.exec() != QMessageBox.StandardButton.Yes:
return
user_conf = os.path.join(cast(str, self.portproton_path), "data", "user.conf")
try:
os.remove(user_conf)
except FileNotFoundError:
pass
except OSError as exc:
logger.warning("Failed to remove user.conf: %s", exc)
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
return
screen_resolution, screen_primary = get_screen_info()
self.user_conf_changes = [
screen_resolution,
screen_primary,
f"PW_WINE_DPI_VALUE={get_system_dpi_for_wine()}",
]
self.user_conf_changes = [
change for change in self.user_conf_changes if change.split('=', 1)[1]
]
self.close_after_user_conf_changes = False
self.apply_button.setEnabled(False)
self._apply_next_user_conf_change()
def populate_table(self):
"""Populate the table with settings."""
self.settings_table.setRowCount(0)
@@ -636,27 +721,46 @@ class ExeSettingsDialog(
name_item.setData(Qt.ItemDataRole.UserRole, toggle)
name_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
current_val = self.current_settings.get(toggle, '0')
current_val = self.current_settings.get(toggle, '') if self.user_conf else (
self.current_settings.get(toggle, '0')
)
is_blocked = toggle in self.blocked_keys
checkbox_widget = QCheckBox()
checkbox_widget.setStyleSheet(self.theme.CHECKBOX_STYLE)
checkbox_widget.setChecked(current_val == '1' and not is_blocked)
checkbox_widget.setEnabled(not is_blocked)
checkbox_widget.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
checkbox_widget.installEventFilter(self)
checkbox_container = QWidget()
checkbox_container.setStyleSheet(self.theme.CHECKBOX_STYLE + self.theme.TRANSPARENT_BACKGROUND_STYLE)
checkbox_layout = QHBoxLayout(checkbox_container)
checkbox_layout.setContentsMargins(0, 0, 0, 0)
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
checkbox_layout.addWidget(checkbox_widget)
checkbox_container.setFocusPolicy(Qt.FocusPolicy.NoFocus)
checkbox_item = QTableWidgetItem()
checkbox_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
if self.user_conf:
value_widget = CustomComboBox(theme=self.theme)
value_widget.setObjectName("settingsTableCombo")
value_widget.view().window().setWindowFlags(
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
)
value_widget.view().window().setAttribute(
Qt.WidgetAttribute.WA_TranslucentBackground
)
value_widget.addItem(_("Default"), "")
value_widget.addItem(_("Yes"), "1")
value_widget.addItem(_("No"), "0")
value_widget.setCurrentIndex(max(0, value_widget.findData(current_val)))
else:
value_widget = QCheckBox()
value_widget.setStyleSheet(self.theme.CHECKBOX_STYLE)
value_widget.setChecked(current_val == '1' and not is_blocked)
value_widget.setEnabled(not is_blocked)
value_widget.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
value_widget.installEventFilter(self)
if is_blocked:
name_item.setForeground(QColor(self.theme.color_disabled_text))
self.settings_table.setItem(row, 1, checkbox_item)
self.settings_table.setCellWidget(row, 1, checkbox_container)
if self.user_conf:
self.settings_table.setCellWidget(row, 1, value_widget)
else:
checkbox_container = QWidget()
checkbox_container.setStyleSheet(self.theme.CHECKBOX_STYLE + self.theme.TRANSPARENT_BACKGROUND_STYLE)
checkbox_layout = QHBoxLayout(checkbox_container)
checkbox_layout.setContentsMargins(0, 0, 0, 0)
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
checkbox_layout.addWidget(value_widget)
checkbox_container.setFocusPolicy(Qt.FocusPolicy.NoFocus)
checkbox_item = QTableWidgetItem()
checkbox_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
self.settings_table.setItem(row, 1, checkbox_item)
self.settings_table.setCellWidget(row, 1, checkbox_container)
desc_item = QTableWidgetItem(description)
desc_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
@@ -666,7 +770,7 @@ class ExeSettingsDialog(
self.settings_table.setItem(row, 2, desc_item)
self.settings_table.setItem(row, 0, name_item)
self.value_widgets[(row, 1)] = checkbox_widget
self.value_widgets[(row, 1)] = value_widget
self.settings_table.resizeRowsToContents()
if self.settings_table.rowCount() > 0:
@@ -698,6 +802,11 @@ class ExeSettingsDialog(
dist_options=self.dist_options,
prefix_options=self.prefix_options
)
if self.user_conf:
for setting in advanced_settings:
setting['key'] = DEFAULT_WINE_SETTING_KEYS.get(setting['key'], setting['key'])
if setting['type'] == 'combo':
setting['options'] = [_('Default')] + setting['options']
self.advanced_settings_by_key = {
setting['key']: setting for setting in advanced_settings
}
@@ -722,7 +831,7 @@ class ExeSettingsDialog(
Qt.WidgetAttribute.WA_TranslucentBackground
)
combo.addItems(setting['options'])
if setting['key'] == 'PW_PREFIX_NAME':
if setting['key'] in ('PW_PREFIX_NAME', 'PW_DEFAULT_PREFIX_NAME'):
combo.setEditable(True)
combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
prefix_line_edit = combo.lineEdit()
@@ -731,17 +840,21 @@ class ExeSettingsDialog(
combo.highlighted.connect(
lambda row, c=combo: self._on_combo_highlighted(row, c),
)
elif setting['key'] == 'PW_WINE_USE':
elif setting['key'] in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE'):
combo.highlighted.connect(
lambda row, c=combo: self._on_combo_highlighted(row, c),
)
current_raw = current.get(setting['key'], setting['default'])
if setting['key'] == 'PW_WINE_CPU_TOPOLOGY':
current_raw = current.get(setting['key'], '') if self.user_conf else (
current.get(setting['key'], setting['default'])
)
if self.user_conf and not current_raw:
current_val = _('Default')
elif setting['key'] == 'PW_WINE_CPU_TOPOLOGY':
current_val = disabled_text if current_raw == 'disabled' else (
current_raw.split(':')[0] if isinstance(current_raw, str) and ':' in current_raw else current_raw
)
elif setting['key'] == 'PW_WINE_USE':
elif setting['key'] in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE'):
current_val = _('System WINE') if current_raw == 'USE_SYSTEM_WINE' else current_raw
else:
current_val = disabled_text if current_raw == 'disabled' else current_raw
@@ -777,6 +890,9 @@ class ExeSettingsDialog(
else:
self.original_display_values[setting['key']] = current_val_text
if self.user_conf and not current_raw:
self.original_display_values[setting['key']] = ''
if '_value_map' in setting:
reverse_map = {v: k for k, v in setting['_value_map'].items()}
self.value_mapping[setting['key']] = {
@@ -786,7 +902,9 @@ class ExeSettingsDialog(
elif setting['type'] == 'text':
line_edit = QLineEdit()
current_val = current.get(setting['key'], setting['default'])
current_val = current.get(setting['key'], '') if self.user_conf else (
current.get(setting['key'], setting['default'])
)
line_edit.setText(current_val)
if not current_val and not setting['default']:
line_edit.setPlaceholderText(_("Default value"))
@@ -992,6 +1110,10 @@ class ExeSettingsDialog(
source_widget = self._find_toggle_widget(key)
checkbox = QCheckBox()
checkbox.setStyleSheet(self.theme.CHECKBOX_STYLE)
if isinstance(source_widget, QComboBox):
self.favorites_table.removeCellWidget(row, 1)
self._add_favorite_combo(row, source_widget)
return
checkbox.setChecked(source_widget.isChecked() if source_widget else False)
checkbox.setEnabled(source_widget.isEnabled() if source_widget else False)
checkbox.stateChanged.connect(
@@ -1035,7 +1157,7 @@ class ExeSettingsDialog(
self.favorites_table.setItem(row, 0, name_item)
self.favorites_table.setItem(row, 2, desc_item)
def _find_toggle_widget(self, key: str) -> QCheckBox | None:
def _find_toggle_widget(self, key: str) -> QCheckBox | QComboBox | None:
for (row, _column), widget in self.value_widgets.items():
item = self.settings_table.item(row, 0)
if item and item.data(Qt.ItemDataRole.UserRole) == key:
@@ -1043,8 +1165,8 @@ class ExeSettingsDialog(
return None
def _sync_checkbox(self, source_widget: QCheckBox | None, checkbox: QCheckBox) -> None:
if source_widget is not None and source_widget.isChecked() != checkbox.isChecked():
source_widget.setChecked(checkbox.isChecked())
if source_widget is not None and source_widget.checkState() != checkbox.checkState():
source_widget.setCheckState(checkbox.checkState())
def _add_favorite_combo(self, row: int, source_widget: QComboBox) -> None:
combo = CustomComboBox(theme=self.theme)
@@ -1222,10 +1344,12 @@ class ExeSettingsDialog(
continue
widget = self.value_widgets.get((row, 1))
if not isinstance(widget, QCheckBox):
if isinstance(widget, QComboBox) and self.user_conf:
new_val = str(widget.currentData() or '')
elif isinstance(widget, QCheckBox):
new_val = '1' if widget.isChecked() else '0'
else:
continue
new_val = '1' if widget.isChecked() else '0'
if new_val != orig_val:
changes.append(f"{key}={new_val}")
# Track if PW_MANGOHUD is being enabled
@@ -1236,9 +1360,11 @@ class ExeSettingsDialog(
orig_val = self.original_display_values.get(key, '')
if isinstance(widget, QComboBox):
new_val = widget.currentText()
if self.user_conf and new_val == _('Default'):
new_val = ''
if key in ('PW_PREFIX_NAME', 'PW_VULKAN_USE') and self.game_source == "steam":
continue
if key == 'PW_PREFIX_NAME':
if key in ('PW_PREFIX_NAME', 'PW_DEFAULT_PREFIX_NAME'):
new_val = re.sub(r"[ \t]", "_", new_val.strip()).upper()
if key in self.value_mapping and 'forward' in self.value_mapping[key]:
@@ -1249,7 +1375,7 @@ class ExeSettingsDialog(
else:
has_changed = (new_val != orig_val)
if key == 'PW_WINE_USE' and new_val == _('System WINE'):
if key in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE') and new_val == _('System WINE'):
new_val = 'USE_SYSTEM_WINE'
if new_val.lower() == _('disabled').lower():
@@ -1271,9 +1397,32 @@ class ExeSettingsDialog(
gamescope_changes = []
if self.gamescope_available:
gamescope_changes = self._collect_gamescope_changes()
changes.extend(mangohud_changes)
changes.extend(self._collect_vkbasalt_changes())
changes.extend(gamescope_changes)
vkbasalt_changes = self._collect_vkbasalt_changes()
if self.user_conf:
if self.current_settings.get('PW_MANGOHUD') != '1':
mangohud_changes = [
change for change in mangohud_changes
if change.startswith(('PW_MANGOHUD=', 'PW_MANGOHUD_USER_CONF='))
]
if self.current_settings.get('PW_VKBASALT') != '1':
vkbasalt_changes = [
change for change in vkbasalt_changes
if change.startswith(('PW_VKBASALT=', 'PW_VKBASALT_USER_CONF='))
]
if self.current_settings.get('PW_GAMESCOPE') != '1':
gamescope_changes = [
change for change in gamescope_changes
if change.startswith('PW_GAMESCOPE=')
]
specialized_changes = mangohud_changes + vkbasalt_changes + gamescope_changes
if self.user_conf:
defaults = ('', '0', '0.00', 'Home')
specialized_changes = [
change for change in specialized_changes
if change.split('=', 1)[0] in self.original_values
or change.split('=', 1)[1] not in defaults
]
changes.extend(specialized_changes)
# Check if PW_GAMESCOPE toggle changes are already in the list
has_gamescope_toggle = any(change.startswith("PW_GAMESCOPE=") for change in gamescope_changes)
@@ -1294,6 +1443,13 @@ class ExeSettingsDialog(
if not changes:
return
if self.user_conf:
self.user_conf_changes = changes
self.close_after_user_conf_changes = True
self.apply_button.setEnabled(False)
self._apply_next_user_conf_change()
return
process = QProcess(self)
process.finished.connect(self.on_edit_db_finished)
process_args = ["cli", "--edit-db", self.exe_path] + changes
@@ -1301,6 +1457,36 @@ class ExeSettingsDialog(
process.start(args[0], args[1:])
self.apply_button.setEnabled(False)
def _apply_next_user_conf_change(self) -> None:
if not self.user_conf_changes:
self.apply_button.setEnabled(True)
if self.close_after_user_conf_changes:
self.close()
else:
self.load_current_settings()
return
key, value = self.user_conf_changes.pop(0).split('=', 1)
action = "--set-user-conf" if value else "--delete-user-conf"
command = ["cli", action, key]
if value:
command.append(value)
self.user_conf_process = QProcess(self)
self.user_conf_process.finished.connect(self._on_user_conf_change_finished)
args = self._get_process_args(command)
self.user_conf_process.start(args[0], args[1:])
def _on_user_conf_change_finished(self, exit_code, exit_status) -> None:
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
error_output = bytes(self.user_conf_process.readAllStandardError().data()).decode(
'utf-8', 'ignore'
)
self.apply_button.setEnabled(True)
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
logger.error("Failed to update user.conf: %s", error_output)
return
self._apply_next_user_conf_change()
def on_edit_db_finished(self, exit_code, exit_status):
"""Handle --edit-db output."""
process = cast(QProcess, self.sender())
+64 -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)
@@ -1026,7 +1027,38 @@ class GamescopeSettingsMixin:
def _filter_gamescope_settings(self, search_text):
"""Filter Gamescope groups based on search text."""
if not self.gamescope_category_groups:
return
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 +1066,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
+145 -13
View File
@@ -3,6 +3,7 @@
import configparser
import os
import re
import subprocess
from pathlib import Path
from typing import Any, cast
@@ -151,8 +152,14 @@ MANGOHUD_VALUE_SPECS = [
'options': [f'{i / 10:.1f}' for i in range(11)]},
{'key': 'round_corners', 'label': _("Round corners (px)"), 'type': 'combo',
'options': [str(i) for i in range(16)]},
{'key': 'font_scale', 'label': _("Font scale"), 'type': 'combo',
'options': ['', '0.5', '0.75', '1.0', '1.25', '1.5', '2.0']},
{'key': 'font_size', 'label': _("Font size"), 'type': 'line'},
{'key': 'font_file', 'label': _("Font"), 'type': 'combo', 'options': []},
]
MANGOHUD_FONT_KEYS = {'font_scale', 'font_size', 'font_file'}
MANGOHUD_VALUE_OPTION_TRANSLATIONS = {
'position': {
'top-left': _("Top-left"),
@@ -385,11 +392,13 @@ 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
self.mangohud_toggle_group = None
self.mangohud_values_group = None
self.mangohud_fonts_group = None
self.mangohud_fps_group = None
self.mangohud_extra_group = None
self.mangohud_action_buttons = []
@@ -411,6 +420,7 @@ class MangoHudSettingsMixin:
self._add_mangohud_presets_group(layout)
self._add_mangohud_toggle_group(layout)
self._add_mangohud_values_group(layout)
self._add_mangohud_fonts_group(layout)
self._add_mangohud_fps_group(layout)
self._add_mangohud_extra_group(layout)
layout.addStretch()
@@ -432,6 +442,8 @@ class MangoHudSettingsMixin:
gpu_count = sum(1 for _text, value in self.mangohud_gpu_options if value.isdigit())
for spec in MANGOHUD_VALUE_SPECS:
if spec['key'] in MANGOHUD_FONT_KEYS:
continue
if spec['key'] == 'fps_limit_method':
continue
if spec['key'] == 'gpu_list' and gpu_count < 2:
@@ -440,8 +452,32 @@ class MangoHudSettingsMixin:
parent_layout.addWidget(group)
def _add_mangohud_fonts_group(self, parent_layout):
"""Add MangoHud font controls."""
group = QGroupBox(_("Fonts"))
group.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
form = QFormLayout(group)
form.setVerticalSpacing(self.theme.exeSettingsGroupBoxElementVerticalSpacing)
form.setHorizontalSpacing(self.theme.exeSettingsGroupBoxElementHorizontalSpacing)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self.mangohud_fonts_group = group
for spec in MANGOHUD_VALUE_SPECS:
if spec['key'] in MANGOHUD_FONT_KEYS:
form.addRow(spec['label'], self._create_mangohud_value_widget(spec))
parent_layout.addWidget(group)
def _create_mangohud_value_widget(self, spec):
"""Create a MangoHud value widget."""
if spec['type'] == 'line':
widget = QLineEdit()
widget.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
widget.installEventFilter(cast(QWidget, self))
widget.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
self.mangohud_widgets[spec['key']] = widget
return widget
widget = CustomComboBox(theme=self.theme)
widget.view().window().setWindowFlags(
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
@@ -457,6 +493,10 @@ class MangoHudSettingsMixin:
gpu_options = self.mangohud_gpu_options or self._get_mangohud_gpu_options()
for text, value in gpu_options:
widget.addItem(text, value)
elif spec['key'] == 'font_file':
widget.addItem(placeholder_text, '')
for text, value in self._get_mangohud_font_options():
widget.addItem(text, value)
else:
value_translations = MANGOHUD_VALUE_OPTION_TRANSLATIONS.get(spec['key'], {})
for option in options:
@@ -474,6 +514,30 @@ class MangoHudSettingsMixin:
self.mangohud_widgets[spec['key']] = widget
return widget
def _get_mangohud_font_options(self) -> list[tuple[str, str]]:
"""Get installed font names and files from fontconfig."""
try:
result = subprocess.run(
['fc-list', '--format=%{family[0]} %{style[0]}\t%{file}\n'],
capture_output=True,
text=True,
check=False,
timeout=3,
)
except (OSError, subprocess.TimeoutExpired) as exc:
logger.warning("Failed to list system fonts: %s", exc)
return []
if result.returncode != 0:
logger.warning("fc-list failed with exit code %s", result.returncode)
return []
fonts = set()
for line in result.stdout.splitlines():
name, separator, path = line.partition('\t')
if separator and path.lower().endswith(('.ttf', '.otf')):
fonts.add((name.strip(), path.strip()))
return sorted(fonts, key=lambda font: font[0].lower())
def _add_mangohud_presets_group(self, parent_layout):
"""Add preset buttons for common MangoHud layouts."""
presets_group = QGroupBox(_("Presets"))
@@ -567,7 +631,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)
@@ -865,6 +929,8 @@ class MangoHudSettingsMixin:
self.mangohud_presets_group.setVisible(config_visible)
if self.mangohud_values_group is not None:
self.mangohud_values_group.setVisible(config_visible)
if self.mangohud_fonts_group is not None:
self.mangohud_fonts_group.setVisible(config_visible)
if self.mangohud_fps_group is not None:
self.mangohud_fps_group.setVisible(config_visible)
if self.mangohud_extra_group is not None:
@@ -1064,6 +1130,9 @@ class MangoHudSettingsMixin:
if widget is None:
return
text = value if isinstance(value, str) else ''
if isinstance(widget, QLineEdit):
widget.setText(text)
return
index = widget.findData(text)
if text and index < 0:
widget.addItem(text, text)
@@ -1178,10 +1247,13 @@ class MangoHudSettingsMixin:
if widget is None:
return ''
current_data = widget.currentData()
value = '' if current_data is None else str(current_data).strip()
if not value and current_data is None:
value = widget.currentText().strip()
if isinstance(widget, QLineEdit):
value = widget.text().strip()
else:
current_data = widget.currentData()
value = '' if current_data is None else str(current_data).strip()
if not value and current_data is None:
value = widget.currentText().strip()
if not value:
return ''
parsed_original, _raw_tokens = self._parse_mangohud_config(
@@ -1231,9 +1303,42 @@ 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
if not self.mangohud_category_groups:
return
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 +1346,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):
+482
View File
@@ -0,0 +1,482 @@
"""Epic Games Store integration through Legendary."""
import hashlib
import os
import platform
import shutil
import subprocess
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
import orjson
import requests
from portprotonqt.localization import get_store_content_languages
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
EGS_LOGIN_URL = "https://legendary.gl/epiclogin"
LEGENDARY_RELEASE_URL = (
"https://api.github.com/repos/Heroic-Games-Launcher/legendary/releases/latest"
)
LEGENDARY_TIMEOUT = 120
EGS_METADATA_WORKERS = 4
EGS_METADATA_TIMEOUT = 15
EGS_DESCRIPTION_CACHE_VERSION = 2
LEGENDARY_UPDATE_INTERVAL = 30 * 24 * 60 * 60
EGS_API_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 EpicGamesLauncher"
)
class EGSAPI:
"""Provide EGS authentication, library and installation helpers."""
def __init__(self) -> None:
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
self.data_dir = data_home / "PortProtonQt" / "launcher" / "egs"
self.bin_dir = data_home / "PortProtonQt" / "bin"
# Remove after the legacy launcher storage migration period.
legacy_dir = data_home / "PortProtonQt" / "egs"
if legacy_dir.exists() and not self.data_dir.exists():
self.data_dir.parent.mkdir(parents=True, exist_ok=True)
legacy_dir.rename(self.data_dir)
legacy_bin = self.data_dir / "bin"
if legacy_bin.is_dir():
self.bin_dir.mkdir(parents=True, exist_ok=True)
for legacy_binary in legacy_bin.iterdir():
target = self.bin_dir / legacy_binary.name
if not target.exists():
legacy_binary.rename(target)
if not any(legacy_bin.iterdir()):
legacy_bin.rmdir()
self.config_dir = self.data_dir / "legendary"
self.user_path = self.config_dir / "user.json"
self.library_path = self.data_dir / "library.json"
self.sizes_path = self.data_dir / "sizes.json"
self.version_path = self.bin_dir / "legendary.version"
self.games_dir = Path.home() / "Games"
def get_legendary_path(self) -> str | None:
"""Return an available Legendary executable."""
bundled = self.bin_dir / "legendary"
if bundled.is_file() and os.access(bundled, os.X_OK):
return str(bundled)
return shutil.which("legendary")
def ensure_legendary(self) -> str:
"""Download the current official Heroic Legendary binary if needed."""
existing = self.get_legendary_path()
if existing:
return existing
asset, release_tag = self._get_latest_legendary_release()
return self._download_legendary(asset, release_tag)
def update_legendary(self) -> str:
"""Update the bundled Legendary binary when a newer release exists."""
bundled = self.bin_dir / "legendary"
if bundled.is_file() and os.access(bundled, os.X_OK):
try:
last_check = self.version_path.stat().st_mtime
except OSError:
last_check = 0
if time.time() - last_check < LEGENDARY_UPDATE_INTERVAL:
return str(bundled)
asset, release_tag = self._get_latest_legendary_release()
if (
bundled.is_file()
and self._get_legendary_release_tag() == release_tag
):
self.version_path.touch()
return str(bundled)
return self._download_legendary(asset, release_tag)
def _get_latest_legendary_release(self) -> tuple[dict, str]:
"""Return the matching Legendary asset and release tag."""
architecture = {"x86_64": "x86_64", "aarch64": "arm64"}.get(
platform.machine()
)
if not architecture:
raise OSError(f"Unsupported Legendary architecture: {platform.machine()}")
response = requests.get(LEGENDARY_RELEASE_URL, timeout=15)
response.raise_for_status()
release = response.json()
asset_name = f"legendary_linux_{architecture}"
asset = next(
(item for item in release.get("assets", []) if item.get("name") == asset_name),
None,
)
if not asset:
raise OSError(f"Legendary release asset not found: {asset_name}")
release_tag = str(release.get("tag_name", ""))
if not release_tag:
raise OSError("Legendary release tag not found")
return asset, release_tag
def _get_legendary_release_tag(self) -> str:
try:
return self.version_path.read_text(encoding="utf-8").strip()
except OSError:
return ""
def _download_legendary(self, asset: dict, release_tag: str) -> str:
target = self.bin_dir / "legendary"
temporary = target.with_suffix(".part")
target.parent.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256()
try:
with requests.get(asset["browser_download_url"], stream=True, timeout=30) as response:
response.raise_for_status()
with open(temporary, "wb") as output:
for chunk in response.iter_content(chunk_size=1024 * 1024):
if chunk:
output.write(chunk)
digest.update(chunk)
expected = str(asset.get("digest", "")).removeprefix("sha256:")
if not expected or digest.hexdigest() != expected:
raise OSError("Legendary checksum verification failed")
temporary.chmod(0o755)
temporary.replace(target)
self.version_path.write_text(release_tag, encoding="utf-8")
return str(target)
except Exception:
temporary.unlink(missing_ok=True)
raise
def build_command(self, arguments: list[str]) -> list[str]:
"""Build a Legendary command using private configuration paths."""
executable = self.get_legendary_path()
if not executable:
raise FileNotFoundError("Legendary executable not found")
self.config_dir.mkdir(parents=True, exist_ok=True)
self.config_dir.chmod(0o700)
command_arguments = list(arguments)
if "-y" in command_arguments:
command_arguments.remove("-y")
command_arguments.insert(0, "-y")
return [executable, *command_arguments]
def get_environment(self) -> dict[str, str]:
"""Return the environment required by Legendary."""
environment = os.environ.copy()
environment["LEGENDARY_CONFIG_PATH"] = str(self.config_dir)
return environment
def authenticate(self, code: str) -> tuple[bool, str]:
"""Authenticate an Epic account with an authorization code."""
self.ensure_legendary()
result = subprocess.run(
self.build_command(["auth", "--code", code]),
env=self.get_environment(), capture_output=True, check=False,
timeout=LEGENDARY_TIMEOUT,
)
if result.returncode == 0 and self.user_path.is_file():
self.user_path.chmod(0o600)
return True, ""
error = result.stderr.decode(errors="replace").strip()
logger.error("Legendary authentication failed: %s", error)
return False, error or f"Legendary exited with code {result.returncode}"
@staticmethod
def extract_auth_code(text: str) -> str:
"""Extract an Epic authorization code from copied text or JSON."""
value = text.strip()
try:
data = orjson.loads(value)
except orjson.JSONDecodeError:
return value if "://" not in value else ""
if not isinstance(data, dict):
return ""
return str(data.get("authorizationCode", "")).strip()
def logout(self) -> bool:
"""Remove the Epic login and cached library."""
try:
if self.get_legendary_path() and self.user_path.is_file():
subprocess.run(
self.build_command(["auth", "--delete"]),
env=self.get_environment(), capture_output=True, check=False,
timeout=LEGENDARY_TIMEOUT,
)
self.user_path.unlink(missing_ok=True)
self.library_path.unlink(missing_ok=True)
return True
except (OSError, subprocess.SubprocessError) as error:
logger.error("Failed to disconnect Epic account: %s", error)
return False
def get_account_name(self) -> str:
"""Return the Epic display name from Legendary state."""
user = self._load_json(self.user_path, {})
return str(user.get("displayName", "")) if isinstance(user, dict) else ""
def refresh_library(
self, progress_callback: Callable[[int, int], None] | None = None
) -> list[dict]:
"""Refresh and cache the Epic library through Legendary."""
self.update_legendary()
result = subprocess.run(
self.build_command(["list", "--json", "--third-party"]),
env=self.get_environment(), capture_output=True, check=False,
timeout=LEGENDARY_TIMEOUT,
)
if result.returncode != 0:
error = result.stderr.decode(errors="replace").strip()
raise OSError(error or "Failed to refresh Epic library")
raw_games = orjson.loads(result.stdout)
if not isinstance(raw_games, list):
raise OSError("Legendary returned an invalid Epic library")
games = []
for game in raw_games:
metadata = game.get("metadata", {})
if metadata.get("mainGameItem"):
continue
release_info = metadata.get("releaseInfo", [])
mobile_only = bool(release_info) and all(
info.get("platform")
and all(platform in {"Android", "iOS"}
for platform in info["platform"])
for info in release_info
)
if not mobile_only:
games.append(self._normalize_game(game))
games = [game for game in games if game.get("app_id")]
self._enrich_descriptions(games, progress_callback)
self._save_json(self.library_path, games)
return games
def load_library(self) -> list[dict]:
"""Load the cached Epic library."""
data = self._load_json(self.library_path, [])
return data if isinstance(data, list) else []
def clear_library_cache(self) -> None:
"""Remove cached Epic library metadata."""
try:
self.library_path.unlink(missing_ok=True)
except OSError as error:
logger.warning("Failed to clear Epic library cache: %s", error)
def load_installed(self) -> dict[str, dict]:
"""Load Legendary installed game records."""
data = self._load_json(self.config_dir / "installed.json", {})
return data if isinstance(data, dict) else {}
def is_game_installed(self, app_id: str) -> bool:
"""Return whether Legendary records a usable installation."""
return self.get_launch_target(app_id) is not None
@staticmethod
def is_eos_overlay_enabled(prefix_path: Path) -> bool:
"""Return whether the EOS overlay is enabled in a Wine prefix."""
try:
registry = (prefix_path / "user.reg").read_text(
encoding="utf-8", errors="replace"
)
except OSError:
return False
for section in registry.casefold().split("\n["):
if section.startswith(r"software\\epic games\\eos]"):
if '"overlaypath"=' in section:
return True
return False
def get_launch_target(self, app_id: str) -> str | None:
"""Return the installed game's Windows executable."""
installed = self.load_installed().get(app_id, {})
install_path = Path(str(installed.get("install_path", "")))
executable = str(installed.get("executable", "")).replace("\\", "/")
target = install_path.joinpath(*executable.split("/"))
return str(target) if executable and target.is_file() else None
def get_cached_download_sizes(self, app_id: str) -> tuple[int, int] | None:
"""Return cached EGS download and disk sizes."""
cache = self._load_json(self.sizes_path, {})
value = cache.get(app_id) if isinstance(cache, dict) else None
if not isinstance(value, list) or len(value) != 2:
return None
sizes = int(value[0]), int(value[1])
return None if sizes[0] > 0 and sizes[1] <= 0 else sizes
def save_download_sizes(self, app_id: str, sizes: tuple[int, int]) -> None:
"""Cache EGS download and disk sizes."""
cache = self._load_json(self.sizes_path, {})
if not isinstance(cache, dict):
cache = {}
cache[app_id] = list(sizes)
self._save_json(self.sizes_path, cache)
@staticmethod
def parse_download_sizes(output: bytes) -> tuple[int, int]:
"""Extract download and install sizes from Legendary JSON."""
data = orjson.loads(output)
values: dict[str, int] = {}
def visit(value: Any) -> None:
if isinstance(value, dict):
for key, item in value.items():
if key in {"download_size", "install_size", "disk_size"}:
try:
values[key] = max(values.get(key, 0), int(item))
except (TypeError, ValueError):
continue
visit(item)
elif isinstance(value, list):
for item in value:
visit(item)
visit(data)
install_size = values.get("install_size") or values.get("disk_size", 0)
return values.get("download_size", 0), install_size
def _enrich_descriptions(
self, games: list[dict],
progress_callback: Callable[[int, int], None] | None = None,
) -> None:
cached = {str(game.get("app_id")): game for game in self.load_library()}
pending = []
completed = 0
total = len(games)
preferred_locale = get_store_content_languages()[0]
for game in games:
old_game = cached.get(game["app_id"], {})
old_description = str(old_game.get("description", ""))
if (
old_description
and old_description != game["title"]
and old_game.get("description_preference") == preferred_locale
and old_game.get("description_cache_version")
== EGS_DESCRIPTION_CACHE_VERSION
):
game["description"] = old_description
game["description_locale"] = str(old_game.get("description_locale", ""))
game["description_preference"] = preferred_locale
game["description_cache_version"] = EGS_DESCRIPTION_CACHE_VERSION
completed += 1
if progress_callback:
progress_callback(completed, total)
else:
pending.append(game)
if not pending:
return
worker_count = min(EGS_METADATA_WORKERS, os.cpu_count() or 1)
with ThreadPoolExecutor(max_workers=worker_count) as executor:
descriptions = executor.map(self._get_store_description, pending)
for game, result in zip(pending, descriptions, strict=True):
description, description_locale = result
if description:
game["description"] = description
game["description_locale"] = description_locale
game["description_preference"] = preferred_locale
game["description_cache_version"] = EGS_DESCRIPTION_CACHE_VERSION
completed += 1
if progress_callback:
progress_callback(completed, total)
def _get_store_description(self, game: dict) -> tuple[str, str]:
slug = self._get_product_slug(str(game.get("namespace", "")), game["title"])
for language in get_store_content_languages():
try:
response = requests.get(
"https://store-content.ak.epicgames.com/api/"
f"{language}/content/products/{slug}",
headers={"User-Agent": EGS_API_USER_AGENT},
timeout=EGS_METADATA_TIMEOUT,
)
if response.status_code == requests.codes.not_found:
continue
response.raise_for_status()
description = self._parse_store_description(response.json())
if description:
return description, language
except requests.RequestException as error:
logger.debug(
"Epic description unavailable for %s: %s", game["app_id"], error
)
return "", ""
return "", ""
@staticmethod
def _parse_store_description(data: Any) -> str:
if not isinstance(data, dict) or not isinstance(data.get("pages"), list):
return ""
page = next(
(item for item in data["pages"]
if isinstance(item, dict) and item.get("type") == "productHome"),
{},
)
about = page.get("data", {}).get("about", {})
short_description = str(about.get("shortDescription") or "").strip()
if short_description:
return short_description
description = str(about.get("description") or "").strip()
return description.split("\n\n", 1)[0].strip()
def _get_product_slug(self, namespace: str, title: str) -> str:
fallback = "-".join("".join(
character if character.isascii() and character.isalnum() else " "
for character in title.lower()
).split())
if not namespace:
return fallback
query = {
"query": "query ProductSlug($namespace: String!) { Catalog { "
"catalogNs(namespace: $namespace) { mappings(pageType: "
"\"productHome\") { pageSlug pageType } } } }",
"variables": {"namespace": namespace},
}
try:
response = requests.post(
"https://launcher.store.epicgames.com/graphql",
json=query,
headers={
"Content-Type": "application/json",
"User-Agent": EGS_API_USER_AGENT,
},
timeout=EGS_METADATA_TIMEOUT,
)
response.raise_for_status()
mappings = response.json()["data"]["Catalog"]["catalogNs"]["mappings"]
return str(mappings[0].get("pageSlug") or fallback) if mappings else fallback
except (KeyError, IndexError, TypeError, requests.RequestException) as error:
logger.debug("Epic product slug unavailable for %s: %s", namespace, error)
return fallback
@staticmethod
def _normalize_game(game: dict) -> dict:
metadata = game.get("metadata", {})
images = metadata.get("keyImages", []) if isinstance(metadata, dict) else []
cover = next(
(str(image.get("url", "")) for image in images
if image.get("type") in ("DieselGameBoxTall", "OfferImageTall")),
"",
)
return {
"app_id": str(game.get("app_name") or game.get("appName") or ""),
"title": str(game.get("app_title") or metadata.get("title") or ""),
"description": str(metadata.get("description", "")),
"cover": cover,
"namespace": str(metadata.get("namespace", "")),
"folder_name": str(metadata.get("customAttributes", {}).get(
"FolderName", {}
).get("value") or game.get("app_name") or game.get("appName") or ""),
"description_locale": "",
"description_preference": "",
"description_cache_version": 0,
}
@staticmethod
def _load_json(path: Path, default: Any) -> Any:
try:
return orjson.loads(path.read_bytes())
except (OSError, orjson.JSONDecodeError):
return default
@staticmethod
def _save_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(orjson.dumps(data, option=orjson.OPT_INDENT_2))
+365 -143
View File
@@ -1,6 +1,7 @@
import os
import re
import shlex
from typing import Any
from PySide6.QtGui import (
QPainter,
@@ -12,8 +13,12 @@ from PySide6.QtGui import (
QIcon,
QPen,
QLinearGradient,
QEnterEvent,
QFocusEvent,
QMouseEvent,
QPaintEvent,
)
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer, QSize
from PySide6.QtCore import QEvent, Signal, Property, Qt, QUrl, QTimer, QSize
from PySide6.QtWidgets import (
QApplication,
QFrame,
@@ -24,6 +29,7 @@ from PySide6.QtWidgets import (
QWidget,
QStackedLayout,
QLabel,
QSizePolicy,
)
from portprotonqt.image_utils import (
cleanup_animated_cover,
@@ -199,11 +205,123 @@ class SourceCorner(QWidget):
self._visible = visible
super().setVisible(visible)
def refresh_source_theme(self, config: dict, icon: str | QIcon | None) -> None:
"""Refresh source-corner visuals for a live theme change."""
self._cfg = config
self._icon = icon
self._color = QColor(config.get("ribbon_color", "#3f424d"))
self._fold_color = QColor(config.get("ribbon_fold_color", "#00000096"))
self.update()
class GameCard(QFrame):
class AnimatedCard(QFrame):
animation_base_size: tuple[int, int]
borderWidthChanged = Signal()
gradientAngleChanged = Signal()
scaleChanged = Signal()
hoverChanged = Signal(str, bool)
focusChanged = Signal(str, bool)
clicked = Signal()
def setup_card_animations(self, theme: Any, layout_config: dict) -> None:
self.theme = theme
self.card_layout_cfg = layout_config
self._borderWidth = layout_config.get(
"default_border_width", theme.GAME_CARD_ANIMATION["default_border_width"]
)
self._gradientAngle = layout_config.get(
"gradient_start_angle", theme.GAME_CARD_ANIMATION["gradient_start_angle"]
)
self._scale = layout_config.get(
"default_scale", theme.GAME_CARD_ANIMATION["default_scale"]
)
self._hovered = False
self._focused = False
self._hover_sound_pending = False
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setMouseTracking(True)
self.animations = GameCardAnimations(self, theme)
self.animations.setup_animations()
def update_scale(self) -> None:
width, height = self.animation_base_size
self.setFixedSize(int(width * self._scale), int(height * self._scale))
def getBorderWidth(self) -> int:
return self._borderWidth
def setBorderWidth(self, value: int) -> None:
self._borderWidth = value
self.borderWidthChanged.emit()
self.update()
def getGradientAngle(self) -> float:
return self._gradientAngle
def setGradientAngle(self, value: float) -> None:
self._gradientAngle = value
self.gradientAngleChanged.emit()
self.update()
def getScale(self) -> float:
return self._scale
def setScale(self, value: float) -> None:
self._scale = value
self.update_scale()
self.scaleChanged.emit()
borderWidth = Property(int, getBorderWidth, setBorderWidth, notify=borderWidthChanged)
gradientAngle = Property(float, getGradientAngle, setGradientAngle, notify=gradientAngleChanged)
scale = Property(float, getScale, setScale, notify=scaleChanged)
def paintEvent(self, event: QPaintEvent) -> None:
super().paintEvent(event)
self.animations.paint_border(QPainter(self))
def enterEvent(self, event: QEnterEvent) -> None:
self.raise_()
self._hover_sound_pending = True
self.animations.handle_enter_event()
super().enterEvent(event)
def leaveEvent(self, event: QEvent) -> None:
self._hover_sound_pending = False
self.animations.handle_leave_event()
super().leaveEvent(event)
def mouseMoveEvent(self, event: QMouseEvent) -> None:
if self._hover_sound_pending:
from portprotonqt.sound_manager import SoundManager
SoundManager().play("navigate")
self._hover_sound_pending = False
super().mouseMoveEvent(event)
def focusInEvent(self, event: QFocusEvent) -> None:
self.raise_()
if QApplication.activeWindow() is not None and event.reason() not in (
Qt.FocusReason.ActiveWindowFocusReason,
Qt.FocusReason.MouseFocusReason,
):
from portprotonqt.sound_manager import SoundManager
SoundManager().play("navigate")
self.animations.handle_focus_in_event()
super().focusInEvent(event)
def focusOutEvent(self, event: QFocusEvent) -> None:
self.animations.handle_focus_out_event()
super().focusOutEvent(event)
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton and self.rect().contains(event.pos()):
self.clicked.emit()
super().mouseReleaseEvent(event)
def click(self) -> None:
self.clicked.emit()
class GameCard(AnimatedCard):
editShortcutRequested = Signal(str, str, str)
deleteGameRequested = Signal(str, str)
addToMenuRequested = Signal(str, str)
@@ -213,8 +331,6 @@ class GameCard(QFrame):
addToSteamRequested = Signal(str, str, str)
removeFromSteamRequested = Signal(str, str)
openGameFolderRequested = Signal(str, str)
hoverChanged = Signal(str, bool)
focusChanged = Signal(str, bool)
def __init__(
self, name, description, cover_path, appid, controller_support, exec_line,
@@ -257,33 +373,47 @@ class GameCard(QFrame):
self.display_filter = game_config.get_display_filter()
self.badge_view_mode = ui_config.get_badge_view_mode()
self.current_theme_name = ui_config.get_theme()
self.layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
self.list_layout = self.layout_mode == "list"
parent_mode = parent.property("library_layout_mode") if parent is not None else None
self.layout_mode = str(
parent_mode or getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")
).lower()
self.list_layout = self.layout_mode in {"list", "vertical"}
self.vertical_layout = self.layout_mode == "vertical"
self.horizontal_layout = self.layout_mode in {"horizontal", "horizontal_top"}
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.egs_visible = str(game_source).lower() == "egs"
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"
self.card_layout_cfg = getattr(self.theme, config_name, {})
if self.vertical_layout:
config_name = "GAME_CARD_VERTICAL"
elif self.list_layout:
config_name = "GAME_CARD_LIST"
elif self.horizontal_layout:
config_name = "GAME_CARD_HORIZONTAL"
else:
config_name = "GAME_CARD_GRID"
self.card_layout_cfg = self._get_layout_config(self.theme, config_name)
self.card_geometry_cfg = self._get_card_geometry_config(self.theme)
default_margin = 8 if self.list_layout else 20
self.base_extra_margin = self.card_layout_cfg.get("extra_margin", default_margin)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"]
self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]
self._scale = self.theme.GAME_CARD_ANIMATION["default_scale"]
self._hovered = False
self._focused = False
self._hover_sound_pending = False
self.setMouseTracking(True)
self.animations = GameCardAnimations(self, self.theme)
self.animations.setup_animations()
card_style_name = (
"GAME_CARD_VERTICAL_STYLE"
if self.vertical_layout
else "GAME_CARD_WINDOW_STYLE"
)
self.setProperty("theme_style_name", card_style_name)
card_style = (
self.theme.GAME_CARD_VERTICAL_STYLE
if self.vertical_layout
else self.theme.GAME_CARD_WINDOW_STYLE
)
self.setStyleSheet(card_style)
self.setup_card_animations(self.theme, self.card_layout_cfg)
self.shadow = QGraphicsDropShadowEffect(self)
self.shadow.setBlurRadius(self.theme.shadow_blur_radius)
@@ -301,6 +431,7 @@ class GameCard(QFrame):
self.coverWidget = QWidget()
if self.list_layout:
self.coverWidget.setProperty("theme_style_name", "COVER_WIDGET_STYLE")
self.coverWidget.setStyleSheet(self.theme.COVER_WIDGET_STYLE)
coverLayout = QStackedLayout(self.coverWidget)
coverLayout.setContentsMargins(0, 0, 0, 0)
@@ -310,8 +441,8 @@ class GameCard(QFrame):
self.coverLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE)
if self.missing_executable_path or (
str(self.game_source).lower() == "gog"
and self.exec_line.startswith("gog://install/")
str(self.game_source).lower() in ("gog", "egs")
and self.exec_line.startswith(("gog://install/", "egs://install/"))
):
self.coverOpacity = QGraphicsOpacityEffect(self.coverLabel)
self.coverOpacity.setOpacity(self.theme.missing_exe_cover_opacity)
@@ -349,9 +480,7 @@ class GameCard(QFrame):
config=corner_config,
parent=self.coverWidget,
)
self.steamLabel.setVisible(self.steam_visible)
if self.economy_mode:
self.steamLabel.setVisible(False)
self.steamLabel.setVisible(self.steam_visible and not self.vertical_layout)
gog_icon = self.theme_manager.get_icon("badge_gog", as_path=True)
self.gogLabel = SourceCorner(
@@ -359,9 +488,15 @@ class GameCard(QFrame):
config=corner_config,
parent=self.coverWidget,
)
self.gogLabel.setVisible(self.gog_visible)
if self.economy_mode:
self.gogLabel.setVisible(False)
self.gogLabel.setVisible(self.gog_visible and not self.vertical_layout)
egs_icon = self.theme_manager.get_icon("badge_egs", as_path=True)
self.egsLabel = SourceCorner(
icon=egs_icon,
config=corner_config,
parent=self.coverWidget,
)
self.egsLabel.setVisible(self.egs_visible and not self.vertical_layout)
portproton_icon = self.theme_manager.get_icon("badge_portproton", as_path=True)
self.portprotonLabel = SourceCorner(
@@ -369,9 +504,9 @@ class GameCard(QFrame):
config=corner_config,
parent=self.coverWidget,
)
self.portprotonLabel.setVisible(self.portproton_visible)
if self.economy_mode:
self.portprotonLabel.setVisible(False)
self.portprotonLabel.setVisible(
self.portproton_visible and not self.vertical_layout
)
if self.ppdb_visible:
self.ppdbLabel = ClickableLabel(
@@ -417,9 +552,36 @@ class GameCard(QFrame):
self.nameLabel.setContentsMargins(0, 0, 10, 0)
else:
self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
self.layout_.addWidget(self.nameLabel)
if self.list_layout:
if self.horizontal_layout:
self.nameLabel.setWordWrap(True)
name_style = (
self.theme.GAME_CARD_VERTICAL_NAME_STYLE
if self.vertical_layout
else self.theme.GAME_CARD_NAME_LABEL_STYLE
)
self.nameLabel.setStyleSheet(name_style)
if self.vertical_layout:
self.nameLabel.setProperty(
"theme_style_name", "GAME_CARD_VERTICAL_NAME_STYLE"
)
if self.vertical_layout:
stretches = self.theme.GAME_CARD_VERTICAL["column_stretches"]
self.layout_.addWidget(self.nameLabel, stretches[0])
for text, stretch in zip(
(self.last_launch, self.formatted_playtime, str(self.game_source)),
stretches[1:],
strict=True,
):
label = QLabel(text)
label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
label.setProperty(
"theme_style_name", "GAME_CARD_COLUMN_LABEL_STYLE"
)
label.setStyleSheet(self.theme.GAME_CARD_COLUMN_LABEL_STYLE)
self.layout_.addWidget(label, stretch)
else:
self.layout_.addWidget(self.nameLabel)
if self.list_layout and not self.vertical_layout:
self.layout_.addStretch()
font_size = self.nameLabel.font().pointSizeF()
@@ -436,6 +598,114 @@ class GameCard(QFrame):
layout.invalidate()
parent.updateGeometry()
def refresh_theme(self, theme: Any) -> None:
"""Refresh cached card visuals after a live theme change."""
old_theme = self.theme
old_layout_cfg = self.card_layout_cfg
self.theme = theme
if self.vertical_layout:
config_name = "GAME_CARD_VERTICAL"
elif self.list_layout:
config_name = "GAME_CARD_LIST"
elif self.horizontal_layout:
config_name = "GAME_CARD_HORIZONTAL"
else:
config_name = "GAME_CARD_GRID"
self.card_layout_cfg = self._get_layout_config(theme, config_name)
self.card_geometry_cfg = self._get_card_geometry_config(theme)
default_margin = 8 if self.list_layout else 20
self.base_extra_margin = self.card_layout_cfg.get("extra_margin", default_margin)
spacing = self.card_layout_cfg.get("spacing", 12 if self.list_layout else 5)
self.layout_.setSpacing(spacing)
margin = self.base_extra_margin // 2
self.layout_.setContentsMargins(margin, margin, margin, margin)
self.shadow.setBlurRadius(theme.shadow_blur_radius)
self.shadow.setColor(QColor(theme.color_shadow_card))
self.shadow.setOffset(*theme.shadow_offset)
if hasattr(self, "coverOpacity"):
self.coverOpacity.setOpacity(theme.missing_exe_cover_opacity)
if is_valid_protondb_tier(self.protondb_tier):
self.protondbLabel.setStyleSheet(
theme.get_protondb_badge_style(self.protondb_tier)
)
if self.ppdb_id:
ppdb_style = (
theme.get_ppdb_badge_style(self.ppdb_rating)
if self.ppdb_rating
else theme.STEAM_BADGE_STYLE
)
self.ppdbLabel.setStyleSheet(ppdb_style)
if self.getAntiCheatText(self.anticheat_status):
self.anticheatLabel.setStyleSheet(
theme.get_anticheat_badge_style(self.anticheat_status)
)
corner_config = theme.get_source_corner_config()
for label, icon_name in (
(self.steamLabel, "badge_steam"),
(self.gogLabel, "badge_gog"),
(self.egsLabel, "badge_egs"),
(self.portprotonLabel, "badge_portproton"),
):
icon = self.theme_manager.get_icon(
icon_name, self.current_theme_name, as_path=True
)
label.refresh_source_theme(corner_config, icon)
if (
old_theme.GAME_CARD_ANIMATION != theme.GAME_CARD_ANIMATION
or old_layout_cfg != self.card_layout_cfg
):
self.animations.refresh_theme(theme)
else:
self.animations.theme = theme
self.update_favorite_icon()
layout_changed = (
old_layout_cfg != self.card_layout_cfg
or old_theme.COMPACT_CARD != theme.COMPACT_CARD
or old_theme.favoriteLabelSize != theme.favoriteLabelSize
or old_theme.favoriteLabelIconSize != theme.favoriteLabelIconSize
)
if layout_changed:
self.update_scale()
else:
self.update()
def _get_card_geometry_config(self, theme: Any) -> dict:
theme_orientation = self.card_layout_cfg.get(
"card_orientation", "horizontal"
)
if (
self.horizontal_layout
and ui_config.get_horizontal_card_orientation(theme_orientation)
== "vertical"
):
return getattr(theme, "GAME_CARD_GRID", {})
return self.card_layout_cfg
@staticmethod
def _get_layout_config(theme: Any, config_name: str) -> dict:
config = getattr(theme, config_name, {})
get_override = getattr(theme, "get_theme_override", None)
if not callable(get_override) or get_override(config_name) is not None:
return config
config = dict(config)
for name in (
"GAME_CARD_GRID", "GAME_CARD_HORIZONTAL",
"GAME_CARD_LIST", "GAME_CARD_VERTICAL",
):
override = get_override(name)
if isinstance(override, dict) and "card_animation_type" in override:
config["card_animation_type"] = override["card_animation_type"]
break
else:
animation = getattr(theme, "GAME_CARD_ANIMATION", {})
config["card_animation_type"] = animation.get(
"card_animation_type", "gradient"
)
radius = str(getattr(theme, "border_radius_card", "")).removesuffix("px")
if radius.isdigit():
config.update(cover_radius=int(radius), border_radius=int(radius))
return config
def on_cover_loaded(self, pixmap):
self.animated_cover_path = ""
self.base_pixmap = pixmap
@@ -448,7 +718,8 @@ class GameCard(QFrame):
height = cover_size
else:
width = self.base_card_width
height = int(self.base_card_width * 1.5)
layout_config = getattr(self, "card_geometry_cfg", {})
height = int(self.base_card_width * layout_config.get("cover_aspect_ratio", 1.5))
if self._set_animated_cover(cover_path, width, height):
return
fallback_exe, fallback_icon_path = self._get_exe_icon_fallback()
@@ -591,13 +862,14 @@ class GameCard(QFrame):
ribbon_size = int(current_width * ribbon_cfg.get("size_ratio", 0.28))
ribbon_size = max(ribbon_size, int(ribbon_cfg.get("min_size", 54) * self._scale))
source_ribbons = [
(self.steam_visible, self.steamLabel),
(self.gog_visible, self.gogLabel),
(self.portproton_visible, self.portprotonLabel),
(self.steam_visible and not self.vertical_layout, self.steamLabel),
(self.gog_visible and not self.vertical_layout, self.gogLabel),
(self.egs_visible and not self.vertical_layout, self.egsLabel),
(self.portproton_visible and not self.vertical_layout, self.portprotonLabel),
]
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
@@ -615,6 +887,7 @@ class GameCard(QFrame):
self.protondbLabel.raise_()
self.portprotonLabel.raise_()
self.gogLabel.raise_()
self.egsLabel.raise_()
self.steamLabel.raise_()
except RuntimeError:
pass
@@ -637,7 +910,15 @@ class GameCard(QFrame):
int(self.card_layout_cfg.get("cover_size", 56) * self._scale),
)
margin_left = self.card_layout_cfg.get("cover_left_margin", 10)
self.setFixedSize(scaled_width + scaled_extra, row_height + scaled_extra)
if self.vertical_layout:
self.setMinimumWidth(0)
self.setFixedHeight(row_height + scaled_extra)
self.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed,
)
else:
self.setFixedSize(scaled_width + scaled_extra, row_height + scaled_extra)
self.coverWidget.setFixedSize(icon_size + margin_left, icon_size)
self.coverWidget.setContentsMargins(margin_left, 0, 0, 0)
self.coverLabel.setFixedSize(icon_size, icon_size)
@@ -645,11 +926,11 @@ class GameCard(QFrame):
small_card_mode = self.base_card_width < self.theme.COMPACT_CARD["width_threshold"]
height_ratio = (
self.theme.COMPACT_CARD["height_ratio"]
if small_card_mode else self.card_layout_cfg.get("card_height_ratio", 1.8)
if small_card_mode else self.card_geometry_cfg.get("card_height_ratio", 1.8)
)
scaled_height = int(self.base_card_width * height_ratio * self._scale)
self.setFixedSize(scaled_width + scaled_extra, scaled_height + scaled_extra)
cover_ratio = self.card_layout_cfg.get("cover_aspect_ratio", 1.5)
cover_ratio = self.card_geometry_cfg.get("cover_aspect_ratio", 1.5)
cover_height = int(scaled_width * cover_ratio)
self.coverWidget.setFixedSize(scaled_width, cover_height)
self.coverLabel.setFixedSize(scaled_width, cover_height)
@@ -751,19 +1032,24 @@ 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.egs_visible = str(self.game_source).lower() == "egs"
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
hidden_badges = self.badge_view_mode == "hidden"
show_source_ribbons = self.vertical_layout is not True
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 and show_source_ribbons)
self.gogLabel.setVisible(self.gog_visible and show_source_ribbons)
self.egsLabel.setVisible(self.egs_visible and show_source_ribbons)
self.portprotonLabel.setVisible(
self.portproton_visible and show_source_ribbons
)
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)
@@ -953,76 +1239,6 @@ class GameCard(QFrame):
favorites_config.set_games(favorites)
self.update_favorite_icon()
def getBorderWidth(self) -> int:
return self._borderWidth
def setBorderWidth(self, value: int):
if self._borderWidth != value:
self._borderWidth = value
self.borderWidthChanged.emit()
self.update()
def getGradientAngle(self) -> float:
return self._gradientAngle
def setGradientAngle(self, value: float):
if self._gradientAngle != value:
self._gradientAngle = value
self.gradientAngleChanged.emit()
self.update()
def getScale(self) -> float:
return self._scale
def setScale(self, value: float):
if self._scale != value:
self._scale = value
self.update_scale()
self.scaleChanged.emit()
borderWidth = Property(int, fget=getBorderWidth, fset=setBorderWidth, notify=borderWidthChanged)
gradientAngle = Property(float, fget=getGradientAngle, fset=setGradientAngle, notify=gradientAngleChanged)
scale = Property(float, fget=getScale, fset=setScale, notify=scaleChanged)
def paintEvent(self, event):
super().paintEvent(event)
self.animations.paint_border(QPainter(self))
def enterEvent(self, event):
self._hover_sound_pending = True
self.animations.handle_enter_event()
super().enterEvent(event)
def leaveEvent(self, event):
self._hover_sound_pending = False
self.animations.handle_leave_event()
super().leaveEvent(event)
def mouseMoveEvent(self, event):
if self._hover_sound_pending:
from portprotonqt.sound_manager import SoundManager
SoundManager().play("navigate")
self._hover_sound_pending = False
super().mouseMoveEvent(event)
def focusInEvent(self, event):
if (
QApplication.activeWindow() is not None
and event.reason() not in (
Qt.FocusReason.ActiveWindowFocusReason,
Qt.FocusReason.MouseFocusReason,
)
):
from portprotonqt.sound_manager import SoundManager
SoundManager().play("navigate")
self.animations.handle_focus_in_event()
super().focusInEvent(event)
def focusOutEvent(self, event):
self.animations.handle_focus_out_event()
super().focusOutEvent(event)
def hideEvent(self, event: QHideEvent) -> None:
self.set_animated_cover_paused(True)
super().hideEvent(event)
@@ -1031,28 +1247,34 @@ class GameCard(QFrame):
self.set_animated_cover_paused(False)
super().showEvent(event)
def mousePressEvent(self, event):
def click(self) -> None:
if self._get_missing_executable_path() and self.context_menu_manager:
self.context_menu_manager.handle_missing_executable(self)
return
game_data = {
"name": self.name,
"description": self.description,
"cover_path": self.cover_path,
"appid": self.appid,
"controller_support": self.controller_support,
"exec_line": self.exec_line,
"last_launch": self.last_launch,
"formatted_playtime": self.formatted_playtime,
"playtime_seconds": self.playtime_seconds,
"protondb_tier": self.protondb_tier,
"game_source": self.game_source,
"anticheat_status": self.anticheat_status,
"anticheat_slug": self.anticheat_slug,
"ppdb_id": self.ppdb_id,
"ppdb_rating": self.ppdb_rating,
"protondb_appid": self.protondb_appid,
"autoinstall_exe_name": getattr(self, "autoinstall_exe_name", ""),
}
self.select_callback(game_data)
def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton:
game_data = {
"name": self.name,
"description": self.description,
"cover_path": self.cover_path,
"appid": self.appid,
"controller_support": self.controller_support,
"exec_line": self.exec_line,
"last_launch": self.last_launch,
"formatted_playtime": self.formatted_playtime,
"playtime_seconds": self.playtime_seconds,
"protondb_tier": self.protondb_tier,
"game_source": self.game_source,
"anticheat_status": self.anticheat_status,
"anticheat_slug": self.anticheat_slug,
"ppdb_id": self.ppdb_id,
"ppdb_rating": self.ppdb_rating,
"protondb_appid": self.protondb_appid,
"autoinstall_exe_name": getattr(self, "autoinstall_exe_name", ""),
}
self.select_callback(game_data)
self.click()
super().mousePressEvent(event)
def cleanup(self):
+311 -20
View File
@@ -1,5 +1,6 @@
from typing import Protocol
from portprotonqt.game_card import GameCard
from types import SimpleNamespace
from typing import Any, Protocol
from portprotonqt.game_card import AnimatedCard, GameCard
from portprotonqt.search_utils import (
SearchOptimizer,
ThreadedSearch,
@@ -7,15 +8,42 @@ from portprotonqt.search_utils import (
search_index,
)
from PySide6.QtWidgets import QAbstractButton
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller, QStackedWidget
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QRegion
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QFrame, QScrollArea, QSlider, QScroller, QStackedWidget
from PySide6.QtCore import QRect, QRectF, Qt, QTimer
from PySide6.QtGui import QPaintEvent, QPainter, QPainterPath, QPixmap, QRegion
from portprotonqt.custom_widgets import FlowLayout, AutoHideScrollArea
from portprotonqt.config import favorites_config, game_config, ui_config
from portprotonqt.image_utils import load_pixmap_async
from portprotonqt.localization import _
from portprotonqt.detail_pages.utils import remove_cover_background, setup_cover_background
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
from collections import deque
class FullLibraryTile(AnimatedCard):
def __init__(self, theme: Any):
super().__init__()
self.name = ""
self.animation_base_size = theme.fullLibraryTileSize
self.tile_pixmap = QPixmap()
self.setup_card_animations(
theme, GameCard._get_layout_config(theme, "GAME_CARD_HORIZONTAL")
)
self.update_scale()
def set_tile_pixmap(self, pixmap: QPixmap) -> None:
self.tile_pixmap = pixmap
self.update()
def paintEvent(self, event: QPaintEvent) -> None:
QFrame.paintEvent(self, event)
painter = QPainter(self)
painter.drawPixmap(self.rect(), self.tile_pixmap)
painter.end()
border_painter = QPainter(self)
self.animations.paint_border(border_painter)
border_painter.end()
class MainWindowProtocol(Protocol):
"""Protocol defining the interface that MainWindow must implement for GameLibraryManager."""
@@ -46,10 +74,18 @@ class GameLibraryManager:
self.game_card_cache = {}
self.pending_images = {}
self.card_width = ui_config.get_card_width()
self.layout_mode = str(getattr(theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
theme_mode = str(getattr(theme, "LIBRARY_LAYOUT_MODE", "grid"))
self.layout_mode = ui_config.get_library_layout_mode(theme_mode)
self.gamesListWidget: QWidget | None = None
self.gamesListLayout: FlowLayout | None = None
self.gamesListLayout: FlowLayout | QHBoxLayout | QVBoxLayout | None = None
self.gamesScrollArea: QScrollArea | None = None
self.libraryHeaderWidget: QWidget | None = None
self.libraryContentLayout: QVBoxLayout | None = None
self.libraryBackgroundLabel: QLabel | None = None
self.fullLibraryTile: FullLibraryTile | None = None
self._full_library_tile_covers: list[str] = []
self._full_library_tile_pixmaps: dict[int, QPixmap] = {}
self.full_library_open = False
self.sizeSlider: QSlider | None = None
self._update_timer: QTimer | None = None
self._incremental_add_timer: QTimer | None = None
@@ -69,23 +105,61 @@ class GameLibraryManager:
def create_games_library_widget(self):
"""Creates the games library widget with search, grid, and slider."""
self.gamesLibraryWidget = QWidget()
self.gamesLibraryWidget.setProperty("theme_style_name", "LIBRARY_WIDGET_STYLE")
self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
layout = QVBoxLayout(self.gamesLibraryWidget)
library_background = getattr(self.theme, "LIBRARY_BACKGROUND", None)
stack_layout = QGridLayout(self.gamesLibraryWidget)
if isinstance(library_background, dict):
stack_layout.setContentsMargins(*library_background["margins"])
else:
stack_layout.setContentsMargins(0, 0, 0, 0)
self.libraryBackgroundLabel = QLabel()
stack_layout.addWidget(self.libraryBackgroundLabel, 0, 0)
content_widget = QWidget()
content_widget.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
layout = QVBoxLayout(content_widget)
stack_layout.addWidget(content_widget, 0, 0)
self.libraryContentLayout = layout
layout.setSpacing(15)
# Search widget
searchWidget, self.searchEdit = self.main_window.createSearchWidget()
layout.addWidget(searchWidget)
if self.layout_mode == "vertical":
self.libraryHeaderWidget = self._create_library_header()
layout.addWidget(self.libraryHeaderWidget)
# Scroll area for game grid
scrollArea = AutoHideScrollArea(theme=self.theme)
scrollArea.setProperty("theme_style_name", "TRANSPARENT_BACKGROUND_STYLE")
self.gamesScrollArea = scrollArea
scrollArea.setWidgetResizable(True)
QScroller.grabGesture(scrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture)
self.gamesListWidget = QWidget()
self.gamesListWidget.setProperty("library_layout_mode", self.layout_mode)
self.gamesListWidget.setProperty("theme_style_name", "LIST_WIDGET_STYLE")
self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE)
self.gamesListLayout = FlowLayout(self.gamesListWidget)
if self.layout_mode in {"horizontal", "horizontal_top"}:
layout_config = getattr(self.theme, "GAME_CARD_HORIZONTAL", {})
self.gamesListLayout = QHBoxLayout(self.gamesListWidget)
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.gamesListLayout.setContentsMargins(
*layout_config.get("layout_margins", (0, 0, 0, 0))
)
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
elif self.layout_mode == "vertical":
layout_config = self.theme.GAME_CARD_VERTICAL
self.gamesListLayout = QVBoxLayout(self.gamesListWidget)
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.gamesListLayout.setContentsMargins(
*layout_config.get("layout_margins", (0, 0, 0, 0))
)
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
else:
self.gamesListLayout = FlowLayout(self.gamesListWidget)
self.gamesListWidget.setLayout(self.gamesListLayout)
scrollArea.setWidget(self.gamesListWidget)
@@ -106,7 +180,7 @@ class GameLibraryManager:
self.main_window._register_gamepad_tooltip(self.sizeSlider, f"{self.card_width} px")
self.sizeSlider.sliderReleased.connect(self.main_window.on_slider_released)
sliderLayout.addWidget(self.sizeSlider)
if self.layout_mode == "list":
if self.layout_mode in {"list", "vertical", "horizontal", "horizontal_top"}:
self.sizeSlider.setVisible(False)
self._set_card_width_from_slider()
@@ -123,30 +197,128 @@ class GameLibraryManager:
# Connect scroll event for lazy loading
scrollArea.verticalScrollBar().valueChanged.connect(self.load_visible_images)
scrollArea.horizontalScrollBar().valueChanged.connect(self.load_visible_images)
return self.gamesLibraryWidget
def _create_library_header(self) -> QWidget:
"""Create the shared header for vertical library columns."""
config = self.theme.GAME_CARD_VERTICAL
header = QWidget()
header.setFixedHeight(config["header_height"])
header.setProperty("theme_style_name", "LIBRARY_HEADER_STYLE")
header.setStyleSheet(self.theme.LIBRARY_HEADER_STYLE)
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(*config["header_margins"])
header_layout.setSpacing(config["header_spacing"])
header_layout.addSpacing(config["header_cover_width"])
labels = (_("Game Title"), _("LAST LAUNCH"), _("TIME SPENT"), _("Library"))
for text, stretch in zip(labels, config["column_stretches"], strict=True):
label = QLabel(text)
label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
label.setProperty("theme_style_name", "LIBRARY_HEADER_LABEL_STYLE")
label.setStyleSheet(self.theme.LIBRARY_HEADER_LABEL_STYLE)
header_layout.addWidget(label, stretch)
return header
def rebuild_library_layout(self, layout_mode: str) -> None:
"""Replace the card layout when a theme changes its library mode."""
if self.gamesListWidget is None or self.gamesListLayout is None:
return
self.clear_layout(self.gamesListLayout)
self.fullLibraryTile = None
old_layout = self.gamesListLayout
if layout_mode in {"horizontal", "horizontal_top"}:
layout_config = getattr(self.theme, "GAME_CARD_HORIZONTAL", {})
self.gamesListLayout = QHBoxLayout()
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.gamesListLayout.setContentsMargins(
*layout_config.get("layout_margins", (0, 0, 0, 0))
)
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
elif layout_mode == "vertical":
layout_config = self.theme.GAME_CARD_VERTICAL
self.gamesListLayout = QVBoxLayout()
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.gamesListLayout.setContentsMargins(
*layout_config.get("layout_margins", (0, 0, 0, 0))
)
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
else:
self.gamesListLayout = FlowLayout()
QWidget().setLayout(old_layout)
self.gamesListWidget.setLayout(self.gamesListLayout)
if self.gamesScrollArea is not None:
horizontal = layout_mode in {"horizontal", "horizontal_top"}
vertical_policy = (
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
if horizontal
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
self.gamesScrollArea.setVerticalScrollBarPolicy(vertical_policy)
theme_mode = ui_config.get_library_layout_mode(
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
)
self.full_library_open = theme_mode == "horizontal_top" and layout_mode == "grid"
if self.full_library_open and self.libraryBackgroundLabel is not None:
remove_cover_background(self.libraryBackgroundLabel)
self.layout_mode = layout_mode
if (
layout_mode == "vertical"
and self.libraryHeaderWidget is None
and self.libraryContentLayout is not None
):
self.libraryHeaderWidget = self._create_library_header()
self.libraryContentLayout.insertWidget(1, self.libraryHeaderWidget)
if self.libraryHeaderWidget is not None:
self.libraryHeaderWidget.setVisible(layout_mode == "vertical")
self.gamesListWidget.setProperty("library_layout_mode", layout_mode)
if self.sizeSlider is not None:
self.sizeSlider.setVisible(
not self.full_library_open
and layout_mode not in {"list", "vertical", "horizontal", "horizontal_top"}
)
self._set_card_width_from_slider()
self.main_window.card_width = self.card_width
self.set_games(self.games, focus_first_card=False)
def open_full_library(self) -> None:
"""Open the complete grid from the horizontal top mode."""
if self.layout_mode == "horizontal_top":
self.rebuild_library_layout("grid")
def close_full_library(self) -> bool:
"""Return from the complete grid to the horizontal top mode."""
if not self.full_library_open:
return False
self.full_library_open = False
self.rebuild_library_layout("horizontal_top")
return True
def on_slider_released(self):
"""Handles slider release to update card size."""
if self.layout_mode == "list":
if self.full_library_open or self.layout_mode in {
"list", "vertical", "horizontal", "horizontal_top"
}:
return
if self.sizeSlider is None:
return
self._set_card_width_from_slider()
if hasattr(self.main_window, "_gamepad_tooltip_map"):
self.main_window._gamepad_tooltip_map[self.sizeSlider] = f"{self.card_width} px"
if self.layout_mode != "list":
ui_config.set_card_width(self.card_width)
ui_config.set_card_width(self.card_width)
self.main_window.card_width = self.card_width
for card in self.game_card_cache.values():
card.update_card_size(self.card_width)
self.update_game_grid()
def _set_card_width_from_slider(self):
"""Use max card width for list layout."""
"""Use max card width for fixed-size layouts."""
if self.sizeSlider is None:
return
if self.layout_mode == "list":
if self.full_library_open or self.layout_mode in {
"list", "vertical", "horizontal", "horizontal_top"
}:
self.card_width = self.sizeSlider.maximum()
else:
self.card_width = self.sizeSlider.value()
@@ -199,7 +371,7 @@ class GameLibraryManager:
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
try:
self.main_window.current_hovered_card._hovered = False
self.main_window.current_hovered_card.leaveEvent(None)
self.main_window.current_hovered_card.animations.handle_leave_event()
except RuntimeError:
pass # Card already deleted
self.main_window.current_hovered_card = None
@@ -210,10 +382,36 @@ class GameLibraryManager:
except RuntimeError:
pass # Card already deleted
self.main_window.current_focused_card = card
self._update_library_background(card)
else:
if self.main_window.current_focused_card == card:
self.main_window.current_focused_card = None
def _update_library_background(self, card: GameCard) -> None:
"""Render the configured library background from the active cover."""
if self.full_library_open or self.libraryBackgroundLabel is None:
return
self.render_library_background(self.libraryBackgroundLabel, card)
def render_library_background(self, label: QLabel, card: GameCard) -> None:
"""Render the configured library background on a target label."""
pixmap = card.coverLabel.pixmap()
if pixmap is None or pixmap.isNull():
return
library_config = getattr(self.theme, "LIBRARY_BACKGROUND", None)
if not isinstance(library_config, dict):
remove_cover_background(label)
return
backgrounds = dict(getattr(self.theme, "DETAIL_PAGE_BACKGROUNDS", {}))
backgrounds.update(library_config.get("backgrounds", {}))
background_theme = SimpleNamespace(
DETAIL_PAGE_BG_MODE=library_config.get("mode", "gradient"),
DETAIL_PAGE_BACKGROUNDS=backgrounds,
)
setup_cover_background(
label, pixmap, self.main_window, background_theme
)
def _collapse_library_filter_controls(self) -> None:
if getattr(self.main_window, "_library_controls_hover_close_delayed", False):
return
@@ -251,10 +449,11 @@ class GameLibraryManager:
try:
if self.main_window.current_hovered_card:
self.main_window.current_hovered_card._hovered = False
self.main_window.current_hovered_card.leaveEvent(None)
self.main_window.current_hovered_card.animations.handle_leave_event()
except RuntimeError:
pass # Card already deleted
self.main_window.current_hovered_card = card
self._update_library_background(card)
else:
if self.main_window.current_hovered_card == card:
self.main_window.current_hovered_card = None
@@ -275,9 +474,16 @@ class GameLibraryManager:
"""Schedules a game grid update with debouncing."""
if focus_first_card is not None:
self._focus_first_card_after_update = focus_first_card
self.layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
theme_mode = ui_config.get_library_layout_mode(
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
)
if not self.full_library_open:
self.layout_mode = theme_mode
if self.sizeSlider is not None:
self.sizeSlider.setVisible(self.layout_mode != "list")
self.sizeSlider.setVisible(
not self.full_library_open
and self.layout_mode not in {"list", "vertical", "horizontal", "horizontal_top"}
)
old_card_width = self.card_width
self._set_card_width_from_slider()
self.main_window.card_width = self.card_width
@@ -292,6 +498,9 @@ class GameLibraryManager:
# When filtering, we want to update with the current filtered_games
# which has already been set by _perform_search
pass
if is_filter and self.layout_mode == "horizontal_top":
self.dirty = True
is_filter = False
self.is_filtering = is_filter
self._pending_update = True
@@ -386,6 +595,7 @@ class GameLibraryManager:
if added_new_card:
self.load_visible_images()
self._sync_full_library_tile()
self.force_update_cards_library()
self._cancel_incremental_add()
self._schedule_focus_first_card()
@@ -404,7 +614,9 @@ class GameLibraryManager:
self._update_search_results(search_text)
else:
# Full update: sorting, removal/addition, reorganization
games_list = self.filtered_games if self.filtered_games else self.games
games_list = self.filtered_games
if self.layout_mode != "horizontal_top" and not games_list:
games_list = self.games
favorites = favorites_config.get_games()
sort_method = game_config.get_sort_method()
@@ -433,6 +645,13 @@ class GameLibraryManager:
sorted_fav = sorted(fav_games, key=partition_sort_key)
sorted_non_fav = sorted(non_fav_games, key=partition_sort_key)
sorted_games = sorted_fav + sorted_non_fav
if self.layout_mode == "horizontal_top":
limit = self.theme.horizontalTopGameLimit
tile_games = sorted_games[limit:limit + self.theme.fullLibraryTileGameCount]
self._full_library_tile_covers = [str(game[2] or "") for game in tile_games]
sorted_games = sorted_games[:limit]
else:
self._full_library_tile_covers = []
# Build set of current game keys for faster lookup
current_game_keys = {(game[0], game[5]) for game in sorted_games}
@@ -525,8 +744,68 @@ class GameLibraryManager:
self.force_update_cards_library()
self.is_filtering = False # Reset flag in any case
if not self._incremental_add_queue:
self._sync_full_library_tile()
self._schedule_focus_first_card()
def _sync_full_library_tile(self) -> None:
if self.fullLibraryTile is not None:
self.fullLibraryTile.setParent(None)
self.fullLibraryTile.deleteLater()
self.fullLibraryTile = None
if self.layout_mode != "horizontal_top" or self.gamesListLayout is None:
return
if len(self.filtered_games) <= self.theme.horizontalTopGameLimit:
return
tile = FullLibraryTile(self.theme)
tile.setProperty("full_library_tile", True)
tile.setStyleSheet(self.theme.FULL_LIBRARY_TILE_STYLE)
tile.clicked.connect(self.open_full_library)
self.gamesListLayout.addWidget(tile)
self.fullLibraryTile = tile
self._full_library_tile_pixmaps = {}
cover_width, cover_height = self.theme.fullLibraryTileSize
for index, cover_path in enumerate(self._full_library_tile_covers):
load_pixmap_async(
cover_path,
cover_width,
cover_height,
lambda pixmap, item=index: self._set_full_library_tile_cover(
tile, item, pixmap
),
)
def _set_full_library_tile_cover(
self, tile: FullLibraryTile, index: int, pixmap: QPixmap
) -> None:
if tile is not self.fullLibraryTile:
return
self._full_library_tile_pixmaps[index] = pixmap
width, height = self.theme.fullLibraryTileSize
cell_width = width // self.theme.fullLibraryTileColumns
cell_height = height // self.theme.fullLibraryTileRows
canvas = QPixmap(width, height)
canvas.fill(Qt.GlobalColor.transparent)
painter = QPainter(canvas)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
clip = QPainterPath()
clip.addRoundedRect(
QRectF(canvas.rect()),
self.theme.fullLibraryTileRadius,
self.theme.fullLibraryTileRadius,
)
painter.setClipPath(clip)
for item, cover in self._full_library_tile_pixmaps.items():
cell = QRect(
item % self.theme.fullLibraryTileColumns * cell_width,
item // self.theme.fullLibraryTileColumns * cell_height,
cell_width,
cell_height,
)
painter.drawPixmap(cell, cover)
painter.end()
tile.set_tile_pixmap(canvas)
def _schedule_focus_first_card(self) -> None:
if not self._focus_first_card_after_update:
return
@@ -722,6 +1001,7 @@ class GameLibraryManager:
"""Add a single game without full reload."""
self.games.append(game_data)
self.filtered_games.append(game_data) # Assume no filter active; adjust if needed
self._build_search_indices(self.games)
self.dirty = True
self.update_game_grid()
self._update_missing_exe_button()
@@ -748,6 +1028,17 @@ class GameLibraryManager:
self.update_game_grid()
self._update_missing_exe_button()
detail_manager = getattr(self.main_window, "detail_page_manager", None)
source = getattr(detail_manager, "_current_detail_source", None)
if detail_manager and source and source[0] == "game" and detail_manager._detail_page_active:
detail_data = source[1]
if (detail_data.get("name"), detail_data.get("exec_line")) == old_key:
detail_data.update(
name=game_data[0], description=game_data[1],
cover_path=game_data[2], exec_line=game_data[5],
)
detail_manager._reopen_current_detail_page()
def remove_game_incremental(self, game_name: str, exec_line: str):
"""Remove a single game without full reload."""
key = (game_name, exec_line)
-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)
+117 -6
View File
@@ -6,6 +6,8 @@ import platform
import re
import shutil
import subprocess
import time
import zlib
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
@@ -25,10 +27,14 @@ 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
GOG_SETUP_TIMEOUT = 600
GOG_REPAIR_METADATA_TIMEOUT = 30
GOG_COMPRESSED_MANIFEST_VERSION = 2
GOG_BUILDS_API_VERSION = 2
GOG_PRODUCT_LOCALES = {
"bg": "bg-BG", "cs": "cs-CZ", "da": "da-DK", "de": "de-DE",
"el": "el-GR", "en": "en-US", "es": "es-ES", "fi": "fi-FI",
@@ -45,10 +51,26 @@ class GOGAPI:
def __init__(self) -> None:
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
self.data_dir = data_home / "PortProtonQt" / "gog"
self.data_dir = data_home / "PortProtonQt" / "launcher" / "gog"
self.bin_dir = data_home / "PortProtonQt" / "bin"
# Remove after the legacy launcher storage migration period.
legacy_dir = data_home / "PortProtonQt" / "gog"
if legacy_dir.exists() and not self.data_dir.exists():
self.data_dir.parent.mkdir(parents=True, exist_ok=True)
legacy_dir.rename(self.data_dir)
legacy_bin = self.data_dir / "bin"
if legacy_bin.is_dir():
self.bin_dir.mkdir(parents=True, exist_ok=True)
for legacy_binary in legacy_bin.iterdir():
target = self.bin_dir / legacy_binary.name
if not target.exists():
legacy_binary.rename(target)
if not any(legacy_bin.iterdir()):
legacy_bin.rmdir()
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.bin_dir / "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"
@@ -56,7 +78,7 @@ class GOGAPI:
def get_gogdl_path(self) -> str | None:
"""Return an available gogdl executable."""
bundled = self.data_dir / "bin/gogdl"
bundled = self.bin_dir / "gogdl"
if bundled.is_file() and os.access(bundled, os.X_OK):
return str(bundled)
return shutil.which("gogdl")
@@ -76,22 +98,62 @@ 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.bin_dir / "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"
target = self.bin_dir / "gogdl"
temporary = target.with_suffix(".part")
target.parent.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256()
@@ -332,6 +394,53 @@ class GOGAPI:
logger.warning("Failed to inspect GOG installation path %s: %s", parent, error)
return None
def prepare_repair_manifest(self, app_id: str, install_path: Path) -> None:
"""Restore gogdl metadata before verifying an imported Windows game."""
manifest_path = self.config_dir / "heroic_gogdl" / "manifests" / app_id
if manifest_path.is_file():
return
info = self._load_json(install_path / f"goggame-{app_id}.info", {})
build_info = self._load_json(install_path / f"goggame-{app_id}.id", {})
build_id = build_info.get("buildId") or info.get("buildId")
credentials = self.get_credentials()
response = requests.get(
f"https://content-system.gog.com/products/{app_id}/os/windows/builds",
params={"generation": GOG_COMPRESSED_MANIFEST_VERSION, "_version": GOG_BUILDS_API_VERSION},
headers={"Authorization": f"Bearer {credentials.get('access_token', '')}"},
timeout=GOG_REPAIR_METADATA_TIMEOUT,
)
response.raise_for_status()
builds = response.json().get("items", [])
build = next((item for item in builds if (
str(item.get("build_id")) == str(build_id) if build_id
else not item.get("branch")
)), None)
if build is None and build_id:
build = next((item for item in builds if not item.get("branch") and item.get("urls")), None)
if build:
logger.warning(
"GOG build %s for %s is unavailable; verifying against build %s",
build_id, app_id, build.get("build_id"),
)
if not build or not build.get("urls"):
raise OSError(f"GOG build not found for installed game {app_id}")
response = requests.get(build["urls"][0]["url"], timeout=GOG_REPAIR_METADATA_TIMEOUT)
response.raise_for_status()
data = response.content
if build.get("generation") == GOG_COMPRESSED_MANIFEST_VERSION:
data = zlib.decompress(data)
manifest = orjson.loads(data)
manifest["HGLPlatform"] = "windows"
manifest["HGLInstallLanguage"] = (
info.get("language") or next(iter(info.get("languages", [])), "en-US")
)
manifest["HGLdlcs"] = [
{"id": path.stem.removeprefix("goggame-")}
for path in install_path.glob("goggame-*.info")
if path.stem != f"goggame-{app_id}"
]
self._save_json(manifest_path, manifest)
def get_launch_target(self, app_id: str) -> str | None:
"""Return the primary executable from gogdl installation metadata."""
primary, install_path = self._get_primary_task(app_id)
@@ -452,6 +561,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)
@@ -570,7 +681,7 @@ class GOGAPI:
response.raise_for_status()
data = response.json()
game = data.get("game", {})
if not game.get("visible_in_library", True):
if data.get("type") not in {"game", "mod"} or not game.get("visible_in_library", True):
return {}
steam_release = next(
(
+51 -12
View File
@@ -2,10 +2,12 @@
import os
import re
import shlex
import struct
import io
import pefile
from PIL import Image
from portprotonqt.config.base import CACHE_DIR
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
@@ -15,16 +17,41 @@ 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(
"XDG_CACHE_HOME",
os.path.join(os.path.expanduser("~"), ".cache"),
)
icon_name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(exe_path))
return os.path.join(
cache_home,
"PortProtonQt",
CACHE_DIR,
"images",
"exe_icons",
f"{icon_name}.png",
@@ -44,6 +71,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 +86,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 +107,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 +124,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."""
+28 -15
View File
@@ -8,8 +8,8 @@ from PySide6.QtCore import (
from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy
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.config import CACHE_DIR, ui_config
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
@@ -23,6 +23,7 @@ downloader = Downloader()
logger = get_logger(__name__)
COVER_IMAGE_EXTENSIONS = (".png", ".apng", ".jpg", ".jpeg", ".gif", ".webp", ".jxl", ".svg")
DEFAULT_ANIMATION_DELAY_MS = 100
REMOTE_COVER_DOWNLOAD_TIMEOUT = 15
# Global queue and thread pool for image loading
image_load_queue = Queue()
@@ -366,8 +367,7 @@ def _get_ppdb_autoinstall_image_name(cover: str) -> str:
def clear_ppdb_autoinstall_image_cache(cover_urls: list[str]) -> None:
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")
image_folder = str(CACHE_DIR / "images")
for cover in cover_urls:
filename = _get_ppdb_autoinstall_image_name(cover)
if not filename:
@@ -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
@@ -455,8 +456,7 @@ def load_pixmap_async(
pixmap.load(icon_path)
return pixmap
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")
image_folder = str(CACHE_DIR / "images")
os.makedirs(image_folder, exist_ok=True)
if cover and cover.startswith("https://steamcdn-a.akamaihd.net/steam/apps/"):
@@ -488,7 +488,11 @@ def load_pixmap_async(
finish_with(pixmap)
finish_with(load_fallback_pixmap())
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
downloader.download_async(
cover, local_path,
timeout=REMOTE_COVER_DOWNLOAD_TIMEOUT,
callback=on_downloaded,
)
return
except Exception as e:
logger.error(f"URL processing error {cover}: {e}")
@@ -523,7 +527,11 @@ def load_pixmap_async(
logger.info("Downloading SGDB cover for %s -> %s", app_name or "unknown", filename)
finish_with(load_fallback_pixmap())
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
downloader.download_async(
cover, local_path,
timeout=REMOTE_COVER_DOWNLOAD_TIMEOUT,
callback=on_downloaded,
)
return
except Exception as e:
@@ -554,7 +562,11 @@ def load_pixmap_async(
finish_with(pixmap)
finish_with(load_fallback_pixmap())
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
downloader.download_async(
cover, local_path,
timeout=REMOTE_COVER_DOWNLOAD_TIMEOUT,
callback=on_downloaded,
)
return
except Exception as e:
logger.error("Error processing remote image URL %s: %s", cover, str(e))
@@ -605,6 +617,7 @@ def round_corners(pixmap, radius):
painter.end()
return rounded
class FullscreenDialog(QDialog):
"""
Dialog for viewing images without standard controls.
@@ -690,9 +703,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
+640
View File
@@ -0,0 +1,640 @@
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 AnimatedCard
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
theme: Any
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(AnimatedCard)
if card.isVisible() and card.isEnabled()
]
if self._navigate_horizontal_library(game_cards, tab_index, code, value):
return
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 _navigate_horizontal_library(
self, cards: list[QWidget], tab_index: int, code: int, value: int
) -> bool:
if tab_index == 1:
mode = str(
getattr(self._parent.theme, "LIBRARY_LAYOUT_MODE", "grid")
).lower()
layout = getattr(self._parent, "autoInstallContainerLayout", None)
else:
manager = getattr(self._parent, "game_library_manager", None)
mode = getattr(manager, "layout_mode", "grid")
layout = getattr(manager, "gamesListLayout", None)
if mode not in {
"horizontal", "horizontal_top"
}:
return False
if code != PAD_DPAD_X or value == 0 or layout is None:
return False
ordered_cards = []
for index in range(layout.count()):
item = layout.itemAt(index)
widget = item.widget() if item is not None else None
if widget in cards:
ordered_cards.append(widget)
focused = QApplication.focusWidget()
if not ordered_cards:
return True
if focused not in ordered_cards:
self._focus_grid_card(ordered_cards[0])
return True
target_index = (ordered_cards.index(focused) + value) % len(ordered_cards)
self._focus_grid_card(ordered_cards[target_index])
return True
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()
+388
View File
@@ -0,0 +1,388 @@
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_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 _handle_table_button(self, focused: QWidget | None, button_code: int) -> bool:
if not isinstance(focused, QTableWidget) or button_code not in BUTTONS['confirm']:
return False
if not self.handle_table_confirm(focused):
return False
SoundManager().play("click")
return 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")
manager = getattr(self._parent, "game_library_manager", None)
close_full_library = getattr(manager, "close_full_library", None)
if callable(close_full_library) and close_full_library():
return
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,
}
+716
View File
@@ -0,0 +1,716 @@
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 isinstance(cell_widget, QComboBox) and cell_widget.isEnabled():
cell_widget.showPopup()
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
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.data(Qt.ItemDataRole.CheckStateRole) is not None
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 AnimatedCard
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, AnimatedCard):
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(AnimatedCard)
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, AnimatedCard) 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}")
+612
View File
@@ -0,0 +1,612 @@
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 AnimatedCard, 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, AnimatedCard | 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",
"gamesLayoutCombo",
):
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(AnimatedCard):
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._handle_standard_back()
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: ...
+412
View File
@@ -0,0 +1,412 @@
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:
"""Open the SDL3 handle that combines all connected gamepads."""
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_changed = active_gamepad.update()
if not active_gamepad.connected():
self.gamepad_hotplug.emit('remove')
return
if active_changed:
self._reset_input_state()
self.gamepad_type = self._get_effective_gamepad_type(active_gamepad)
self._refresh_gamepad_ui()
logger.info(
"Active gamepad changed to SDL instance %s",
active_gamepad.active_instance_id,
)
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)
+521
View File
@@ -0,0 +1,521 @@
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 and Main Tables)
table = self._get_current_settings_table()
if not table or table.rowCount() == 0:
return
combo_tables = (
self.settings_dialog.advanced_table,
self.settings_dialog.settings_table,
)
if self.settings_dialog and table in combo_tables 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 isinstance(cell_widget, QComboBox) and cell_widget.isEnabled():
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 == 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
@@ -169,10 +169,19 @@ msgstr ""
msgid "Import"
msgstr ""
msgid "Open Game Folder"
msgid "Edit Shortcut"
msgstr ""
msgid "Edit Shortcut"
msgid "Update"
msgstr ""
msgid "Disable EOS Overlay"
msgstr ""
msgid "Enable EOS Overlay"
msgstr ""
msgid "Open Game Folder"
msgstr ""
msgid "Remove from Steam"
@@ -240,12 +249,26 @@ msgstr ""
msgid "Failed to save custom data: {error}"
msgstr ""
msgid "Executable not found"
msgstr ""
#, python-brace-format
msgid "Executable not found: {0}"
msgstr ""
msgid "Select another executable"
msgstr ""
msgid "Game name and executable path are required"
msgstr ""
msgid "Failed to generate .desktop file data"
msgstr ""
#, python-brace-format
msgid "Failed to save .desktop file: {error}"
msgstr ""
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr ""
@@ -254,10 +277,6 @@ msgstr ""
msgid "Removed old .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
msgid "Failed to save .desktop file: {error}"
msgstr ""
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr ""
@@ -334,15 +353,24 @@ msgstr ""
msgid "Denied"
msgstr ""
msgid "Game Title"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
msgid "TIME SPENT"
msgstr ""
msgid "Library"
msgstr ""
msgid "Unknown Game"
msgstr ""
msgid "Downloading Wine…"
msgstr ""
msgid "Library"
msgstr ""
msgid "Auto Install"
msgstr ""
@@ -406,10 +434,6 @@ msgstr ""
msgid "Unknown Application"
msgstr ""
#, python-brace-format
msgid "Executable not found: {0}"
msgstr ""
#, python-brace-format
msgid "File not found: {0}"
msgstr ""
@@ -434,6 +458,19 @@ msgstr ""
msgid "32-bit dependencies are missing. Install them now?"
msgstr ""
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr ""
msgid "Select Epic game folder"
msgstr ""
msgid "Another Epic operation is running"
msgstr ""
msgid "Wine prefix not found"
msgstr ""
msgid "Game not found."
msgstr ""
@@ -729,6 +766,9 @@ msgstr ""
msgid "sec."
msgstr ""
msgid "Pause Game"
msgstr ""
msgid "Exit"
msgstr ""
@@ -741,6 +781,12 @@ msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Resume Game"
msgstr ""
msgid "Failed to pause game"
msgstr ""
msgid "No recent games"
msgstr ""
@@ -763,12 +809,6 @@ msgstr ""
msgid "Size"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
msgid "TIME SPENT"
msgstr ""
msgid "MAIN STORY"
msgstr ""
@@ -793,15 +833,15 @@ msgstr ""
msgid "Create Log"
msgstr ""
msgid "Stop Log"
msgstr ""
msgid "Open Card"
msgstr ""
msgid "Reinstall"
msgstr ""
msgid "Stop Log"
msgstr ""
msgid "Failed to start debug session"
msgstr ""
@@ -833,9 +873,6 @@ msgstr ""
msgid "A new AppImage update is available."
msgstr ""
msgid "Update"
msgstr ""
msgid "Later"
msgstr ""
@@ -1101,9 +1138,15 @@ msgstr ""
msgid "System WINE"
msgstr ""
msgid "Global Game Settings"
msgstr ""
msgid "Exe Settings"
msgstr ""
msgid "Edit user.conf"
msgstr ""
msgid "Search settings…"
msgstr ""
@@ -1122,12 +1165,15 @@ msgstr ""
msgid "Description"
msgstr ""
msgid "Open PPDB"
msgid "Edit PPDB"
msgstr ""
msgid "Clear PPDB"
msgstr ""
msgid "Failed to load global game settings."
msgstr ""
msgid "Executable path is not available."
msgstr ""
@@ -1137,6 +1183,9 @@ msgstr ""
msgid "Failed to open PPDB file:\n"
msgstr ""
msgid "Failed to open file:\n"
msgstr ""
msgid "Information"
msgstr ""
@@ -1149,6 +1198,12 @@ msgstr ""
msgid "Failed to remove PPDB file:\n"
msgstr ""
msgid "Failed to apply changes. Check logs."
msgstr ""
msgid "Default"
msgstr ""
msgid "disabled"
msgstr ""
@@ -1158,9 +1213,6 @@ msgstr ""
msgid "Default value"
msgstr ""
msgid "Failed to apply changes. Check logs."
msgstr ""
msgid "Borderless window"
msgstr ""
@@ -1354,9 +1406,6 @@ msgstr ""
msgid "Enable {0}"
msgstr ""
msgid "Default"
msgstr ""
msgid "FSR upscaling"
msgstr ""
@@ -1433,6 +1482,15 @@ msgstr ""
msgid "Round corners (px)"
msgstr ""
msgid "Font scale"
msgstr ""
msgid "Font size"
msgstr ""
msgid "Font"
msgstr ""
msgid "Top-left"
msgstr ""
@@ -1742,6 +1800,9 @@ msgstr ""
msgid "Layout and limiter"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Presets"
msgstr ""
@@ -1827,9 +1888,6 @@ msgstr ""
msgid "Libraries"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
@@ -1945,9 +2003,6 @@ msgstr ""
msgid "Disk: "
msgstr ""
msgid "Game Title"
msgstr ""
msgid "Started at"
msgstr ""
@@ -1993,9 +2048,39 @@ msgstr ""
msgid "Failed to refresh GOG library: {0}"
msgstr ""
msgid "Failed to log out of Epic account"
msgstr ""
msgid "Copy the authorization code to clipboard"
msgstr ""
msgid "Epic authorization…"
msgstr ""
#, python-brace-format
msgid "Epic login failed: {0}"
msgstr ""
msgid "Epic account connected"
msgstr ""
msgid "Epic account not connected"
msgstr ""
#, python-brace-format
msgid "Refreshing Epic library… {0}/{1}"
msgstr ""
#, python-brace-format
msgid "Failed to refresh Epic library: {0}"
msgstr ""
msgid "Select installation folder"
msgstr ""
msgid "Another download is running"
msgstr ""
msgid "Another GOG operation is already running"
msgstr ""
@@ -2008,10 +2093,6 @@ msgstr ""
msgid "Selected folder is not this GOG game"
msgstr ""
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr ""
msgid "Starting"
msgstr ""
@@ -2063,6 +2144,9 @@ msgstr ""
msgid "Display Filter:"
msgstr ""
msgid "Only Installed"
msgstr ""
msgid "Detailed"
msgstr ""
@@ -2072,6 +2156,33 @@ msgstr ""
msgid "Badge View Type:"
msgstr ""
msgid "Theme"
msgstr ""
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
msgid "Table"
msgstr ""
msgid "Horizontal Blocks"
msgstr ""
msgid "Horizontal Cards"
msgstr ""
msgid "Horizontal Top Blocks"
msgstr ""
msgid "Horizontal Top Cards"
msgstr ""
msgid "Grid Type:"
msgstr ""
#, python-brace-format
msgid ""
"Are you sure you want to delete {count} cards without executable? This "
@@ -2087,6 +2198,9 @@ msgstr ""
msgid "Auto"
msgstr ""
msgid "Account"
msgstr ""
msgid "Brief"
msgstr ""
@@ -2120,12 +2234,21 @@ msgstr ""
msgid "Hide Auto-Install Tab"
msgstr ""
msgid "Hide Control Hints"
msgstr ""
msgid "Force English language"
msgstr ""
msgid "Enable UI Sounds"
msgstr ""
msgid "Show compatibility report after game crash"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr ""
msgid "Disable runtime download"
msgstr ""
@@ -2285,6 +2408,9 @@ msgstr ""
msgid "Output"
msgstr ""
msgid "Return to desktop"
msgstr ""
msgid "Logout"
msgstr ""
@@ -2353,6 +2479,9 @@ msgstr ""
msgid "Apply Theme"
msgstr ""
msgid "Delete Theme"
msgstr ""
msgid "No link"
msgstr ""
@@ -2365,10 +2494,14 @@ msgstr ""
msgid "Link:"
msgstr ""
msgid "Prefix:"
#, python-brace-format
msgid "Delete theme '{0}'?"
msgstr ""
msgid "Use by default"
msgid "Failed to delete theme."
msgstr ""
msgid "Prefix:"
msgstr ""
msgid "Wine Configuration"
@@ -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-18 23:41+0500\n"
"PO-Revision-Date: 2026-07-28 16:30+0000\n"
"PO-Revision-Date: 2026-09-11 12:20+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"
@@ -174,12 +174,21 @@ msgstr "Löschen"
msgid "Import"
msgstr "Import"
msgid "Open Game Folder"
msgstr "Spielordner öffnen"
msgid "Edit Shortcut"
msgstr "Verknüpfung bearbeiten"
msgid "Update"
msgstr "Aktualisierung"
msgid "Disable EOS Overlay"
msgstr "EOS-Overlay deaktivieren"
msgid "Enable EOS Overlay"
msgstr "EOS-Overlay aktivieren"
msgid "Open Game Folder"
msgstr "Spielordner öffnen"
msgid "Remove from Steam"
msgstr "Von Steam entfernen"
@@ -249,12 +258,27 @@ msgstr "Desktop"
msgid "Failed to save custom data: {error}"
msgstr "Fehler beim Speichern der Benutzerdaten: {error}"
#, python-brace-format
msgid "Executable not found"
msgstr "Ausführbare Datei nicht gefunden"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "Ausführbare Datei nicht gefunden: {0}"
msgid "Select another executable"
msgstr "Wähle eine andere ausführbare Datei aus"
msgid "Game name and executable path are required"
msgstr "Spielname und Ausführungspfad werden benötigt"
msgid "Failed to generate .desktop file data"
msgstr "Fehler beim Generieren der .desktop-Datei"
#, python-brace-format
msgid "Failed to save .desktop file: {error}"
msgstr "Speichern der .desktop-Datei fehlgeschlagen: {error}"
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr "Die alte .desktop-Datei konnte nicht gelöscht werden: {error}"
@@ -263,10 +287,6 @@ msgstr "Die alte .desktop-Datei konnte nicht gelöscht werden: {error}"
msgid "Removed old .desktop file for '{game_name}'"
msgstr "Die alte .desktop-Datei für '{game_name}' wurde entfernt"
#, python-brace-format
msgid "Failed to save .desktop file: {error}"
msgstr "Speichern der .desktop-Datei fehlgeschlagen: {error}"
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr "Fehler beim Kopieren des Titelbildes: {error}"
@@ -347,15 +367,24 @@ msgstr "Kaputt"
msgid "Denied"
msgstr "Abgelehnt"
msgid "Game Title"
msgstr "Spieltitel"
msgid "LAST LAUNCH"
msgstr "LETZTER START"
msgid "TIME SPENT"
msgstr "SPIELZEIT"
msgid "Library"
msgstr "Sammlung"
msgid "Unknown Game"
msgstr "Unbekanntes Spiel"
msgid "Downloading Wine…"
msgstr "Wine wird heruntergeladen…"
msgid "Library"
msgstr "Sammlung"
msgid "Auto Install"
msgstr "Automatische Installation"
@@ -416,10 +445,6 @@ msgstr "Niemals"
msgid "Unknown Application"
msgstr "Unbekannte Anwendung"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "Ausführbare Datei nicht gefunden: {0}"
#, python-brace-format
msgid "File not found: {0}"
msgstr "Datei nicht gefunden: {0}"
@@ -445,6 +470,19 @@ msgstr "Abhängigkeiten installieren"
msgid "32-bit dependencies are missing. Install them now?"
msgstr "Notwendige 32-Bit-Software fehlen. Jetzt installieren?"
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr "'{0}' und alle Dateien im Installationsordner löschen?"
msgid "Select Epic game folder"
msgstr "Epic-Spieleordner auswählen"
msgid "Another Epic operation is running"
msgstr "Eine weitere Epic-Operation läuft"
msgid "Wine prefix not found"
msgstr "Wine-Prefix nicht gefunden"
msgid "Game not found."
msgstr "Spiel nicht gefunden."
@@ -799,6 +837,9 @@ msgstr "Min."
msgid "sec."
msgstr "Sek."
msgid "Pause Game"
msgstr "Spiel anhalten"
msgid "Exit"
msgstr "Exit"
@@ -811,6 +852,12 @@ msgstr "Favoriten"
msgid "Recent Games"
msgstr "Aktuelle Spiele"
msgid "Resume Game"
msgstr "Spiel fortsetzen"
msgid "Failed to pause game"
msgstr "Spiel konnte nicht pausiert werden"
msgid "No recent games"
msgstr "Keine aktuellen Spiele"
@@ -834,12 +881,6 @@ msgstr "Herunterladen"
msgid "Size"
msgstr "Größe"
msgid "LAST LAUNCH"
msgstr "LETZTER START"
msgid "TIME SPENT"
msgstr "SPIELZEIT"
msgid "MAIN STORY"
msgstr "HAUPTSPIEL"
@@ -864,15 +905,15 @@ msgstr "Einstellungen"
msgid "Create Log"
msgstr "Protokoll erstellen"
msgid "Stop Log"
msgstr "Protokollierung beenden"
msgid "Open Card"
msgstr "Ansicht öffnen"
msgid "Reinstall"
msgstr "Neuinstallation"
msgid "Stop Log"
msgstr "Protokollierung beenden"
msgid "Failed to start debug session"
msgstr "Protokollierung konnte nicht gestartet werden"
@@ -904,9 +945,6 @@ msgstr "Update verfügbar"
msgid "A new AppImage update is available."
msgstr "Ein neues AppImage-Update ist verfügbar."
msgid "Update"
msgstr "Aktualisierung"
msgid "Later"
msgstr "Später"
@@ -1180,9 +1218,16 @@ msgstr "Der Download oder das Entpacken wurde abgebrochen."
msgid "System WINE"
msgstr "System WINE"
#, fuzzy
msgid "Global Game Settings"
msgstr "Einstellungen speichern"
msgid "Exe Settings"
msgstr "Einstellungen für die exe-Datei"
msgid "Edit user.conf"
msgstr ""
msgid "Search settings…"
msgstr "Einstellungen suchen…"
@@ -1201,12 +1246,16 @@ msgstr "Wert"
msgid "Description"
msgstr "Beschreibung"
msgid "Open PPDB"
msgstr "PPDB öffnen"
msgid "Edit PPDB"
msgstr "PPDB bearbeiten"
msgid "Clear PPDB"
msgstr "PPDB zurücksetzen"
#, fuzzy
msgid "Failed to load global game settings."
msgstr "Laden der Themes fehlgeschlagen"
msgid "Executable path is not available."
msgstr "Der Pfad zur ausführbaren Datei ist nicht verfügbar."
@@ -1216,6 +1265,10 @@ msgstr "PPDB-Datei ist nicht vorhanden unter: "
msgid "Failed to open PPDB file:\n"
msgstr "Fehler beim Öffnen der PPDB-Datei:\n"
#, fuzzy
msgid "Failed to open file:\n"
msgstr "Fehler beim Öffnen der PPDB-Datei:\n"
msgid "Information"
msgstr "Information"
@@ -1230,6 +1283,12 @@ msgstr ""
msgid "Failed to remove PPDB file:\n"
msgstr "Fehler beim Entfernen der PPDB-Datei:\n"
msgid "Failed to apply changes. Check logs."
msgstr "Anwenden der Änderungen fehlgeschlagen. Überprüfe die Protokolle."
msgid "Default"
msgstr "Standard"
msgid "disabled"
msgstr "deaktiviert"
@@ -1239,9 +1298,6 @@ msgstr "Prefix-Name eingeben"
msgid "Default value"
msgstr "Standardwert"
msgid "Failed to apply changes. Check logs."
msgstr "Anwenden der Änderungen fehlgeschlagen. Überprüfe die Protokolle."
msgid "Borderless window"
msgstr "Rahmenloses Fenster"
@@ -1445,9 +1501,6 @@ msgstr "Voreingestellte Einstellungen"
msgid "Enable {0}"
msgstr "Einschalten {0}"
msgid "Default"
msgstr "Standard"
msgid "FSR upscaling"
msgstr "FSR-Upscaling"
@@ -1526,6 +1579,18 @@ msgstr "Hintergrund-Deckkraft"
msgid "Round corners (px)"
msgstr "Abgerundete Ecken (px)"
#, fuzzy
msgid "Font scale"
msgstr "Schriftarten"
#, fuzzy
msgid "Font size"
msgstr "Schriftarten"
#, fuzzy
msgid "Font"
msgstr "Schriftarten"
msgid "Top-left"
msgstr "Oben links"
@@ -1835,6 +1900,9 @@ msgstr "VRAM-Nutzung"
msgid "Layout and limiter"
msgstr "Layout und Limiter"
msgid "Fonts"
msgstr "Schriftarten"
msgid "Presets"
msgstr "Voreinstellungen"
@@ -1924,9 +1992,6 @@ msgstr "Auswahl"
msgid "Libraries"
msgstr "Bibliotheken"
msgid "Fonts"
msgstr "Schriftarten"
msgid "Winetricks not found. Please try again."
msgstr "Winetricks wurde nicht gefunden. Bitte versuche es erneut."
@@ -2042,9 +2107,6 @@ msgstr "Warten…"
msgid "Disk: "
msgstr "Datenträger: "
msgid "Game Title"
msgstr "Spieltitel"
msgid "Started at"
msgstr "Begonnen"
@@ -2073,7 +2135,6 @@ msgstr "GOG-Anmeldung fehlgeschlagen: {0}"
msgid "GOG account connected"
msgstr "GOG-Konto verknüpft"
#, fuzzy
msgid "Log out"
msgstr "Abmelden"
@@ -2091,9 +2152,39 @@ msgstr "GOG-Bibliothek wird aktualisiert… {0}/{1}"
msgid "Failed to refresh GOG library: {0}"
msgstr "Aktualisierung der GOG-Bibliothek fehlgeschlagen: {0}"
msgid "Failed to log out of Epic account"
msgstr "Abmeldung vom Epic-Konto fehlgeschlagen"
msgid "Copy the authorization code to clipboard"
msgstr "Kopiere den Autorisierungscode in die Zwischenablage"
msgid "Epic authorization…"
msgstr "Anmeldung bei Epic-Konto…"
#, python-brace-format
msgid "Epic login failed: {0}"
msgstr "Epic-Anmeldung fehlgeschlagen: {0}"
msgid "Epic account connected"
msgstr "Epic-Konto verknüpft"
msgid "Epic account not connected"
msgstr "Epic-Konto nicht verbunden"
#, python-brace-format
msgid "Refreshing Epic library… {0}/{1}"
msgstr "Epic-Bibliothek wird aktualisiert… {0}/{1}"
#, python-brace-format
msgid "Failed to refresh Epic library: {0}"
msgstr "Aktualisierung der Epic-Bibliothek fehlgeschlagen: {0}"
msgid "Select installation folder"
msgstr "Installationsordner auswählen"
msgid "Another download is running"
msgstr "Ein weiterer Download läuft"
msgid "Another GOG operation is already running"
msgstr "Eine andere GOG-Aktion läuft bereits"
@@ -2106,10 +2197,6 @@ msgstr "GOG-Spielordner auswählen"
msgid "Selected folder is not this GOG game"
msgstr "Der ausgewählte Ordner ist nicht dieses GOG-Spiel"
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr "'{0}' und alle Dateien im Installationsordner löschen?"
msgid "Starting"
msgstr "Starten"
@@ -2161,6 +2248,9 @@ msgstr "Alle"
msgid "Display Filter:"
msgstr "Anzeigefilter:"
msgid "Only Installed"
msgstr "Nur installierte"
msgid "Detailed"
msgstr "Detailliert"
@@ -2170,6 +2260,40 @@ msgstr "Versteckt"
msgid "Badge View Type:"
msgstr "Anzeigetyp von Titelbildern:"
#, fuzzy
msgid "Theme"
msgstr "Themen"
msgid "Grid"
msgstr ""
#, fuzzy
msgid "List"
msgstr "Hell"
#, fuzzy
msgid "Table"
msgstr "Stable"
#, fuzzy
msgid "Horizontal Blocks"
msgstr "Horizontales HUD-Layout"
#, fuzzy
msgid "Horizontal Cards"
msgstr "Horizontales HUD-Layout"
#, fuzzy
msgid "Horizontal Top Blocks"
msgstr "Horizontales HUD-Layout"
msgid "Horizontal Top Cards"
msgstr ""
#, fuzzy
msgid "Grid Type:"
msgstr "Gamepad-Typ:"
#, python-brace-format
msgid ""
"Are you sure you want to delete {count} cards without executable? This "
@@ -2187,6 +2311,9 @@ msgstr "Accounts"
msgid "Auto"
msgstr "Auto"
msgid "Account"
msgstr "Account"
msgid "Brief"
msgstr "Vereinfacht"
@@ -2220,12 +2347,21 @@ msgstr "Minimiert im Tray starten"
msgid "Hide Auto-Install Tab"
msgstr "Tab „Automatische Installation“ ausblenden"
msgid "Hide Control Hints"
msgstr "Steuerungshinweise ausblenden"
msgid "Force English language"
msgstr "Englische Sprache erzwingen"
msgid "Enable UI Sounds"
msgstr "Benutzeroberflächen-Sounds aktivieren"
msgid "Show compatibility report after game crash"
msgstr "Kompatibilitätsbericht nach Spielabsturz anzeigen"
msgid "Disable ALT Linux i586 dependency check"
msgstr ""
msgid "Disable runtime download"
msgstr "Runtime-Download deaktivieren"
@@ -2389,6 +2525,9 @@ msgstr "Wiedergabegeräte"
msgid "Output"
msgstr "Ausgang"
msgid "Return to desktop"
msgstr "Zum Desktop zurückkehren"
msgid "Logout"
msgstr "Abmelden"
@@ -2456,6 +2595,9 @@ msgstr "Theme auswählen:"
msgid "Apply Theme"
msgstr "Theme anwenden"
msgid "Delete Theme"
msgstr "Theme löschen"
msgid "No link"
msgstr "Kein Link"
@@ -2468,12 +2610,16 @@ msgstr "Autor:"
msgid "Link:"
msgstr "Link:"
#, python-brace-format
msgid "Delete theme '{0}'?"
msgstr "Theme '{0}' löschen?"
msgid "Failed to delete theme."
msgstr "Das Theme konnte nicht gelöscht werden."
msgid "Prefix:"
msgstr "Prefix:"
msgid "Use by default"
msgstr "Standardmäßig verwenden"
msgid "Wine Configuration"
msgstr "Wine-Konfiguration"
@@ -7,9 +7,9 @@
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-25 17:07+0000\n"
"PO-Revision-Date: 2026-09-10 15:44+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"
@@ -176,12 +176,21 @@ msgstr "Borrar"
msgid "Import"
msgstr "Importar"
msgid "Open Game Folder"
msgstr "Abrir la carpeta del juego"
msgid "Edit Shortcut"
msgstr "Editar acceso directo"
msgid "Update"
msgstr "Actualizar"
msgid "Disable EOS Overlay"
msgstr ""
msgid "Enable EOS Overlay"
msgstr ""
msgid "Open Game Folder"
msgstr "Abrir la carpeta del juego"
msgid "Remove from Steam"
msgstr "Eliminar de Steam"
@@ -249,6 +258,18 @@ msgstr "Escritorio"
msgid "Failed to save custom data: {error}"
msgstr "No se han podido guardar los datos personalizados: {error}"
#, fuzzy, python-brace-format
msgid "Executable not found"
msgstr "No se ha encontrado el archivo ejecutable: {0}"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "No se ha encontrado el archivo ejecutable: {0}"
#, fuzzy
msgid "Select another executable"
msgstr "Ruta al ejecutable:"
msgid "Game name and executable path are required"
msgstr ""
"Es necesario indicar el nombre del juego y la ruta del archivo ejecutable"
@@ -256,6 +277,10 @@ msgstr ""
msgid "Failed to generate .desktop file data"
msgstr "No se han podido generar los datos del archivo .desktop"
#, python-brace-format
msgid "Failed to save .desktop file: {error}"
msgstr "No se ha podido guardar el archivo .desktop: {error}"
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr "No se ha podido eliminar el archivo .desktop antiguo: {error}"
@@ -264,10 +289,6 @@ msgstr "No se ha podido eliminar el archivo .desktop antiguo: {error}"
msgid "Removed old .desktop file for '{game_name}'"
msgstr "Se ha eliminado el antiguo archivo .desktop de «{game_name}»"
#, python-brace-format
msgid "Failed to save .desktop file: {error}"
msgstr "No se ha podido guardar el archivo .desktop: {error}"
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr "No se ha podido copiar la imagen de portada: {error}"
@@ -348,15 +369,24 @@ msgstr "Falló"
msgid "Denied"
msgstr "Denegado"
msgid "Game Title"
msgstr "Título del juego"
msgid "LAST LAUNCH"
msgstr "ÚLTIMO LANZAMIENTO"
msgid "TIME SPENT"
msgstr "TIEMPO EMPLEADO"
msgid "Library"
msgstr "Biblioteca"
msgid "Unknown Game"
msgstr "Juego desconocido"
msgid "Downloading Wine…"
msgstr "Descargando Wine…"
msgid "Library"
msgstr "Biblioteca"
msgid "Auto Install"
msgstr "Instalación automática"
@@ -417,10 +447,6 @@ msgstr "Nunca"
msgid "Unknown Application"
msgstr "Aplicación desconocida"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "No se ha encontrado el archivo ejecutable: {0}"
#, python-brace-format
msgid "File not found: {0}"
msgstr "Archivo no encontrado: {0}"
@@ -447,6 +473,21 @@ msgstr "Instalar las dependencias"
msgid "32-bit dependencies are missing. Install them now?"
msgstr "Faltan algunas dependencias de 32 bits. ¿Las instalas ahora?"
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr ""
#, fuzzy
msgid "Select Epic game folder"
msgstr "Borrar la carpeta"
msgid "Another Epic operation is running"
msgstr ""
#, fuzzy
msgid "Wine prefix not found"
msgstr "Prefijo no encontrado"
msgid "Game not found."
msgstr "Juego no encontrado."
@@ -816,6 +857,9 @@ msgstr "min"
msgid "sec."
msgstr "s"
msgid "Pause Game"
msgstr "Pausar el juego"
msgid "Exit"
msgstr "Salir"
@@ -828,6 +872,12 @@ msgstr "Favoritos"
msgid "Recent Games"
msgstr "Juegos recientes"
msgid "Resume Game"
msgstr "Reanudar el juego"
msgid "Failed to pause game"
msgstr "Error al pausar el juego"
msgid "No recent games"
msgstr "Sin juegos recientes"
@@ -850,12 +900,6 @@ msgstr "Descargar"
msgid "Size"
msgstr "Tamaño"
msgid "LAST LAUNCH"
msgstr "ÚLTIMO LANZAMIENTO"
msgid "TIME SPENT"
msgstr "TIEMPO EMPLEADO"
msgid "MAIN STORY"
msgstr "HISTORIA PRINCIPAL"
@@ -880,15 +924,15 @@ msgstr "Ajustes"
msgid "Create Log"
msgstr "Crear registro"
msgid "Stop Log"
msgstr "Detener el registro"
msgid "Open Card"
msgstr "Carta abierta"
msgid "Reinstall"
msgstr "Reinstalar"
msgid "Stop Log"
msgstr "Detener el registro"
msgid "Failed to start debug session"
msgstr "No se pudo iniciar la sesión de depuración"
@@ -920,9 +964,6 @@ msgstr "Actualización disponible"
msgid "A new AppImage update is available."
msgstr "Hay una nueva actualización de AppImage disponible."
msgid "Update"
msgstr "Actualizar"
msgid "Later"
msgstr "Más tarde"
@@ -1192,9 +1233,16 @@ msgstr ""
msgid "System WINE"
msgstr ""
#, fuzzy
msgid "Global Game Settings"
msgstr "Ajustes de Wine"
msgid "Exe Settings"
msgstr ""
msgid "Edit user.conf"
msgstr ""
msgid "Search settings…"
msgstr "Buscar ajustes…"
@@ -1213,12 +1261,16 @@ msgstr ""
msgid "Description"
msgstr ""
msgid "Open PPDB"
msgid "Edit PPDB"
msgstr ""
msgid "Clear PPDB"
msgstr ""
#, fuzzy
msgid "Failed to load global game settings."
msgstr "Error al cargar los temas"
msgid "Executable path is not available."
msgstr ""
@@ -1228,6 +1280,10 @@ msgstr ""
msgid "Failed to open PPDB file:\n"
msgstr ""
#, fuzzy, python-brace-format
msgid "Failed to open file:\n"
msgstr "No se ha podido abrir la carpeta: {error}"
msgid "Information"
msgstr ""
@@ -1245,6 +1301,12 @@ msgstr ""
msgid "Failed to remove PPDB file:\n"
msgstr "No se ha podido eliminar el archivo PPDB:\n"
msgid "Failed to apply changes. Check logs."
msgstr ""
msgid "Default"
msgstr ""
msgid "disabled"
msgstr ""
@@ -1254,9 +1316,6 @@ msgstr ""
msgid "Default value"
msgstr ""
msgid "Failed to apply changes. Check logs."
msgstr ""
msgid "Borderless window"
msgstr ""
@@ -1449,9 +1508,6 @@ msgstr ""
msgid "Enable {0}"
msgstr ""
msgid "Default"
msgstr ""
msgid "FSR upscaling"
msgstr ""
@@ -1528,6 +1584,17 @@ msgstr ""
msgid "Round corners (px)"
msgstr ""
#, fuzzy
msgid "Font scale"
msgstr "Local"
#, fuzzy
msgid "Font size"
msgstr "Tamaño"
msgid "Font"
msgstr ""
msgid "Top-left"
msgstr ""
@@ -1837,6 +1904,9 @@ msgstr ""
msgid "Layout and limiter"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Presets"
msgstr ""
@@ -1922,9 +1992,6 @@ msgstr ""
msgid "Libraries"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
@@ -2040,9 +2107,6 @@ msgstr "Esperando…"
msgid "Disk: "
msgstr "Disco: "
msgid "Game Title"
msgstr "Título del juego"
msgid "Started at"
msgstr "Comenzó en"
@@ -2089,9 +2153,40 @@ msgstr ""
msgid "Failed to refresh GOG library: {0}"
msgstr "Error al iniciar el juego: {0}"
msgid "Failed to log out of Epic account"
msgstr ""
msgid "Copy the authorization code to clipboard"
msgstr ""
msgid "Epic authorization…"
msgstr ""
#, fuzzy, python-brace-format
msgid "Epic login failed: {0}"
msgstr "Archivo: {0}"
msgid "Epic account connected"
msgstr ""
msgid "Epic account not connected"
msgstr ""
#, fuzzy, python-brace-format
msgid "Refreshing Epic library… {0}/{1}"
msgstr "Error al iniciar el juego: {0}"
#, fuzzy, python-brace-format
msgid "Failed to refresh Epic library: {0}"
msgstr "Error al iniciar el juego: {0}"
msgid "Select installation folder"
msgstr ""
#, fuzzy
msgid "Another download is running"
msgstr "Descargando"
msgid "Another GOG operation is already running"
msgstr ""
@@ -2105,10 +2200,6 @@ msgstr "Borrar la carpeta"
msgid "Selected folder is not this GOG game"
msgstr ""
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr ""
#, fuzzy
msgid "Starting"
msgstr "Iniciar"
@@ -2163,6 +2254,9 @@ msgstr ""
msgid "Display Filter:"
msgstr ""
msgid "Only Installed"
msgstr "Solo instalados"
msgid "Detailed"
msgstr ""
@@ -2172,6 +2266,35 @@ msgstr ""
msgid "Badge View Type:"
msgstr ""
#, fuzzy
msgid "Theme"
msgstr "Temas"
msgid "Grid"
msgstr ""
msgid "List"
msgstr ""
#, fuzzy
msgid "Table"
msgstr "Estable"
msgid "Horizontal Blocks"
msgstr ""
msgid "Horizontal Cards"
msgstr ""
msgid "Horizontal Top Blocks"
msgstr ""
msgid "Horizontal Top Cards"
msgstr ""
msgid "Grid Type:"
msgstr ""
#, fuzzy, python-brace-format
msgid ""
"Are you sure you want to delete {count} cards without executable? This "
@@ -2189,6 +2312,9 @@ msgstr ""
msgid "Auto"
msgstr ""
msgid "Account"
msgstr ""
msgid "Brief"
msgstr ""
@@ -2222,12 +2348,21 @@ msgstr ""
msgid "Hide Auto-Install Tab"
msgstr ""
msgid "Hide Control Hints"
msgstr ""
msgid "Force English language"
msgstr ""
msgid "Enable UI Sounds"
msgstr ""
msgid "Show compatibility report after game crash"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr ""
msgid "Disable runtime download"
msgstr ""
@@ -2388,6 +2523,10 @@ msgstr ""
msgid "Output"
msgstr ""
#, fuzzy
msgid "Return to desktop"
msgstr "Añadir al escritorio"
msgid "Logout"
msgstr ""
@@ -2455,6 +2594,10 @@ msgstr ""
msgid "Apply Theme"
msgstr ""
#, fuzzy
msgid "Delete Theme"
msgstr "Borrar el archivo"
msgid "No link"
msgstr ""
@@ -2467,12 +2610,17 @@ 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:"
msgid "Use by default"
msgstr "Usar por defecto"
msgid "Wine Configuration"
msgstr "Configuración de Wine"
Loaded 100 of 246 files, more files were not shown because too many files have changed in this diff. Show more