83 Commits

Author SHA1 Message Date
e9865da0fe chore: bump to 0.1.9
All checks were successful
Build AppImage, Arch and Fedora Packages / Build AppImage (push) Successful in 2m57s
Build AppImage, Arch and Fedora Packages / Build Arch Package (push) Successful in 1m40s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (41) (push) Successful in 54s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (42) (push) Successful in 49s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (43) (push) Successful in 52s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (rawhide) (push) Successful in 1m3s
Build AppImage, Arch and Fedora Packages / Create and Publish Release (push) Successful in 22s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-08 11:46:35 +05:00
b88b4e0652 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-08 11:45:29 +05:00
d0fad6a3c9 fix: added correct parent to GameCard
All checks were successful
Code check / Check code (push) Successful in 1m30s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-07 15:46:27 +05:00
468887110c fix(qt): prevent RuntimeError from accessing deleted Qt C++ objects
All checks were successful
Code check / Check code (push) Successful in 1m34s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-07 12:45:37 +05:00
32e4950a00 Revert "chore: bump ver to 0.1.9"
All checks were successful
Code check / Check code (push) Successful in 1m5s
renovate / renovate (push) Successful in 44s
This reverts commit 29d25cec01.
2025-12-06 14:26:04 +05:00
b16074fa5c fix: Add protection against accessing deleted Qt objects in async callbacks
All checks were successful
Code check / Check code (push) Successful in 1m7s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-06 14:22:41 +05:00
1bd7c23419 fix(settings): Remove surrounding quotes from the value if present
All checks were successful
Code check / Check code (push) Successful in 1m21s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-04 11:53:54 +05:00
f4275dd465 fix(get_portproton_start_command): Check if flatpak command exists before trying to run it
All checks were successful
Code check / Check code (push) Successful in 1m7s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-02 18:44:47 +05:00
c8b91c4687 fix(settings): update keyboard navigation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-02 18:40:27 +05:00
4aaeb2e809 fix: dont start game by Enter
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-02 18:23:49 +05:00
b6ea9350fa fix: fix gamecard refrefresh regression after 0889aa8
All checks were successful
Code check / Check code (push) Successful in 1m13s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-02 17:52:19 +05:00
29d25cec01 chore: bump ver to 0.1.9
All checks were successful
Code check / Check code (push) Successful in 1m13s
Build AppImage, Arch and Fedora Packages / Build AppImage (push) Successful in 2m50s
Build AppImage, Arch and Fedora Packages / Build Arch Package (push) Successful in 1m42s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (41) (push) Successful in 59s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (42) (push) Successful in 1m1s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (43) (push) Successful in 54s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (rawhide) (push) Successful in 58s
Build AppImage, Arch and Fedora Packages / Create and Publish Release (push) Successful in 35s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-01 20:29:45 +05:00
a634de5462 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-01 20:27:12 +05:00
1ba1781994 feat(settings): added preloader because flatpak is too slow
All checks were successful
Code check / Check code (push) Successful in 1m6s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-01 17:06:20 +05:00
0aae292f61 fix(settings): fix work on Flatpak
All checks were successful
Code check / Check code (push) Successful in 1m5s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-01 16:59:43 +05:00
3ef433af0c fix: Only handle menu button if our main window is currently active
All checks were successful
Code check / Check code (push) Successful in 1m52s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-12-01 12:08:55 +05:00
Gitea Actions
9fe33e02d8 chore: update steam apps list 2025-12-01T00:01:44Z 2025-12-01 00:01:44 +00:00
2ac91a759d chore(localization): update
All checks were successful
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Has been skipped
Code check / Check code (push) Successful in 1m5s
Fetch Data / build (push) Successful in 1m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-30 13:20:33 +05:00
2c82bff204 fix(main_window): remove redundant loading status and improve loading flow
All checks were successful
Code check / Check code (push) Successful in 1m5s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-30 13:14:38 +05:00
0889aa883e fix: refresh button refresh custom data too now
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-30 12:59:32 +05:00
7780dcfc4d chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m10s
renovate / renovate (push) Successful in 38s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-29 23:12:31 +05:00
9ef39ae2b6 fix: save cover images from URL to custom_data folder
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-29 23:08:54 +05:00
86fb2b2d7c chore: added refresh hint
All checks were successful
Code check / Check code (push) Successful in 1m24s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-29 11:33:07 +05:00
9d469f0a12 add horizontal scroll styles for exe settings
All checks were successful
Code check / Check code (push) Successful in 1m11s
2025-11-28 13:54:25 +00:00
665a4df322 perf(search): implement full async + indexed search system with major performance gains
All checks were successful
Code check / Check code (push) Successful in 1m26s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-28 13:48:17 +05:00
3abaccb1e0 fix(startup): prevent main thread hangs and optimize resource loading
All checks were successful
Code check / Check code (push) Successful in 1m33s
- run start_sh initialization via QTimer.singleShot with timeout
- add timeout protection to load_theme_fonts()
- load Steam/EGS/PortProton games in parallel instead of sequential
- delay game loading until UI is fully initialized
- fix callback chaining to avoid blocking operations
- add proper timeout + error handling for all Steam/EGS network requests
- add timeouts for flatpak subprocess calls
- improve file I/O error handling to avoid UI freeze
- optimize theme font loading:
  - delay font loading via QTimer.singleShot
  - load fonts in batches of 10
  - reduce font load timeout to 3s
  - remove fonts only when switching themes

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-28 12:00:00 +05:00
77b025f580 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-27 16:03:43 +05:00
Renovate Bot
42e2025e54 fix(deps): lock file maintenance python dependencies
All checks were successful
Code check / Check code (pull_request) Successful in 1m13s
Code check / Check code (push) Successful in 1m24s
2025-11-27 10:58:16 +00:00
Renovate Bot
8f84bbce31 chore(deps): update https://gitea.com/actions/setup-python digest to 83679a8
Some checks failed
Code check / Check code (push) Has been cancelled
Code check / Check code (pull_request) Has been cancelled
2025-11-27 10:55:42 +00:00
3026e7da4e fix: fix code work with pyside 6.10
Some checks failed
Code check / Check code (push) Has been cancelled
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-27 15:54:08 +05:00
3522764c3e fix(detail-page): prevent crash on exit by adding robust widget/animation safety checks
All checks were successful
Code check / Check code (push) Successful in 1m21s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-26 21:43:18 +05:00
fd456e5330 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-26 18:02:48 +05:00
99a963d60c chore: drop all pyright ignore
All checks were successful
Code check / Check code (push) Successful in 1m22s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-26 17:34:24 +05:00
0b36e73bce chore(build): fix build on arch
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-26 16:59:04 +05:00
4baa2e8684 chore(themes): delete unused fonts
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-26 16:57:56 +05:00
4344bbca70 feat: added combination for Update Grid
All checks were successful
Code check / Check code (push) Successful in 1m16s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-26 14:54:15 +05:00
0a8a290d2d chore: ignore pyright
All checks were successful
Code check / Check code (push) Successful in 1m18s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-25 20:49:58 +05:00
92652e8faa fix(mouse_emulations): ignore triggers
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-25 20:32:44 +05:00
4f2afaed24 fix: use kernel for detect_gamepad_axes
Some checks failed
Code check / Check code (push) Failing after 1m24s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-25 14:39:52 +05:00
1751e01e47 feat: added setfocus to gamedetail page
All checks were successful
Code check / Check code (push) Successful in 1m23s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-25 10:22:27 +05:00
0f74a47aed chore(localization): update
All checks were successful
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Has been skipped
Code check / Check code (push) Successful in 1m31s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-24 23:49:29 +05:00
666ec654a0 fix(ui): prevent text truncation in show_gamepad_tooltip
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-24 23:34:57 +05:00
0c25cc9fd2 chore(settings): rework tabble
All checks were successful
Code check / Check code (push) Successful in 1m19s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-24 23:23:56 +05:00
5de83dbf49 fix(settings): drop .ppdb from show-ppdb
All checks were successful
Code check / Check code (push) Successful in 1m22s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-24 23:03:31 +05:00
1821faadf6 styles for virtual keyboard
All checks were successful
Code check / Check code (push) Successful in 2m56s
2025-11-24 16:47:41 +00:00
17f0a6b0ea fix(ui): prevent segfault by validating widget existence in async callbacks
All checks were successful
Code check / Check code (push) Successful in 1m26s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-24 16:27:02 +05:00
e9c75b998f chore(localization): update
All checks were successful
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Has been skipped
Code check / Check code (push) Successful in 1m17s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-23 15:43:59 +05:00
bbfbc00c11 fix(settings): fix virtual keyboard
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-23 15:28:30 +05:00
b7804fdd01 fix(ui): unify handling of QMessageBox and QMenu in controller
All checks were successful
Code check / Check code (push) Successful in 1m14s
- Added _handle_common_ui_elements() for QMessageBox, QMenu, etc.
- Fixed A/B behavior for single- and multi-button QMessageBox dialogs
- Improved D-pad navigation and focused-button selection
- Removed duplicated logic in specialized handlers

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-23 15:13:21 +05:00
Renovate Bot
043da2cf5d chore(deps): update https://gitea.com/actions/checkout action to v6
All checks were successful
Code check / Check code (pull_request) Successful in 1m33s
Code check / Check code (push) Successful in 1m6s
2025-11-23 00:01:25 +00:00
2fa10e7db3 feat(settings): added tooltip to desc
All checks were successful
Code check / Check code (push) Successful in 1m26s
renovate / renovate (push) Successful in 41s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-22 23:34:11 +05:00
b1b9706272 chore(input_manager): clean dialogs code
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-22 22:36:37 +05:00
9c11d33c0a chore(setting): add human readeble value to PW_VULKAN_USE
All checks were successful
Code check / Check code (push) Successful in 1m16s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-22 19:52:36 +05:00
173e1cb88e fix(settings): fix PW_WINE_USE_LIST
All checks were successful
Code check / Check code (push) Successful in 1m20s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-22 11:53:24 +05:00
30606c7ec1 Revert "fix: eliminate blocking calls causing startup freezes and UI hangs"
This reverts commit b2a1046f9d.
2025-11-22 11:21:25 +05:00
873e8b050e chore(settings): added disable style to comboboxes
All checks were successful
Code check / Check code (push) Successful in 1m27s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-22 00:28:15 +05:00
59dad21945 chore(settings): adjust virtual keyboard button width (40 → 50)
All checks were successful
Code check / Check code (push) Successful in 1m27s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-22 00:16:14 +05:00
b0c4e943ae feat(settings): added blocked style to advanced tab
All checks were successful
Code check / Check code (push) Successful in 1m30s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-22 00:05:46 +05:00
19e01bba17 Fix: normalize disabled value for PW_AMD_VULKAN_USE
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-21 23:43:36 +05:00
836e6cdd36 feat(settings): added initial gamepad navigation
All checks were successful
Code check / Check code (push) Successful in 1m20s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-21 00:08:02 +05:00
b2a1046f9d fix: eliminate blocking calls causing startup freezes and UI hangs
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-20 10:55:35 +05:00
80a2c06b5a feat: added refresh button
All checks were successful
Code check / Check code (push) Successful in 1m20s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-16 17:07:58 +05:00
f0a4ace735 perf: add config and icon caching to reduce I/O and improve UI responsiveness
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-16 16:57:40 +05:00
7dfaee6831 feat(settings): added proton, 3d_api and prefixes settings
All checks were successful
Code check / Check code (push) Successful in 1m17s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-16 13:35:35 +05:00
5481cd80d7 chore: added null pixmaps check
All checks were successful
Code check / Check code (push) Successful in 1m14s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-16 12:25:01 +05:00
a016cfa810 chore: convert list to set for optimize
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-16 12:13:42 +05:00
8fc097ccaf chore: remove broken styles
All checks were successful
Code check / Check code (push) Successful in 1m10s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-16 12:03:18 +05:00
ad3eeb6e06 chore(localization): update
All checks were successful
Code check / Check code (push) Successful in 1m7s
renovate / renovate (push) Successful in 29s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-15 21:39:05 +05:00
92631cd2c6 chore: separate settings list to new module
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-15 21:39:03 +05:00
4477679f2d chore: replace emulataion buttons to xbox + start
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-15 21:38:07 +05:00
Renovate Bot
b6644eeee5 fix(deps): update dependency pillow to v12 2025-11-15 21:38:07 +05:00
Renovate Bot
2e921226c4 chore(deps): update https://gitea.com/actions/setup-python action to v6 2025-11-15 21:38:06 +05:00
Renovate Bot
4fc1ea73d3 chore(deps): update https://gitea.com/actions/setup-node action to v6 2025-11-15 21:38:06 +05:00
Renovate Bot
3c15cbe495 chore(deps): update archlinux:base-devel docker digest to 943bdad 2025-11-15 21:38:06 +05:00
fed6aafed5 feat: trigger emulation by Xbox + B
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-15 21:38:06 +05:00
2e8be13437 WINETRICKS_TABBLE_STYLE more fixes
All checks were successful
Code check / Check code (pull_request) Successful in 1m19s
2025-11-15 16:22:59 +07:00
ea272c29b6 WINETRICKS_TABBLE_STYLE reworked
All checks were successful
Code check / Check code (pull_request) Successful in 1m14s
2025-11-13 15:43:13 +07:00
17262f6c9f Play Button & Settings Button in row 2025-11-07 12:17:36 +07:00
e07f3f06bc chore(build): return QtSvg to appimage
All checks were successful
Code check / Check code (push) Successful in 1m29s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-04 12:28:09 +05:00
16a3f4e09a chore(build): added udev rule to allow create virtual devices
All checks were successful
Code check / Check code (push) Successful in 1m28s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-04 11:14:17 +05:00
a448ba29b0 feat(input_manager): added mouse emulation
All checks were successful
Code check / Check code (push) Successful in 1m19s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-03 12:34:27 +05:00
06e55db54d feat(settings): update styles
All checks were successful
Code check / Check code (push) Successful in 1m13s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-02 16:05:22 +05:00
5fce23f261 chore: disable pre-commit auto update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-02 15:31:01 +05:00
58 changed files with 18607 additions and 2516 deletions

View File

@@ -12,7 +12,7 @@ jobs:
name: Build AppImage
runs-on: ubuntu-22.04
steps:
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: Install required dependencies
run: |
@@ -62,7 +62,7 @@ jobs:
- name: Install build dependencies
run: |
dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
python3-build pyproject-rpm-macros python3-setuptools \
python3-build pyproject-rpm-macros systemd-rpm-macros python3-setuptools \
redhat-rpm-config nodejs npm
- name: Setup rpmbuild environment
@@ -73,7 +73,7 @@ jobs:
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout repo
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: Copy fedora.spec
run: |
@@ -94,7 +94,7 @@ jobs:
name: Build Arch Package
runs-on: ubuntu-22.04
container:
image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
image: archlinux:base-devel@sha256:943bdad9e9d0d23503f24797b44ce2cc1531bf101e18b3e7fb8c8776190dc45e
volumes:
- /usr:/usr-host
- /opt:/opt-host
@@ -103,7 +103,7 @@ jobs:
steps:
- name: Prepare container
run: |
pacman -Sy --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
yes | pacman -Scc
@@ -134,7 +134,7 @@ jobs:
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
- name: Checkout
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: Upload Arch package
uses: https://gitea.com/actions/gitea-upload-artifact@v4

View File

@@ -8,7 +8,7 @@ on:
env:
# Common version, will be used for tagging the release
VERSION: 0.1.8
VERSION: 0.1.9
PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -64,7 +64,7 @@ jobs:
steps:
- name: Prepare container
run: |
pacman -Sy --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
yes | pacman -Scc
@@ -119,7 +119,7 @@ jobs:
- name: Install build dependencies
run: |
dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
python3-build pyproject-rpm-macros python3-setuptools \
python3-build pyproject-rpm-macros systemd-rpm-macros python3-setuptools \
redhat-rpm-config nodejs npm
- name: Setup rpmbuild environment

View File

@@ -16,10 +16,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: Set up Python
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: https://gitea.com/actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
with:
python-version-file: "pyproject.toml"

View File

@@ -18,7 +18,7 @@ jobs:
fedora: ${{ steps.check.outputs.fedora }}
arch: ${{ steps.check.outputs.arch }}
steps:
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
with:
fetch-depth: 0
@@ -63,7 +63,7 @@ jobs:
needs: changes
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
steps:
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: Install required dependencies
run: |
@@ -115,7 +115,7 @@ jobs:
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout repo
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: Copy fedora-git.spec
run: |
@@ -138,7 +138,7 @@ jobs:
needs: changes
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
image: archlinux:base-devel@sha256:943bdad9e9d0d23503f24797b44ce2cc1531bf101e18b3e7fb8c8776190dc45e
volumes:
- /usr:/usr-host
- /opt:/opt-host
@@ -178,7 +178,7 @@ jobs:
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
- name: Checkout
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: Upload Arch package
uses: https://gitea.com/actions/gitea-upload-artifact@v4

View File

@@ -20,10 +20,10 @@ jobs:
name: Check code
runs-on: ubuntu-latest
steps:
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: Set up Node.js
uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
uses: https://gitea.com/actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
with:
node-version: 20

View File

@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: Set up Python
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: https://gitea.com/actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
with:
python-version-file: "pyproject.toml"

View File

@@ -10,10 +10,10 @@ jobs:
runs-on: ubuntu-latest
container: ghcr.io/renovatebot/renovate:latest@sha256:17c8966ef38fc361e108a550ffe2dcedf73e846f9975a974aea3d48c66b107a6
steps:
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: Set up Node.js
uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
uses: https://gitea.com/actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
with:
node-version: 20

View File

@@ -3,6 +3,38 @@
Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [0.1.9] - 2025-12-08
### Added
- Добавлены основные и расширенные настройки для `.exe`-файлов
- Добавлена кнопка обновления сетки без необходимости перезапуска PortProtonQt (F5 на клавиатуре, GUIDE + Select на геймпаде)
- Добавлена эмуляция мыши по GUIDE (Xbox или PS) + Start для установки приложений или взаимодействия с инструментами Wine не адаптированные под геймпад (работает только если PortProtonQt вне фокуса)
- При сворачивании приложения в трей оно теперь корректно восстанавливается, вместо запуска нового экземпляра
- Добавлена поддержка SteamGridDB в качестве дополнительного источника обложек
- При добавлении карточки в избранное она автоматически становится первой без необходимости перезапуска
### Changed
- Изменено оформление виртуальной клавиатуры для лучшего соответствия общей теме
- Ускорено чтение конфигов за счёт уменьшения количества обращений к файловой системе.
- Из стандартной темы удалены неиспользуемые шрифты
- Улучшена совместимость с Qt 6.10
- Ускорен запуск программы
- В диалог редактирования ярылыка добавлен placeholder с уточнением того что в качевстве обложки можно использовать и ссылку, а не только файл
- Ссылку на обложку в диалоге редактирования ярлыка теперь можно указывать без протокола вроде http или https
### Fixed
- Добавлено больше проверок на None для избежания вылетов
- Улучшена работа с потоками для избежания вылетов
- Исправлен запуск PortProton из Flatpak: теперь используется `flatpak run`, а не `start.sh`
- Исправлено применение обложки по ссылке например со steamgriddb.com/
- Исправлено множественное открытие окон в X11
### Contributors
- @Vector_null
- @Dervart
---
## [0.1.8] - 2025-10-18
### Added

View File

@@ -6,11 +6,12 @@ script:
- uv pip install --no-cache-dir ../
- cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages
- cp -r share AppDir/usr
- cp -r lib AppDir/usr
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetwork*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*}
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*}
- shopt -s extglob
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6DBus*|libQt6Egl*|libQt6Gui*|libQt6Svg*|libQt6Wayland*|libQt6Widgets*|libQt6XcbQpa*|libicudata*|libicui18n*|libicuuc*)
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6DBus*|libQt6Egl*|libQt6Gui*|libQt6Network*|libQt6Svg*|libQt6Wayland*|libQt6Widgets*|libQt6XcbQpa*|libicudata*|libicui18n*|libicuuc*)
AppDir:
path: ./AppDir
after_bundle:
@@ -36,7 +37,7 @@ AppDir:
id: ru.linux_gaming.PortProtonQt
name: PortProtonQt
icon: ru.linux_gaming.PortProtonQt
version: 0.1.8
version: 0.1.9
exec: usr/bin/python3
exec_args: "-m portprotonqt.app $@"
apt:

View File

@@ -1,12 +1,12 @@
pkgname=portprotonqt
pkgver=0.1.8
pkgver=0.1.9
pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
arch=('any')
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
license=('GPL-3.0')
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'python-rapidfuzz' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
makedepends=('python-'{'build','installer','setuptools','wheel'})
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
sha256sums=('SKIP')
@@ -20,4 +20,5 @@ package() {
cd "$srcdir/PortProtonQt"
python -m installer --destdir="$pkgdir" dist/*.whl
cp -r build-aux/share "$pkgdir/usr/"
cp -r build-aux/lib "$pkgdir/usr/"
}

View File

@@ -6,7 +6,7 @@ arch=('any')
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
license=('GPL-3.0')
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'python-rapidfuzz' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
makedepends=('python-'{'build','installer','setuptools','wheel'})
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
sha256sums=('SKIP')
@@ -25,4 +25,5 @@ package() {
cd "$srcdir/PortProtonQt"
python -m installer --destdir="$pkgdir" dist/*.whl
cp -r build-aux/share "$pkgdir/usr/"
cp -r build-aux/lib "$pkgdir/usr/"
}

View File

@@ -22,6 +22,7 @@ BuildRequires: python3-build
BuildRequires: pyproject-rpm-macros
BuildRequires: python3dist(setuptools)
BuildRequires: git
BuildRequires: systemd-rpm-macros
%description
%{summary}
@@ -43,9 +44,10 @@ Requires: python3-tqdm
Requires: python3-vdf
Requires: python3-pefile
Requires: python3-pillow
Requires: python3-beautifulsoup4
Requires: python3-rapidfuzz
Requires: perl-Image-ExifTool
Requires: xdg-utils
Requires: python3-beautifulsoup4
Requires: cabextract
Requires: gzip
Requires: unzip
@@ -69,11 +71,13 @@ cd %{oname}
%pyproject_install
%pyproject_save_files %{pypi_name}
cp -r build-aux/share %{buildroot}/usr/
cp -r build-aux/lib %{buildroot}/usr/
%files -n python3-%{pypi_name}-git -f %{pyproject_files}
%{_bindir}/%{pypi_name}
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
%{_udevrulesdir}/60-portprotonqt.rules
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
%{bash_completions_dir}/portprotonqt

View File

@@ -1,5 +1,5 @@
%global pypi_name portprotonqt
%global pypi_version 0.1.8
%global pypi_version 0.1.9
%global oname PortProtonQt
%global _python_no_extras_requires 1
@@ -19,6 +19,7 @@ BuildRequires: python3-build
BuildRequires: pyproject-rpm-macros
BuildRequires: python3dist(setuptools)
BuildRequires: git
BuildRequires: systemd-rpm-macros
%description
%{summary}
@@ -40,9 +41,10 @@ Requires: python3-tqdm
Requires: python3-vdf
Requires: python3-pefile
Requires: python3-pillow
Requires: python3-beautifulsoup4
Requires: python3-rapidfuzz
Requires: perl-Image-ExifTool
Requires: xdg-utils
Requires: python3-beautifulsoup4
Requires: cabextract
Requires: gzip
Requires: unzip
@@ -68,11 +70,13 @@ cd %{oname}
%pyproject_install
%pyproject_save_files %{pypi_name}
cp -r build-aux/share %{buildroot}/usr/
cp -r build-aux/lib %{buildroot}/usr/
%files -n python3-%{pypi_name} -f %{pyproject_files}
%{_bindir}/%{pypi_name}
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
%{_udevrulesdir}/60-portprotonqt.rules
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
%{bash_completions_dir}/portprotonqt

View File

@@ -0,0 +1 @@
KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess"

View File

@@ -1373,7 +1373,7 @@
},
{
"normalized_name": "arena breakout infinite",
"status": "Broken"
"status": "Denied"
},
{
"normalized_name": "pixel gun 3d pc",
@@ -4316,7 +4316,7 @@
"status": "Broken"
},
{
"normalized_name": "solo leveling arise",
"normalized_name": "solo leveling arise overdrive",
"status": "Running"
},
{
@@ -4527,10 +4527,6 @@
"normalized_name": "project wraith",
"status": "Broken"
},
{
"normalized_name": "solo leveling arise",
"status": "Broken"
},
{
"normalized_name": "freedom wars",
"status": "Running"
@@ -4542,5 +4538,9 @@
{
"normalized_name": "no more room in hell 2",
"status": "Running"
},
{
"normalized_name": "call of duty black ops 7",
"status": "Denied"
}
]

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,4 +1,128 @@
[
{
"normalized_title": "metal gear solid v the phantom pain",
"slug": "metal-gear-solid-v-the-phantom-pain"
},
{
"normalized_title": "battlefield bad company 2",
"slug": "battlefield-bad-company-2"
},
{
"normalized_title": "call of duty black ops",
"slug": "call-of-duty-black-ops"
},
{
"normalized_title": "call of duty modern warfare 2 (2009)",
"slug": "call-of-duty-modern-warfare-2-2009"
},
{
"normalized_title": "call of duty black ops cold war",
"slug": "call-of-duty-black-ops-cold-war"
},
{
"normalized_title": "call of duty infinite warfare",
"slug": "call-of-duty-infinite-warfare"
},
{
"normalized_title": "lost planet 2",
"slug": "lost-planet-2"
},
{
"normalized_title": "lost planet extreme condition colonies",
"slug": "lost-planet-extreme-condition-colonies-edition"
},
{
"normalized_title": "starcraft",
"slug": "starcraft-remastered"
},
{
"normalized_title": "the entropy centre",
"slug": "the-entropy-centre"
},
{
"normalized_title": "metal gear solid v ground zeroes",
"slug": "metal-gear-solid-v-ground-zeroes"
},
{
"normalized_title": "escape from tarkov",
"slug": "escape-from-tarkov"
},
{
"normalized_title": "command & conquer generals",
"slug": "command-conquer-generals"
},
{
"normalized_title": "command & conquer generals zero hour",
"slug": "command-conquer-generals-zero-hour"
},
{
"normalized_title": "absolum",
"slug": "absolum"
},
{
"normalized_title": "tom clancy's splinter cell chaos theory",
"slug": "tom-clancys-splinter-cell-chaos-theory"
},
{
"normalized_title": "winter burrow",
"slug": "winter-burrow"
},
{
"normalized_title": "forager",
"slug": "forager"
},
{
"normalized_title": "wall world",
"slug": "wall-world"
},
{
"normalized_title": "grand theft auto iv the",
"slug": "grand-theft-auto-iv-the-complete-edition"
},
{
"normalized_title": "voidtrain",
"slug": "voidtrain"
},
{
"normalized_title": "jdm japanese drift master",
"slug": "jdm-japanese-drift-master"
},
{
"normalized_title": "lego harry potter collection",
"slug": "lego-harry-potter-collection"
},
{
"normalized_title": "life is strange season",
"slug": "life-is-strange-complete-season"
},
{
"normalized_title": "земский собор [демо]",
"slug": "zemskij-sobor-demo"
},
{
"normalized_title": "syberia",
"slug": "syberia-remastered"
},
{
"normalized_title": "europa universalis v",
"slug": "europa-universalis-v"
},
{
"normalized_title": "no i'm not a human",
"slug": "no-im-not-a-human"
},
{
"normalized_title": "dispatch digital deluxe",
"slug": "dispatch-digital-deluxe-edition"
},
{
"normalized_title": "cossacks 3 digital deluxe",
"slug": "cossacks-3-digital-deluxe"
},
{
"normalized_title": "battlefield 2",
"slug": "battlefield-2"
},
{
"normalized_title": "split/second",
"slug": "split-second"
@@ -11,10 +135,6 @@
"normalized_title": "foundation",
"slug": "foundation"
},
{
"normalized_title": "земский собор [демо]",
"slug": "zemskij-sobor-demo"
},
{
"normalized_title": "crusader kings 3",
"slug": "crusader-kings-3"
@@ -1411,10 +1531,6 @@
"normalized_title": "world of sea battle",
"slug": "world-of-sea-battle"
},
{
"normalized_title": "escape from tarkov",
"slug": "escape-from-tarkov"
},
{
"normalized_title": "bayonetta",
"slug": "bayonetta"
@@ -1539,10 +1655,6 @@
"normalized_title": "call of duty 2",
"slug": "call-of-duty-2"
},
{
"normalized_title": "call of duty infinite warfare",
"slug": "call-of-duty-infinite-warfare"
},
{
"normalized_title": "call of duty world at war",
"slug": "call-of-duty-world-at-war"
@@ -1735,10 +1847,6 @@
"normalized_title": "elden ring",
"slug": "elden-ring"
},
{
"normalized_title": "starcraft",
"slug": "starcraft-remastered"
},
{
"normalized_title": "cataclismo",
"slug": "cataclismo"

Binary file not shown.

View File

@@ -20,3 +20,33 @@ Stop Game
Fullscreen
Fulscreen
\t
Горячая
vkbasalt
dgVoodoo2
Zink
Vulkan
VKD3D
DirectX12
Prev Dir
Forced
GOverlay
Glide
all
futex
DLSS
fullscreen
ProtonGE
window
compositing
Zink
Use
bundled
dxvk
older games
versions
DLL Overrides
COMP
VKD3D
Select needed
CPUs
cores

View File

@@ -17,17 +17,31 @@ import json
class PySide6DependencyAnalyzer:
def __init__(self):
def __init__(self, project_root: Path = None):
# Системные библиотеки, которые нужно всегда оставлять
self.system_libs = {
'libQt6XcbQpa', 'libQt6Wayland', 'libQt6Egl',
'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus'
'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus',
'libQt6Svg'
}
self.critical_modules = {
'QtSvg',
}
self.real_dependencies = {}
self.used_modules_code = set()
self.used_modules_ldd = set()
self.all_required_modules = set()
# Определяем корень проекта
if project_root is None:
# Корень проекта - две директории выше от скрипта
self.project_root = Path(__file__).parent.parent
else:
self.project_root = project_root
self.venv_path = self.project_root / ".venv"
self.build_path = self.project_root / "build-aux"
def find_python_files(self, directory: Path) -> List[Path]:
"""Находит все Python файлы в директории"""
@@ -44,19 +58,56 @@ class PySide6DependencyAnalyzer:
"""Находит все PySide6 библиотеки (.so файлы)"""
libs = {}
# Поиск в единственной локации
search_path = Path("../.venv/lib/python3.10/site-packages/PySide6")
print(f"Поиск PySide6 библиотек в: {search_path}")
# Ищем venv в корне проекта
venv_candidates = [
self.venv_path, # .venv
self.project_root / "venv",
self.project_root / ".virtualenv",
]
pyside6_path = None
# Пробуем найти PySide6 в venv
for venv in venv_candidates:
if venv.exists():
# Ищем Python версию
lib_path = venv / "lib"
if lib_path.exists():
for python_dir in lib_path.iterdir():
if python_dir.name.startswith('python'):
candidate = python_dir / "site-packages" / "PySide6"
if candidate.exists():
pyside6_path = candidate
print(f"Найден PySide6 в: {candidate}")
break
if pyside6_path:
break
if not pyside6_path:
print(f"Предупреждение: PySide6 не найден в venv, проверяем AppDir...")
# Если не нашли в venv, пробуем в AppDir
if base_path:
appdir_candidate = base_path / "AppDir/usr/local/lib"
if appdir_candidate.exists():
for python_dir in appdir_candidate.iterdir():
if python_dir.name.startswith('python'):
candidate = python_dir / "dist-packages" / "PySide6"
if candidate.exists():
pyside6_path = candidate
print(f"Найден PySide6 в AppDir: {candidate}")
break
if not pyside6_path:
return libs
if search_path.exists():
# Ищем .so файлы модулей
for so_file in search_path.glob("Qt*.*.so"):
for so_file in pyside6_path.glob("Qt*.*.so"):
module_name = so_file.stem.split('.')[0] # QtCore.abi3.so -> QtCore
if module_name.startswith('Qt'):
libs[module_name] = so_file
# Также ищем в подпапках
for subdir in search_path.iterdir():
for subdir in pyside6_path.iterdir():
if subdir.is_dir() and subdir.name.startswith('Qt'):
for so_file in subdir.glob("*.so*"):
if 'Qt' in so_file.name:
@@ -257,7 +308,9 @@ class PySide6DependencyAnalyzer:
# Модули для удаления
if removable_modules:
modules_list = ','.join([f"{mod}*" for mod in sorted(removable_modules)])
removable_filtered = [m for m in removable_modules if m not in self.critical_modules]
if removable_filtered:
modules_list = ','.join([f"{mod}*" for mod in sorted(removable_filtered)])
cleanup_lines.append(f" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{{{modules_list}}}")
# Генерируем команду для удаления нативных библиотек с сохранением нужных
@@ -276,39 +329,82 @@ class PySide6DependencyAnalyzer:
f" - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!({keep_pattern})"
])
# Заменяем блок очистки в рецепте
import re
# Ищем блок "# 5) чистим от ненужных модулей и бинарников" до следующего комментария или до AppDir:
pattern = r'( # 5\) чистим от ненужных модулей и бинарников\n).*?(?=\nAppDir:|\n # [0-9]+\)|$)'
# Ищем весь блок команд очистки PySide6 (от первой rm до AppDir:)
# Паттерн: после " - cp -r lib AppDir/usr\n" идут команды rm, а затем "AppDir:"
pattern = r'( - cp -r lib AppDir/usr\n)((?: - (?:rm|shopt).*\n)*?)(?=AppDir:)'
new_cleanup_block = " # 5) чистим от ненужных модулей и бинарников\n" + '\n'.join(cleanup_lines)
match = re.search(pattern, recipe_content)
updated_recipe = re.sub(pattern, new_cleanup_block, recipe_content, flags=re.DOTALL)
if not match:
print("ПРЕДУПРЕЖДЕНИЕ: Не удалось найти блок очистки в рецепте")
print("Добавляем команды очистки перед блоком AppDir:")
# Просто вставим команды перед AppDir:
appdir_pos = recipe_content.find('AppDir:')
if appdir_pos != -1:
new_content = (
recipe_content[:appdir_pos] +
'\n'.join(cleanup_lines) + '\n' +
recipe_content[appdir_pos:]
)
return new_content
else:
print("ОШИБКА: Не найден блок AppDir: в рецепте")
return ""
# Создаем замену - группа 1 (cp -r lib) + новые команды очистки
replacement = r'\1' + '\n'.join(cleanup_lines) + '\n'
updated_recipe = re.sub(pattern, replacement, recipe_content, count=1)
return updated_recipe
def main():
parser = argparse.ArgumentParser(description='Анализ зависимостей PySide6 модулей с использованием ldd')
parser.add_argument('project_path', help='Путь к проекту для анализа')
parser.add_argument('project_path', nargs='?', default='.',
help='Путь к проекту для анализа (по умолчанию: текущая директория)')
parser.add_argument('--appdir', help='Путь к AppDir для поиска PySide6 библиотек')
parser.add_argument('--output', '-o', help='Путь для сохранения результатов (JSON)')
parser.add_argument('--verbose', '-v', action='store_true', help='Подробный вывод')
parser.add_argument('--venv', help='Путь к виртуальному окружению (по умолчанию: .venv в корне проекта)')
args = parser.parse_args()
project_path = Path(args.project_path)
project_path = Path(args.project_path).resolve()
if not project_path.exists():
print(f"Ошибка: путь {project_path} не существует")
sys.exit(1)
appdir_path = Path(args.appdir) if args.appdir else None
appdir_path = Path(args.appdir).resolve() if args.appdir else None
if appdir_path and not appdir_path.exists():
print(f"Предупреждение: AppDir путь {appdir_path} не существует")
appdir_path = None
analyzer = PySide6DependencyAnalyzer()
# Определяем корень проекта
# Если запущен из подпапки проекта, ищем корень
project_root = project_path
if (project_path / ".git").exists() or (project_path / "pyproject.toml").exists():
project_root = project_path
else:
# Пытаемся найти корень проекта
current = project_path
while current != current.parent:
if (current / ".git").exists() or (current / "pyproject.toml").exists():
project_root = current
break
current = current.parent
print(f"Корень проекта: {project_root}")
analyzer = PySide6DependencyAnalyzer(project_root=project_root)
# Если указан custom venv путь
if args.venv:
analyzer.venv_path = Path(args.venv).resolve()
print(f"Использую указанный venv: {analyzer.venv_path}")
results = analyzer.analyze_project(project_path, appdir_path)
# Сохраняем в анализатор для генерации команд
@@ -347,13 +443,13 @@ def main():
print(f"\nПотенциальная экономия места: {results['analysis_summary']['space_saving_potential']}")
if args.verbose and results['real_dependencies']:
Devlin(f"\nРеальные зависимости (ldd):")
print(f"\nРеальные зависимости (ldd):")
for module, deps in results['real_dependencies'].items():
if deps:
print(f" {module}{', '.join(deps)}")
# Обновляем AppImage рецепт
recipe_path = Path("../build-aux/AppImageBuilder.yml")
recipe_path = analyzer.build_path / "AppImageBuilder.yml"
if recipe_path.exists():
updated_recipe = analyzer.generate_appimage_recipe(results['removable'], recipe_path)
if updated_recipe:

View File

@@ -21,9 +21,9 @@ Current translation status:
| Locale | Progress | Translated |
| :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 249 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 249 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 of 249 |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 341 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 341 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 341 of 341 |
---

View File

@@ -21,9 +21,9 @@
| Локаль | Прогресс | Переведено |
| :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 249 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 249 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 из 249 |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 341 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 341 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 341 из 341 |
---

View File

@@ -1,3 +1,4 @@
from typing import Any, cast
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstractAnimation, QParallelAnimationGroup, QRect, Qt, QPoint
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect
@@ -236,14 +237,31 @@ class DetailPageAnimations:
self.main_window = main_window
self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.animations = main_window._animations if hasattr(main_window, '_animations') else {}
# Ensure the main window has an animations dict
if not hasattr(main_window, '_animations'):
main_window._animations = {}
self.animations = main_window._animations
def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):
"""Animate the detail page based on theme settings."""
# Check if the detail page is still valid before proceeding
if not detail_page or detail_page.isHidden() or detail_page.parent() is None:
logger.warning("Detail page is not valid, skipping enter animation")
load_image_and_restore_effect()
cleanup_animation()
return
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
if animation_type == "fade":
# Check again if page is still valid before starting animation
if not detail_page or detail_page.isHidden():
logger.warning("Detail page became invalid during fade setup, skipping animation")
load_image_and_restore_effect()
cleanup_animation()
return
original_effect = detail_page.graphicsEffect()
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=True)
opacity_effect.setOpacity(0.0)
@@ -252,17 +270,36 @@ class DetailPageAnimations:
animation.setDuration(duration)
animation.setStartValue(0.0)
animation.setEndValue(0.999)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation
def restore_effect():
try:
detail_page.setGraphicsEffect(original_effect) # type: ignore
# Check if page is still valid before restoring effect
if detail_page and not detail_page.isHidden():
detail_page.setGraphicsEffect(cast(Any, original_effect))
except RuntimeError:
logger.warning("Original effect already deleted")
# Only start animation if page is still valid
if detail_page and not detail_page.isHidden():
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation
animation.finished.connect(restore_effect)
animation.finished.connect(load_image_and_restore_effect)
animation.finished.connect(opacity_effect.deleteLater)
else:
logger.warning("Detail page invalid when starting fade, cleaning up")
restore_effect()
load_image_and_restore_effect()
opacity_effect.deleteLater()
cleanup_animation()
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
# Check again if page is still valid before starting animation
if not detail_page or detail_page.isHidden():
logger.warning("Detail page became invalid during slide setup, skipping animation")
load_image_and_restore_effect()
cleanup_animation()
return
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500)
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
start_pos = {
@@ -277,11 +314,25 @@ class DetailPageAnimations:
animation.setStartValue(start_pos)
animation.setEndValue(self.main_window.stackedWidget.rect().topLeft())
animation.setEasingCurve(easing_curve)
# Only start animation if page is still valid
if detail_page and not detail_page.isHidden():
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation
animation.finished.connect(cleanup_animation)
animation.finished.connect(load_image_and_restore_effect)
else:
logger.warning("Detail page invalid when starting slide, cleaning up")
load_image_and_restore_effect()
cleanup_animation()
elif animation_type == "bounce":
# Check again if page is still valid before starting animation
if not detail_page or detail_page.isHidden():
logger.warning("Detail page became invalid during bounce setup, skipping animation")
load_image_and_restore_effect()
cleanup_animation()
return
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration", 400)
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
detail_page.setWindowOpacity(0.0)
@@ -300,14 +351,27 @@ class DetailPageAnimations:
group_anim = QParallelAnimationGroup()
group_anim.addAnimation(opacity_anim)
group_anim.addAnimation(geometry_anim)
group_anim.finished.connect(load_image_and_restore_effect)
group_anim.finished.connect(cleanup_animation)
# Only start animation if page is still valid
if detail_page and not detail_page.isHidden():
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = group_anim
group_anim.finished.connect(load_image_and_restore_effect)
group_anim.finished.connect(cleanup_animation)
else:
logger.warning("Detail page invalid when starting bounce, cleaning up")
load_image_and_restore_effect()
cleanup_animation()
def animate_detail_page_exit(self, detail_page: QWidget, cleanup_callback: Callable):
"""Animate the detail page exit based on theme settings."""
try:
# Check if the detail page is still valid before proceeding
if not detail_page or detail_page.isHidden() or detail_page.parent() is None:
logger.warning("Detail page is not valid, skipping exit animation")
cleanup_callback()
return
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
# Safely stop and remove any existing animation
@@ -326,6 +390,13 @@ class DetailPageAnimations:
# Define animation based on type
if animation_type == "fade":
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration_exit", 350)
# Check if page is still valid before accessing properties
if not detail_page or detail_page.isHidden():
logger.warning("Detail page became invalid during fade exit setup, skipping animation")
cleanup_callback()
return
original_effect = detail_page.graphicsEffect()
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=False)
opacity_effect.setOpacity(0.999)
@@ -334,18 +405,36 @@ class DetailPageAnimations:
animation.setDuration(duration)
animation.setStartValue(0.999)
animation.setEndValue(0.0)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation
def restore_and_cleanup():
try:
detail_page.setGraphicsEffect(original_effect) # type: ignore
# Check if page is still valid before restoring effect
if detail_page and not detail_page.isHidden():
detail_page.setGraphicsEffect(cast(Any, original_effect))
except RuntimeError:
logger.debug("Original effect already deleted")
cleanup_callback()
# Check if animation is still valid before starting
if animation and not detail_page.isHidden():
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation
animation.finished.connect(restore_and_cleanup)
animation.finished.connect(opacity_effect.deleteLater)
else:
logger.warning("Animation or detail page invalid when starting fade exit, cleaning up")
restore_and_cleanup()
opacity_effect.deleteLater()
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500)
# Check if page is still valid before accessing properties
if not detail_page or detail_page.isHidden():
logger.warning("Detail page became invalid during slide exit setup, skipping animation")
cleanup_callback()
return
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
end_pos = {
"slide_left": QPoint(-self.main_window.width(), 0),
@@ -353,16 +442,37 @@ class DetailPageAnimations:
"slide_up": QPoint(0, self.main_window.height()),
"slide_down": QPoint(0, -self.main_window.height())
}[animation_type]
animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
animation.setDuration(duration)
animation.setStartValue(detail_page.pos())
animation.setEndValue(end_pos)
animation.setEasingCurve(easing_curve)
def slide_cleanup():
# Check if page is still valid before cleanup
if not detail_page or detail_page.isHidden():
logger.debug("Detail page already cleaned up")
cleanup_callback()
# Check if animation is still valid before starting
if animation and not detail_page.isHidden():
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation
animation.finished.connect(cleanup_callback)
animation.finished.connect(slide_cleanup)
else:
logger.warning("Animation or detail page invalid when starting slide exit, cleaning up")
slide_cleanup()
elif animation_type == "bounce":
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration_exit", 400)
# Check if page is still valid before accessing properties
if not detail_page or detail_page.isHidden():
logger.warning("Detail page became invalid during bounce exit setup, skipping animation")
cleanup_callback()
return
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
opacity_anim = QPropertyAnimation(detail_page, QByteArray(b"windowOpacity"))
opacity_anim.setDuration(duration)
@@ -375,13 +485,38 @@ class DetailPageAnimations:
geometry_anim.setStartValue(detail_page.geometry())
geometry_anim.setEndValue(final_rect)
geometry_anim.setEasingCurve(easing_curve)
# Check if animations are still valid before creating group
if not detail_page or detail_page.isHidden():
logger.warning("Detail page became invalid during bounce exit setup, cleaning up")
cleanup_callback()
return
group_anim = QParallelAnimationGroup()
group_anim.addAnimation(opacity_anim)
group_anim.addAnimation(geometry_anim)
group_anim.finished.connect(cleanup_callback)
# Check if group animation is still valid before connecting
if not detail_page or detail_page.isHidden():
logger.warning("Detail page became invalid during group animation setup, cleaning up")
cleanup_callback()
return
def bounce_cleanup():
# Check if page is still valid before cleanup
if not detail_page or detail_page.isHidden():
logger.debug("Detail page already cleaned up")
cleanup_callback()
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = group_anim
group_anim.finished.connect(bounce_cleanup)
except RuntimeError:
# Widget was already deleted, which is expected after deleteLater()
logger.debug("Detail page already deleted during animation setup")
cleanup_callback()
except Exception as e:
logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True)
if detail_page in self.animations:
self.animations.pop(detail_page, None)
cleanup_callback()

View File

@@ -17,7 +17,7 @@ from portprotonqt.cli import parse_args
__app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt"
__app_version__ = "0.1.8"
__app_version__ = "0.1.9"
def get_version():
try:
@@ -34,13 +34,12 @@ def main():
os.environ["PROCESS_LOG"] = "1"
os.environ["START_FROM_STEAM"] = "1"
# Get the PortProton start command
start_sh = get_portproton_start_command()
if start_sh is None:
return
subprocess.run(start_sh + ["cli", "--initial"])
app = QApplication(sys.argv)
app.setWindowIcon(QIcon.fromTheme(__app_id__))
app.setDesktopFileName(__app_id__)
@@ -104,15 +103,14 @@ def main():
def restore_window():
try:
if msg.startswith("show"):
if hasattr(window, "restore_from_tray"):
window.restore_from_tray() # type: ignore[attr-defined]
else:
window.showNormal()
# Ensure the window is visible and not minimized
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
window.show()
window.raise_()
window.activateWindow()
window.setWindowState(
window.windowState() & ~Qt.WindowState.WindowMinimized | Qt.WindowState.WindowActive
)
# Ensure window is in active state for systems with strict focus policies
window.setWindowState(window.windowState() | Qt.WindowState.WindowActive)
if ":fullscreen" in msg:
logger.info("Switching to fullscreen via IPC")
@@ -145,6 +143,22 @@ def main():
save_fullscreen_config(False)
window.showNormal()
# Execute the initial PortProton command after the UI is set up
def run_initial_command():
nonlocal start_sh
if start_sh:
try:
subprocess.run(start_sh + ["cli", "--initial"], timeout=10)
except subprocess.TimeoutExpired:
logger.warning("Initial PortProton command timed out")
except Exception as e:
logger.error(f"Error running initial PortProton command: {e}")
else:
logger.warning("PortProton start command not available, skipping initial command")
# Run the initial command after the UI is displayed
QTimer.singleShot(100, run_initial_command)
# --- Cleanup ---
def cleanup_on_exit():
try:

View File

@@ -9,6 +9,10 @@ logger = get_logger(__name__)
_portproton_location = None
_portproton_start_sh = None
# Configuration cache for performance optimization
_config_cache = {}
_config_last_modified = {}
# Paths to configuration files
CONFIG_FILE = os.path.join(
os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
@@ -28,13 +32,35 @@ THEMES_DIRS = [
]
def read_config_safely(config_file: str) -> configparser.ConfigParser | None:
"""Safely reads a configuration file and returns a ConfigParser object or None if reading fails."""
cp = configparser.ConfigParser()
"""Safely reads a configuration file and returns a ConfigParser object or None if reading fails.
Uses caching to avoid repeated file reads for better performance.
"""
# Check if file exists
if not os.path.exists(config_file):
logger.debug(f"Configuration file {config_file} not found")
return None
# Get file modification time
try:
current_mtime = os.path.getmtime(config_file)
except OSError:
logger.warning(f"Failed to get modification time for {config_file}")
return None
# Check if we have a cached version that's still valid
if config_file in _config_cache and config_file in _config_last_modified:
if _config_last_modified[config_file] == current_mtime:
logger.debug(f"Using cached config for {config_file}")
return _config_cache[config_file]
# Read and parse the config file
cp = configparser.ConfigParser()
try:
cp.read(config_file, encoding="utf-8")
# Update cache
_config_cache[config_file] = cp
_config_last_modified[config_file] = current_mtime
logger.debug(f"Config file {config_file} loaded and cached")
return cp
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.warning(f"Invalid configuration file format: {e}")
@@ -43,6 +69,14 @@ def read_config_safely(config_file: str) -> configparser.ConfigParser | None:
logger.warning(f"Failed to read configuration file: {e}")
return None
def invalidate_config_cache(config_file: str = CONFIG_FILE):
"""Invalidates the cached configuration for the specified file."""
if config_file in _config_cache:
del _config_cache[config_file]
if config_file in _config_last_modified:
del _config_last_modified[config_file]
logger.debug(f"Config cache invalidated for {config_file}")
def read_config():
"""Reads the configuration file and returns a dictionary of parameters.
Example line in config (no sections):
@@ -77,6 +111,8 @@ def save_theme_to_config(theme_name):
cp["Appearance"]["theme"] = theme_name
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_time_config():
"""Reads time settings from the [Time] section of the configuration file.
@@ -96,11 +132,19 @@ def save_time_config(detail_level):
cp["Time"]["detail_level"] = detail_level
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_file_content(file_path):
"""Reads the content of a file and returns it as a string."""
try:
# Add timeout protection for file operations using a simple approach
with open(file_path, encoding="utf-8") as f:
return f.read().strip()
content = f.read().strip()
return content
except Exception as e:
logger.warning(f"Error reading file {file_path}: {e}")
raise # Re-raise the exception to be handled by the caller
def get_portproton_location():
"""Возвращает путь к PortProton каталогу (строку) или None."""
@@ -121,6 +165,8 @@ def get_portproton_location():
logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults")
except (OSError, PermissionError) as e:
logger.warning(f"Failed to read PortProton configuration file: {e}")
except Exception as e:
logger.warning(f"Unexpected error reading PortProton configuration file: {e}")
default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
if os.path.isdir(default_flatpak_dir):
@@ -137,17 +183,38 @@ def get_portproton_start_command():
if not portproton_path:
return None
# Check if flatpak command exists before trying to run it
try:
subprocess.run(
["flatpak", "--version"],
capture_output=True,
text=True,
check=False,
timeout=5
)
flatpak_available = True
except FileNotFoundError:
flatpak_available = False
except Exception:
flatpak_available = False
if flatpak_available:
try:
result = subprocess.run(
["flatpak", "list"],
capture_output=True,
text=True,
check=False
check=False,
timeout=10
)
if "ru.linux_gaming.PortProton" in result.stdout:
logger.info("Detected Flatpak installation")
return ["flatpak", "run", "ru.linux_gaming.PortProton"]
except Exception:
except subprocess.TimeoutExpired:
logger.warning("Flatpak list command timed out")
return None
except Exception as e:
logger.warning(f"Error checking flatpak list: {e}")
pass
start_sh_path = os.path.join(portproton_path, "data", "scripts", "start.sh")
@@ -205,6 +272,8 @@ def save_card_size(card_width):
cp["Cards"]["card_width"] = str(card_width)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_auto_card_size():
"""Reads the card size (width) for Auto Install from the [Cards] section.
@@ -224,6 +293,8 @@ def save_auto_card_size(card_width):
cp["Cards"]["auto_card_width"] = str(card_width)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_sort_method():
@@ -244,6 +315,8 @@ def save_sort_method(sort_method):
cp["Games"]["sort_method"] = sort_method
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_display_filter():
"""Reads the display_filter parameter from the [Games] section.
@@ -263,6 +336,8 @@ def save_display_filter(filter_value):
cp["Games"]["display_filter"] = filter_value
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_favorites():
"""Reads the list of favorite games from the [Favorites] section.
@@ -288,6 +363,8 @@ def save_favorites(favorites):
cp["Favorites"]["games"] = f'"{fav_str}"'
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_rumble_config():
"""Reads the gamepad rumble setting from the [Gamepad] section.
@@ -307,6 +384,8 @@ def save_rumble_config(rumble_enabled):
cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_gamepad_type():
"""Reads the gamepad type from the [Gamepad] section.
@@ -326,6 +405,8 @@ def save_gamepad_type(gpad_type):
cp["Gamepad"]["type"] = gpad_type
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def ensure_default_proxy_config():
"""Ensures the [Proxy] section exists in the configuration file.
@@ -370,6 +451,8 @@ def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
cp["Proxy"]["proxy_password"] = proxy_password
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_fullscreen_config():
"""Reads the fullscreen mode setting from the [Display] section.
@@ -389,6 +472,8 @@ def save_fullscreen_config(fullscreen):
cp["Display"]["fullscreen"] = str(fullscreen)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_window_geometry() -> tuple[int, int]:
"""Reads the window width and height from the [MainWindow] section.
@@ -410,6 +495,8 @@ def save_window_geometry(width: int, height: int):
cp["MainWindow"]["height"] = str(height)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def reset_config():
"""Resets the configuration file by deleting it.
@@ -419,6 +506,8 @@ def reset_config():
try:
os.remove(CONFIG_FILE)
logger.info("Configuration file %s deleted", CONFIG_FILE)
# Invalidate cache after deletion
invalidate_config_cache()
except Exception as e:
logger.warning(f"Failed to delete configuration file: {e}")
@@ -433,6 +522,9 @@ def clear_cache():
except Exception as e:
logger.warning(f"Failed to delete cache: {e}")
# Also clear our internal config cache
invalidate_config_cache()
def read_auto_fullscreen_gamepad():
"""Reads the auto-fullscreen setting for gamepad from the [Display] section.
Returns False if the parameter is missing.
@@ -451,6 +543,8 @@ def save_auto_fullscreen_gamepad(auto_fullscreen):
cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_favorite_folders():
"""Reads the list of favorite folders from the [FavoritesFolders] section.
@@ -476,6 +570,8 @@ def save_favorite_folders(folders):
cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_minimize_to_tray():
"""Reads the minimize-to-tray setting from the [Display] section.
@@ -495,3 +591,5 @@ def save_minimize_to_tray(minimize_to_tray):
cp["Display"]["minimize_to_tray"] = str(minimize_to_tray)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()

View File

@@ -1035,7 +1035,15 @@ Icon={icon_path}
)
return
if os.path.isfile(new_cover_path):
# Check if new_cover_path is a URL by checking for common image extensions
image_extensions = ('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp')
has_image_extension = any(new_cover_path.lower().endswith(ext) for ext in image_extensions)
# Consider it a URL if it has image extension and is not a local file
is_url = has_image_extension and not os.path.isfile(new_cover_path)
# Use the downloaded file path if we have a URL and the file was downloaded, otherwise use the local file
if os.path.isfile(new_cover_path) or (is_url and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path)):
exe_name = os.path.splitext(os.path.basename(new_exe_path))[0]
xdg_data_home = os.getenv(
"XDG_DATA_HOME",
@@ -1043,16 +1051,25 @@ Icon={icon_path}
)
custom_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", exe_name)
os.makedirs(custom_folder, exist_ok=True)
ext = os.path.splitext(new_cover_path)[1].lower()
# Use the actual cover file path (either from URL download or local file)
cover_to_copy = dialog.last_cover_path if is_url and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path) else new_cover_path
ext = os.path.splitext(cover_to_copy)[1].lower()
if ext in [".png", ".jpg", ".jpeg", ".bmp"]:
try:
shutil.copyfile(new_cover_path, os.path.join(custom_folder, f"cover{ext}"))
shutil.copyfile(cover_to_copy, os.path.join(custom_folder, f"cover{ext}"))
except OSError as e:
self.signals.show_warning_dialog.emit(
_("Error"),
_("Failed to copy cover image: {error}").format(error=str(e))
)
return
else:
self.signals.show_warning_dialog.emit(
_("Error"),
_("Unsupported image format: {extension}").format(extension=ext)
)
return
def add_to_steam(self, game_name, exec_line, cover_path):
"""

View File

@@ -5,12 +5,12 @@ from typing import cast, TYPE_CHECKING
from PySide6.QtGui import QPixmap, QIcon, QTextCursor, QColor
from PySide6.QtWidgets import (
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller,
QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit, QAbstractItemView, QStackedWidget, QComboBox
QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit, QAbstractItemView, QStackedWidget, QComboBox, QLineEdit
)
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot, QProcess, QProcessEnvironment
from icoextract import IconExtractor, IconExtractorError
from PIL import Image
from portprotonqt.config_utils import get_portproton_location, read_favorite_folders, read_theme_from_config
from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command, read_favorite_folders, read_theme_from_config
from portprotonqt.localization import _
from portprotonqt.logger import get_logger
from portprotonqt.theme_manager import ThemeManager
@@ -18,6 +18,7 @@ from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.downloader import Downloader
from portprotonqt.virtual_keyboard import VirtualKeyboard
from portprotonqt.preloader import Preloader
from portprotonqt.settings_manager import get_toggle_settings, get_advanced_settings, ADVANCED_SETTING_KEYS
import psutil
if TYPE_CHECKING:
@@ -124,6 +125,15 @@ def create_dialog_hints_widget(theme, main_window, input_manager, context='defau
("prev_tab", _("Prev Tab")), # LB / L1
("next_tab", _("Next Tab")), # RB / R1
]
elif context == 'settings':
dialog_actions = [
("confirm", _("Toggle")), # A / Cross
("add_game", _("Save")), # X / Triangle
("prev_dir", _("Search")), # Y / Square
("back", _("Cancel")), # B / Circle
("prev_tab", _("Prev Tab")), # LB / L1
("next_tab", _("Next Tab")), # RB / R1
]
hints_labels = [] # Store for updates (returned for class storage)
@@ -896,6 +906,7 @@ class AddGameDialog(QDialog):
self.coverEdit = CustomLineEdit(self, theme=self.theme)
self.coverEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
self.coverEdit.setPlaceholderText(_("Enter local path or URL for cover image"))
if cover_path:
self.coverEdit.setText(cover_path)
@@ -939,7 +950,12 @@ class AddGameDialog(QDialog):
# Подключение сигналов
self.select_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
self.coverEdit.textChanged.connect(self.updatePreview)
# Set up a timer for debounced cover preview updates
self.cover_preview_timer = QTimer(self)
self.cover_preview_timer.setSingleShot(True)
self.cover_preview_timer.timeout.connect(self.updatePreview)
self.coverEdit.textChanged.connect(self.onCoverTextChanged)
self.exeEdit.textChanged.connect(self.updatePreview)
# Установка одинаковой ширины для кнопок и полей ввода
@@ -1073,6 +1089,11 @@ class AddGameDialog(QDialog):
def handleDownloadedCover(self, file_path):
"""Handle the downloaded cover image and update the preview."""
# Check if the dialog or widget has been destroyed before updating
if not hasattr(self, 'coverPreview') or self.coverPreview is None:
return
try:
if file_path and os.path.isfile(file_path):
self.last_cover_path = file_path
pixmap = QPixmap(file_path)
@@ -1083,23 +1104,36 @@ class AddGameDialog(QDialog):
else:
self.coverPreview.setText(_("Failed to download cover"))
logger.warning(f"Failed to download cover to {file_path}")
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
def onCoverTextChanged(self):
"""Handle cover text changes with debounce."""
# Restart the timer to delay the preview update
self.cover_preview_timer.start(500) # 500ms delay
def updatePreview(self):
"""Update the cover preview image."""
cover_path = self.coverEdit.text().strip()
exe_path = self.exeEdit.text().strip()
# Check if cover_path is a URL
url_pattern = r'^https?://[^\s/$.?#].[^\s]*$'
if re.match(url_pattern, cover_path):
# Check if cover_path is a URL by checking for common image extensions
image_extensions = ('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp')
has_image_extension = any(cover_path.lower().endswith(ext) for ext in image_extensions)
# Consider it a URL if it has image extension and is not a local file
if has_image_extension and not os.path.isfile(cover_path):
# Create a temporary file for the downloaded image
fd, local_path = tempfile.mkstemp(suffix=".png")
os.close(fd)
os.unlink(local_path)
# Start asynchronous download
# Add protocol if not present
download_url = cover_path if cover_path.startswith(('http://', 'https://')) else f'https://{cover_path}'
self.downloader.download_async(
url=cover_path,
url=download_url,
local_path=local_path,
timeout=10,
callback=self.handleDownloadedCover
@@ -1325,7 +1359,9 @@ class WinetricksDialog(QDialog):
# DLLs tab
self.dll_table = QTableWidget()
self.dll_table.setAlternatingRowColors(True)
self.dll_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# self.dll_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.dll_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.dll_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.dll_table.setColumnCount(3)
@@ -1356,7 +1392,9 @@ class WinetricksDialog(QDialog):
# Fonts tab
self.fonts_table = QTableWidget()
self.fonts_table.setAlternatingRowColors(True)
self.fonts_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# self.fonts_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.fonts_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.fonts_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.fonts_table.setColumnCount(3)
@@ -1387,7 +1425,9 @@ class WinetricksDialog(QDialog):
# Settings tab
self.settings_table = QTableWidget()
self.settings_table.setAlternatingRowColors(True)
self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.settings_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.settings_table.setColumnCount(3)
@@ -1673,6 +1713,7 @@ class WinetricksDialog(QDialog):
if self.input_manager:
self.input_manager.disable_winetricks_mode()
super().reject()
class ExeSettingsDialog(QDialog):
def __init__(self, parent=None, theme=None, exe_path=None):
super().__init__(parent)
@@ -1684,8 +1725,20 @@ class ExeSettingsDialog(QDialog):
if self.portproton_path is None:
logger.error("PortProton location not found")
return
base_path = os.path.join(self.portproton_path, "data")
self.start_sh = [os.path.join(base_path, "scripts", "start.sh")]
self.start_sh = get_portproton_start_command()
if self.start_sh is None:
logger.error("PortProton start command not found")
return
self.dist_options = []
self.prefix_options = []
if self.portproton_path:
dist_dir = os.path.join(self.portproton_path, "data", 'dist')
if os.path.exists(dist_dir):
self.dist_options = [f for f in os.listdir(dist_dir) if os.path.isdir(os.path.join(dist_dir, f))]
prefixes_dir = os.path.join(self.portproton_path, 'prefixes')
if os.path.exists(prefixes_dir):
self.prefix_options = [f for f in os.listdir(prefixes_dir) if os.path.isdir(os.path.join(prefixes_dir, f))]
self.current_settings = {}
self.value_widgets = {}
@@ -1699,14 +1752,17 @@ class ExeSettingsDialog(QDialog):
self.locale_options = []
self.logical_core_options = []
self.amd_vulkan_drivers = []
self.branch_name = _("Unknown")
self.setWindowTitle(_("Exe Settings"))
self.setModal(True)
self.resize(900, 600)
self.resize(1100, 720)
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
# Set focus policy to handle focus changes properly
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# Load toggle settings from config module
self.toggle_settings = get_toggle_settings()
self.init_toggle_settings()
self.setup_ui()
# Find input_manager and main_window
@@ -1721,68 +1777,59 @@ class ExeSettingsDialog(QDialog):
self.current_theme_name = read_theme_from_config()
# Enable settings dialog-specific mode
if self.input_manager:
self.input_manager.enable_settings_mode(self)
# Create hints widget using common function
self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='settings')
self.main_layout.addWidget(self.hints_widget)
# Connect signals
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)
)
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
# Initialize virtual keyboard
self.init_virtual_keyboard()
# Load current settings (includes list-db)
self.load_current_settings()
def _get_process_args(self, subcommand_args):
"""Get the full arguments for QProcess.start, handling flatpak separator."""
if self.start_sh[0] == "flatpak":
return self.start_sh[1:] + ["--"] + subcommand_args
"""Get the full arguments for QProcess.start, handling flatpak format."""
if self.start_sh and self.start_sh[0] == "flatpak":
return self.start_sh + subcommand_args
else:
return self.start_sh + subcommand_args
def init_toggle_settings(self):
"""Initialize predefined toggle settings with descriptions."""
self.toggle_settings = {
'PW_MANGOHUD': _("Using FPS and system load monitoring (Turns on and off by the key combination - right Shift + F12)"),
'PW_MANGOHUD_USER_CONF': _("Forced use of MANGOHUD system settings (GOverlay, etc.)"),
'PW_VKBASALT': _("Enable vkBasalt by default to improve graphics in games running on Vulkan. (The HOME hotkey disables vkbasalt)"),
'PW_VKBASALT_USER_CONF': _("Forced use of VKBASALT system settings (GOverlay, etc.)"),
'PW_DGVOODOO2': _("Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, DirectDraw 1-7, Direct3D 2-9) on all 3D API."),
'PW_GAMESCOPE': _("Super + F : Toggle fullscreen\nSuper + N : Toggle nearest neighbour filtering\nSuper + U : Toggle FSR upscaling\nSuper + Y : Toggle NIS upscaling\nSuper + I : Increase FSR sharpness by 1\nSuper + O : Decrease FSR sharpness by 1\nSuper + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\nSuper + G : Toggle keyboard grab\nSuper + C : Update clipboard"),
'PW_USE_ESYNC': _("Enable in-process synchronization primitives based on eventfd."),
'PW_USE_FSYNC': _("Enable futex-based in-process synchronization primitives."),
'PW_USE_NTSYNC': _("Enable in-process synchronization via the Linux ntsync driver."),
'PW_USE_RAY_TRACING': _("Enable vkd3d support - Ray Tracing"),
'PW_USE_NVAPI_AND_DLSS': _("Enable DLSS on supported NVIDIA graphics cards"),
'PW_USE_OPTISCALER': _("Enable OptiScaler (replacement upscaler / frame generator)"),
'PW_USE_LS_FRAME_GEN': _("Enable Lossless Scaling frame generation (experimental)"),
'PW_WINE_FULLSCREEN_FSR': _("FSR upscaling in fullscreen with ProtonGE below native resolution"),
'PW_HIDE_NVIDIA_GPU': _("Disguise all NVIDIA GPU features"),
'PW_VIRTUAL_DESKTOP': _("Run the application in WINE virtual desktop"),
'PW_USE_TERMINAL': _("Run the application in a terminal"),
'PW_GUI_DISABLED_CS': _("Disable startup mode and WINE version selector window"),
'PW_USE_GAMEMODE': _("Use system GameMode for performance optimization"),
'PW_USE_D3D_EXTRAS': _("Enable forced use of third-party DirectX libraries"),
'PW_FIX_VIDEO_IN_GAME': _("Fix pink-tinted video playback in some games"),
'PW_REDUCE_PULSE_LATENCY': _("Reduce PulseAudio latency to fix intermittent sound"),
'PW_USE_US_LAYOUT': _("Force US keyboard layout"),
'PW_USE_GSTREAMER': _("Use GStreamer for in-game clips (WMF support)"),
'PW_USE_SHADER_CACHE': _("Use WINE shader caching"),
'PW_USE_WINE_DXGI': _("Force use of built-in DXGI library"),
'PW_USE_EAC_AND_BE': _("Enable Easy Anti-Cheat and BattlEye runtimes"),
'PW_USE_SYSTEM_VK_LAYERS': _("Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"),
'PW_USE_OBS_VKCAPTURE': _("Enable OBS Studio capture via obs-vkcapture"),
'PW_DISABLE_COMPOSITING': _("Disable desktop compositing for performance"),
'PW_USE_RUNTIME': _("Use container launch mode (recommended default)"),
'PW_DINPUT_PROTOCOL': _("Force DirectInput protocol instead of XInput"),
'PW_USE_NATIVE_WAYLAND': _("Enable experimental native Wayland support"),
'PW_USE_DXVK_HDR': _("Enable HDR settings under native Wayland"),
'PW_USE_GALLIUM_ZINK': _("Use Gallium Zink (OpenGL via Vulkan)"),
'PW_USE_GALLIUM_NINE': _("Use Gallium Nine (native DirectX 9 for Mesa)"),
'PW_USE_WINED3D_VULKAN': _("Use WineD3D Vulkan backend (Damavand)"),
'PW_USE_SUPPLIED_DXVK_VKD3D': _("Use bundled dxvk/vkd3d from Wine/Proton"),
'PW_USE_SAREK_ASYNC': _("Use async dxvk-sarek (experimental)")
}
def setup_ui(self):
"""Set up the user interface."""
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(10, 10, 10, 10)
self.main_layout.setSpacing(10)
# Search bar
search_layout = QHBoxLayout()
self.search_label = QLabel(_("Search:"))
self.search_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.search_edit = QLineEdit()
self.search_edit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
self.search_edit.setPlaceholderText(_("Search settings..."))
self.search_edit.textChanged.connect(self.filter_settings)
self.search_edit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
search_layout.addWidget(self.search_label)
search_layout.addWidget(self.search_edit)
self.main_layout.addLayout(search_layout)
# Tab widget
self.tab_widget = QTabWidget()
self.tab_widget.setStyleSheet(self.theme.WINETRICKS_TAB_STYLE)
self.main_tab = QWidget()
self.main_tab_layout = QVBoxLayout(self.main_tab)
self.advanced_tab = QWidget()
@@ -1790,12 +1837,14 @@ class ExeSettingsDialog(QDialog):
self.tab_widget.addTab(self.main_tab, _("Main"))
self.tab_widget.addTab(self.advanced_tab, _("Advanced"))
# Connect tab change to update description hint
self.tab_widget.currentChanged.connect(self.on_table_selection_changed)
# Таблица настроек
# Main settings table with preloader
self.settings_table = QTableWidget()
self.settings_table.setAlternatingRowColors(True)
self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.settings_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.settings_table.setColumnCount(3)
self.settings_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")])
self.settings_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
@@ -1806,13 +1855,36 @@ class ExeSettingsDialog(QDialog):
self.settings_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.settings_table.setTextElideMode(Qt.TextElideMode.ElideNone)
self.settings_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE)
self.main_tab_layout.addWidget(self.settings_table)
# Таблица Advanced
# Create preloader for main settings table
self.settings_preloader = Preloader()
settings_preloader_container = QWidget()
settings_preloader_layout = QVBoxLayout(settings_preloader_container)
settings_preloader_layout.addStretch()
settings_preloader_hlayout = QHBoxLayout()
settings_preloader_hlayout.addStretch()
settings_preloader_hlayout.addWidget(self.settings_preloader)
settings_preloader_hlayout.addStretch()
settings_preloader_layout.addLayout(settings_preloader_hlayout)
settings_preloader_layout.addStretch()
settings_preloader_layout.setContentsMargins(0, 0, 0, 0)
settings_preloader_layout.setSpacing(0)
# Create stacked widget for main settings
self.settings_container = QStackedWidget()
self.settings_container.addWidget(settings_preloader_container) # Index 0: preloader
self.settings_container.addWidget(self.settings_table) # Index 1: table
self.main_tab_layout.addWidget(self.settings_container)
# Connect selection changed signal for the main table
self.settings_table.currentCellChanged.connect(self.on_table_selection_changed)
# Advanced settings table with preloader
self.advanced_table = QTableWidget()
self.advanced_table.setAlternatingRowColors(True)
self.advanced_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.advanced_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.advanced_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.advanced_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
# self.advanced_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.advanced_table.setColumnCount(3)
self.advanced_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")])
self.advanced_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
@@ -1823,11 +1895,49 @@ class ExeSettingsDialog(QDialog):
self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone)
self.advanced_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE)
self.advanced_tab_layout.addWidget(self.advanced_table)
# Create preloader for advanced settings table
self.advanced_preloader = Preloader()
advanced_preloader_container = QWidget()
advanced_preloader_layout = QVBoxLayout(advanced_preloader_container)
advanced_preloader_layout.addStretch()
advanced_preloader_hlayout = QHBoxLayout()
advanced_preloader_hlayout.addStretch()
advanced_preloader_hlayout.addWidget(self.advanced_preloader)
advanced_preloader_hlayout.addStretch()
advanced_preloader_layout.addLayout(advanced_preloader_hlayout)
advanced_preloader_layout.addStretch()
advanced_preloader_layout.setContentsMargins(0, 0, 0, 0)
advanced_preloader_layout.setSpacing(0)
# Create stacked widget for advanced settings
self.advanced_container = QStackedWidget()
self.advanced_container.addWidget(advanced_preloader_container) # Index 0: preloader
self.advanced_container.addWidget(self.advanced_table) # Index 1: table
self.advanced_tab_layout.addWidget(self.advanced_container)
# Connect selection changed signal for the advanced table
self.advanced_table.currentCellChanged.connect(self.on_table_selection_changed)
self.main_layout.addWidget(self.tab_widget)
# Кнопки
# Gamepad tooltip for showing descriptions
self.gamepad_tooltip = QLabel()
self.gamepad_tooltip.setWordWrap(True)
self.gamepad_tooltip.setStyleSheet("""
QLabel {
background-color: #2d2d2d;
border: 1px solid #555;
border-radius: 4px;
padding: 8px;
color: white;
font-size: 14px;
}
""")
self.gamepad_tooltip.setVisible(False)
self.gamepad_tooltip.setParent(self)
self.gamepad_tooltip.setWindowFlags(Qt.WindowType.ToolTip)
# Buttons
button_layout = QHBoxLayout()
self.apply_button = AutoSizeButton(_("Apply"), icon=ThemeManager().get_icon("apply"))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=ThemeManager().get_icon("cancel"))
@@ -1840,13 +1950,16 @@ class ExeSettingsDialog(QDialog):
self.apply_button.clicked.connect(self.apply_changes)
self.cancel_button.clicked.connect(self.reject)
def load_current_settings(self):
"""Load available toggles first, then current settings."""
# Show preloaders initially
self.settings_container.setCurrentIndex(0) # Show preloader for main settings
self.advanced_container.setCurrentIndex(0) # Show preloader for advanced settings
process = QProcess(self)
process.finished.connect(self.on_list_db_finished)
process.start(self.start_sh[0], ["cli", "--list-db"])
args = self._get_process_args(["cli", "--list-db"])
process.start(args[0], args[1:])
def on_list_db_finished(self, exit_code, exit_status):
"""Handle --list-db output and extract available keys and system info."""
@@ -1868,6 +1981,9 @@ class ExeSettingsDialog(QDialog):
if re.match(r'^[A-Z_0-9]+=[^=]+$', line_stripped) and not line_stripped.startswith('PW_'):
# System info
k, v = line_stripped.split('=', 1)
# Remove surrounding quotes from the value if present
if v.startswith('"') and v.endswith('"') and len(v) >= 2:
v = v[1:-1]
if k.startswith('NUMA_NODE_'):
node_id = k[10:]
self.numa_nodes[node_id] = v
@@ -1887,13 +2003,14 @@ class ExeSettingsDialog(QDialog):
if len(parts) > 1 and 'blocked' in parts[1]:
self.blocked_keys.add(key)
# Показываем только пересечение
# Show only intersection
self.available_keys &= set(self.toggle_settings.keys())
# Загружаем текущие настройки
# Load current settings
process = QProcess(self)
process.finished.connect(self.on_show_ppdb_finished)
process.start(self.start_sh[0], ["cli", "--show-ppdb", f"{self.exe_path}.ppdb"])
args = self._get_process_args(["cli", "--show-ppdb", f"{self.exe_path}"])
process.start(args[0], args[1:])
def on_show_ppdb_finished(self, exit_code, exit_status):
"""Handle --show-ppdb output."""
@@ -1902,11 +2019,7 @@ class ExeSettingsDialog(QDialog):
# Fallback to defaults if load fails
for key in self.toggle_settings:
self.current_settings[key] = '0'
for adv_key in ['PW_WINDOWS_VER', 'WINEDLLOVERRIDES', 'LAUNCH_PARAMETERS',
'PW_WINE_CPU_TOPOLOGY', 'PW_MESA_GL_VERSION_OVERRIDE',
'PW_VKD3D_FEATURE_LEVEL', 'PW_LOCALE_SELECT',
'PW_MESA_VK_WSI_PRESENT_MODE', 'PW_AMD_VULKAN_USE',
'PW_CPU_NUMA_NODE_INDEX']:
for adv_key in ADVANCED_SETTING_KEYS:
self.current_settings[adv_key] = 'disabled' if 'TOPOLOGY' in adv_key or 'SELECT' in adv_key or 'MODE' in adv_key or 'LEVEL' in adv_key or 'GL_VERSION' in adv_key or 'NUMA' in adv_key else ''
else:
output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore').strip()
@@ -1914,14 +2027,12 @@ class ExeSettingsDialog(QDialog):
for line in output.split('\n'):
line_stripped = line.strip()
if '=' in line_stripped:
# Parse all KEY=VALUE lines, not just specific prefixes, to catch more
try:
key, val = line_stripped.split('=', 1)
if key in self.toggle_settings or key in ['PW_WINDOWS_VER', 'WINEDLLOVERRIDES', 'LAUNCH_PARAMETERS',
'PW_WINE_CPU_TOPOLOGY', 'PW_MESA_GL_VERSION_OVERRIDE',
'PW_VKD3D_FEATURE_LEVEL', 'PW_LOCALE_SELECT',
'PW_MESA_VK_WSI_PRESENT_MODE', 'PW_AMD_VULKAN_USE',
'PW_CPU_NUMA_NODE_INDEX', 'PW_TASKSET_SLR']:
if key in self.toggle_settings or key in ADVANCED_SETTING_KEYS:
# Remove surrounding quotes from the value if present
if val.startswith('"') and val.endswith('"') and len(val) >= 2:
val = val[1:-1]
self.current_settings[key] = val
except ValueError:
continue
@@ -1930,6 +2041,11 @@ class ExeSettingsDialog(QDialog):
for key in self.blocked_keys:
self.current_settings[key] = '0'
# Ensure the currently used Proton version is in dist_options even if not available locally
current_wine_version = self.current_settings.get('PW_WINE_USE')
if current_wine_version and current_wine_version not in self.dist_options:
self.dist_options.append(current_wine_version)
self.original_values = self.current_settings.copy()
for key in set(self.toggle_settings.keys()):
self.original_values.setdefault(key, '0')
@@ -1937,6 +2053,10 @@ class ExeSettingsDialog(QDialog):
self.populate_table()
self.populate_advanced()
# Show the loaded content and hide preloaders
self.settings_container.setCurrentIndex(1) # Show main settings table
self.advanced_container.setCurrentIndex(1) # Show advanced settings table
def populate_table(self):
"""Populate the table with settings that are available in both lists."""
self.settings_table.setRowCount(0)
@@ -1959,10 +2079,8 @@ class ExeSettingsDialog(QDialog):
current_val = self.current_settings.get(toggle, '0')
is_blocked = toggle in self.blocked_keys
checkbox = QTableWidgetItem()
checkbox.setFlags(checkbox.flags() | Qt.ItemFlag.ItemIsUserCheckable)
check_state = Qt.CheckState.Checked if current_val == '1' and not is_blocked else Qt.CheckState.Unchecked
checkbox.setCheckState(check_state)
checkbox.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
if is_blocked:
checkbox.setFlags(checkbox.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
checkbox.setBackground(QColor(240, 240, 240))
@@ -1985,124 +2103,39 @@ class ExeSettingsDialog(QDialog):
self.settings_table.setCurrentCell(0, 0)
self.settings_table.setFocus(Qt.FocusReason.OtherFocusReason)
# Initialize gamepad tooltip for the first row if gamepad is connected
if self.input_manager and self.input_manager.gamepad:
self.on_table_selection_changed()
def populate_advanced(self):
"""Populate the advanced tab with table format."""
self.advanced_table.setRowCount(0)
self.advanced_widgets.clear()
self.original_display_values = {}
self.value_mapping = {} # Store value mappings for settings that need translation
self.advanced_table.verticalHeader().setVisible(False)
current = self.current_settings
disabled_text = _('disabled')
# Define advanced settings configuration
advanced_settings = []
# 1. Windows version
advanced_settings.append({
'key': 'PW_WINDOWS_VER',
'name': _("Windows version"),
'description': _("Changing the WINDOWS emulation version may be required to run older games. WINDOWS versions below 10 do not support new games with DirectX 12"),
'type': 'combo',
'options': ['11', '10', '7', 'XP'],
'default': '10'
})
# 2. Forced to use/disable libraries
advanced_settings.append({
'key': 'WINEDLLOVERRIDES',
'name': _("DLL Overrides"),
'description': _("Forced to use/disable the library only for the given application.\n\nA brief instruction:\n* libraries are written WITHOUT the .dll file extension\n* libraries are separated by semicolons - ;\n* library=n - use the WINDOWS (third-party) library\n* library=b - use WINE (built-in) library\n* library=n,b - use WINDOWS library and then WINE\n* library=b,n - use WINE library and then WINDOWS\n* library= - disable the use of this library\n\nExample: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"),
'type': 'text',
'default': ''
})
# 3. Launch arguments
advanced_settings.append({
'key': 'LAUNCH_PARAMETERS',
'name': _("Launch Arguments"),
'description': _("Adding an argument after the .exe file, just like you would add an argument in a shortcut on a WINDOWS system.\n\nExample: -dx11 -skipintro 1"),
'type': 'text',
'default': ''
})
# 4. CPU cores limit
advanced_settings.append({
'key': 'PW_WINE_CPU_TOPOLOGY',
'name': _("CPU Cores Limit"),
'description': _("Limiting the number of CPU cores is useful for Unity games (It is recommended to set the value equal to 8)"),
'type': 'combo',
'options': [disabled_text] + self.logical_core_options,
'default': disabled_text
})
# 5. OpenGL version
advanced_settings.append({
'key': 'PW_MESA_GL_VERSION_OVERRIDE',
'name': _("OpenGL Version"),
'description': _("You can select the required OpenGL version, some games require a forced Compatibility Profile (COMP)."),
'type': 'combo',
'options': [disabled_text, '4.6COMPAT', '4.5COMPAT', '4.3COMPAT', '4.1COMPAT', '3.3COMPAT', '3.2COMPAT'],
'default': disabled_text
})
# 6. VKD3D feature level
advanced_settings.append({
'key': 'PW_VKD3D_FEATURE_LEVEL',
'name': _("VKD3D Feature Level"),
'description': _("You can set a forced feature level VKD3D for games on DirectX12"),
'type': 'combo',
'options': [disabled_text, '12_2', '12_1', '12_0', '11_1', '11_0'],
'default': disabled_text
})
# 7. Locale
advanced_settings.append({
'key': 'PW_LOCALE_SELECT',
'name': _("Locale"),
'description': _("Force certain locale for an app. Fixes encoding issues in legacy software"),
'type': 'combo',
'options': [disabled_text] + self.locale_options,
'default': disabled_text
})
# 8. Present mode
advanced_settings.append({
'key': 'PW_MESA_VK_WSI_PRESENT_MODE',
'name': _("Window Mode"),
'description': _("Window mode (for Vulkan and OpenGL):\nfifo - First in, first out. Limits the frame rate + no tearing. (VSync)\nimmediate - Unlimited frame rate + tearing.\nmailbox - Triple buffering. Unlimited frame rate + no tearing.\nrelaxed - Same as fifo but allows tearing when below the monitors refresh rate."),
'type': 'combo',
'options': [disabled_text, 'fifo', 'immediate', 'mailbox', 'relaxed'],
'default': disabled_text
})
# 9. AMD Vulkan (always show, block if not applicable)
amd_options = [disabled_text] + self.amd_vulkan_drivers if self.is_amd and self.amd_vulkan_drivers else [disabled_text]
advanced_settings.append({
'key': 'PW_AMD_VULKAN_USE',
'name': _("AMD Vulkan Driver"),
'description': _("Select needed AMD vulkan implementation. Choosing which implementation of vulkan will be used to run the game"),
'type': 'combo',
'options': amd_options,
'default': disabled_text
})
# 10. NUMA node (always show if numa_nodes exist, block if <=1)
numa_ids = sorted(self.numa_nodes.keys())
numa_options = [disabled_text] + numa_ids if len(numa_ids) > 1 else [disabled_text]
advanced_settings.append({
'key': 'PW_CPU_NUMA_NODE_INDEX',
'name': _("NUMA Node"),
'description': _("NUMA node for CPU affinity. In multi-core systems, CPUs are split into NUMA nodes, each with its own local memory and cores. Binding a game to a single node reduces memory-access latency and limits costly core-to-core switches."),
'type': 'combo',
'options': numa_options,
'default': disabled_text
})
# Get advanced settings from config module
advanced_settings = get_advanced_settings(
disabled_text=disabled_text,
logical_core_options=self.logical_core_options,
locale_options=self.locale_options,
amd_vulkan_drivers=self.amd_vulkan_drivers,
is_amd=self.is_amd,
numa_nodes=self.numa_nodes,
dist_options=self.dist_options,
prefix_options=self.prefix_options
)
# Populate table
for setting in advanced_settings:
row = self.advanced_table.rowCount()
self.advanced_table.insertRow(row)
is_blocked = setting.get("type") == "combo" and len(setting.get("options", [])) == 1
# Name column
name_item = QTableWidgetItem(setting['name'])
@@ -2119,29 +2152,70 @@ class ExeSettingsDialog(QDialog):
if 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_AMD_VULKAN_USE':
current_val = disabled_text if not current_raw or current_raw == '' else current_raw
current_val = disabled_text if not current_raw or current_raw == '' or current_raw == 'disabled' else current_raw
elif setting['key'] == 'PW_WINE_USE':
current_val = current_raw
else:
current_val = disabled_text if current_raw == 'disabled' else current_raw
if current_val not in setting['options']:
# For settings with value mapping, convert the raw value to display text
if '_value_map' in setting:
# Create reverse mapping for lookup
reverse_map = {v: k for k, v in setting['_value_map'].items()}
if current_raw in reverse_map:
# The value from DB maps to a display text, so show that text
current_val = reverse_map[current_raw]
if current_val and current_val not in setting['options']:
combo.addItem(current_val)
combo.setCurrentText(current_val)
# Block if only disabled option
if len(setting['options']) == 1:
if is_blocked:
combo.setEnabled(False)
name_item.setForeground(QColor(128, 128, 128))
# Set focus policy for combo box
combo.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.advanced_table.setCellWidget(row, 1, combo)
self.advanced_widgets[setting['key']] = combo
# For settings with value mapping, we need to store what was originally displayed to the user
# If the current_raw value maps to a display text, store the display text for comparison
if '_value_map' in setting:
reverse_map = {v: k for k, v in setting['_value_map'].items()}
if current_raw in reverse_map:
# The raw value from database maps to a display text, store that display text
# so comparison with user selection will work correctly
self.original_display_values[setting['key']] = reverse_map[current_raw]
else:
# No mapping found, store the current display value
self.original_display_values[setting['key']] = current_val
else:
# For regular settings, store the display value
self.original_display_values[setting['key']] = current_val
elif setting['type'] == 'text':
text_edit = QTextEdit()
current_val = current.get(setting['key'], setting['default'])
text_edit.setPlainText(current_val)
# Check if this setting has a value mapping and store it
if '_value_map' in setting:
# Create reverse mapping for original value lookup
reverse_map = {v: k for k, v in setting['_value_map'].items()}
self.value_mapping[setting['key']] = {
'forward': setting['_value_map'],
'reverse': reverse_map
}
self.advanced_table.setCellWidget(row, 1, text_edit)
self.advanced_widgets[setting['key']] = text_edit
elif setting['type'] == 'text':
line_edit = QLineEdit()
current_val = current.get(setting['key'], setting['default'])
line_edit.setText(current_val)
if is_blocked:
line_edit.setEnabled(False)
line_edit.setStyleSheet("background-color: #f0f0f0;")
self.advanced_table.setCellWidget(row, 1, line_edit)
self.advanced_widgets[setting['key']] = line_edit
self.original_display_values[setting['key']] = current_val
# Description column
@@ -2149,17 +2223,87 @@ class ExeSettingsDialog(QDialog):
desc_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
desc_item.setToolTip(setting['description'])
desc_item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
if is_blocked:
desc_item.setForeground(QColor(128, 128, 128))
self.advanced_table.setItem(row, 2, desc_item)
self.advanced_table.resizeRowsToContents()
if self.advanced_table.rowCount() > 0:
self.advanced_table.setCurrentCell(0, 0)
# Initialize gamepad tooltip for the first row if gamepad is connected
if self.input_manager and self.input_manager.gamepad and self.advanced_table.rowCount() > 0:
self.on_table_selection_changed()
def init_virtual_keyboard(self):
"""Initialize virtual keyboard"""
self.keyboard = VirtualKeyboard(self, theme=self.theme, button_width=50)
self.keyboard.hide()
self.keyboard.current_input_widget = None
def show_virtual_keyboard(self, widget=None):
"""Show virtual keyboard for search or text input"""
if not widget:
# Default to search edit
widget = self.search_edit
if not widget or not widget.isVisible():
return
# Set the current input widget
self.keyboard.current_input_widget = widget
# Position the keyboard
keyboard_height = 220
self.keyboard.setFixedWidth(self.width())
self.keyboard.setFixedHeight(keyboard_height)
self.keyboard.move(0, self.height() - keyboard_height)
# Show and raise keyboard
self.keyboard.setParent(self)
self.keyboard.show()
self.keyboard.raise_()
# Focus on first button of keyboard
first_button = self.keyboard.findFirstFocusableButton()
if first_button:
# First hide the current focus to prevent conflicts
focused_widget = QApplication.focusWidget()
if focused_widget and focused_widget != self.keyboard:
focused_widget.clearFocus()
# Then focus the keyboard button
QTimer.singleShot(50, lambda: first_button.setFocus())
def filter_settings(self, text):
"""Filter settings based on search text."""
# Filter main settings table
search_text = text.lower()
for row in range(self.settings_table.rowCount()):
name_item = self.settings_table.item(row, 0) # Setting name
desc_item = self.settings_table.item(row, 2) # Description
should_show = False
if name_item and search_text in name_item.text().lower():
should_show = True
elif desc_item and search_text in desc_item.text().lower():
should_show = True
self.settings_table.setRowHidden(row, not should_show)
# Filter advanced settings table
for row in range(self.advanced_table.rowCount()):
name_item = self.advanced_table.item(row, 0) # Setting name
desc_item = self.advanced_table.item(row, 2) # Description
should_show = False
if name_item and search_text in name_item.text().lower():
should_show = True
elif desc_item and search_text in desc_item.text().lower():
should_show = True
self.advanced_table.setRowHidden(row, not should_show)
def apply_changes(self):
"""Apply changes by collecting diffs from both main and advanced tabs."""
changes = []
# --- 1. Обычные (toggle) настройки ---
for key, orig_val in self.original_values.items():
if key in self.blocked_keys:
continue # Skip blocked keys
@@ -2180,32 +2324,50 @@ class ExeSettingsDialog(QDialog):
if new_val != orig_val:
changes.append(f"{key}={new_val}")
# --- 2. Advanced настройки ---
for key, widget in self.advanced_widgets.items():
orig_val = self.original_display_values.get(key, '')
if isinstance(widget, QComboBox):
new_val = widget.currentText()
# приведение disabled к 'disabled'
# Special handling for settings that have value mapping
# Check if this key has a forward value mapping stored
if key in self.value_mapping and 'forward' in self.value_mapping[key]:
value_map = self.value_mapping[key]['forward']
# Compare the selected display text with the original display text
has_changed = (new_val != orig_val)
# If new_val (the display text selected by user) is in the forward mapping,
# convert it to the actual value for saving
if new_val in value_map:
new_val = value_map[new_val]
# else: new_val remains as the display text (for cases not in the mapping)
else:
# No value mapping, regular comparison
has_changed = (new_val != orig_val)
if new_val.lower() == _('disabled').lower():
new_val = 'disabled'
elif isinstance(widget, QTextEdit):
new_val = widget.toPlainText().strip()
if has_changed:
changes.append(f"{key}={new_val}")
elif isinstance(widget, QLineEdit):
new_val = widget.text().strip()
if new_val != orig_val:
changes.append(f"{key}={new_val}")
else:
continue
if new_val != orig_val:
changes.append(f"{key}={new_val}")
# --- 3. Проверка на изменения ---
if not changes:
QMessageBox.information(self, _("Info"), _("No changes to apply."))
return
# --- 4. Запуск процесса сохранения ---
process = QProcess(self)
process.finished.connect(self.on_edit_db_finished)
args = ["cli", "--edit-db", self.exe_path] + changes
process.start(self.start_sh[0], args)
process_args = ["cli", "--edit-db", self.exe_path] + changes
args = self._get_process_args(process_args)
process.start(args[0], args[1:])
self.apply_button.setEnabled(False)
def on_edit_db_finished(self, exit_code, exit_status):
@@ -2220,8 +2382,118 @@ class ExeSettingsDialog(QDialog):
self.load_current_settings()
QMessageBox.information(self, _("Success"), _("Settings updated successfully."))
def keyPressEvent(self, event):
"""Override key press event to handle combo box interaction properly."""
# If a combo box in the advanced table is active and has an open dropdown,
# we need to handle Escape key specially to prevent dialog closure
focused_widget = QApplication.focusWidget()
if (event.key() == Qt.Key.Key_Escape and
isinstance(focused_widget, QComboBox) and
focused_widget.view().isVisible()):
# If a combo box dropdown is open, just close the dropdown instead of the dialog
focused_widget.hidePopup()
self.advanced_table.setFocus()
return
super().keyPressEvent(event)
def closeEvent(self, event):
# Hide virtual keyboard if visible
if hasattr(self, 'keyboard') and self.keyboard.isVisible():
self.keyboard.hide()
if self.input_manager:
self.input_manager.disable_settings_mode()
super().closeEvent(event)
def show_gamepad_tooltip(self, show=True, text=""):
"""Show or hide the gamepad tooltip with the provided text."""
if show and text:
# Set the text to the tooltip
self.gamepad_tooltip.setText(text)
# Temporarily set a large size to allow proper text wrapping measurement
self.gamepad_tooltip.setFixedSize(500, 300)
# Use font metrics to calculate the proper size with Qt's wrapping
font_metrics = self.gamepad_tooltip.fontMetrics()
max_width = 500 # Maximum width in pixels
# Calculate the required size using Qt's text wrapping functionality
# We'll allow Qt to do the wrapping and measure accordingly
# Using the boundingRect with TextWordWrap flag for accurate measurement
text_rect = font_metrics.boundingRect(
0, 0, max_width - 20, 1000, # Leave space for padding
Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextExpandTabs,
text
)
# Calculate final dimensions with sufficient padding
required_width = min(max_width, text_rect.width() + 25) # Add padding
required_height = min(300, text_rect.height() + 25) # Add padding
# Position the tooltip near the currently focused cell
current_table = self.advanced_table if self.tab_widget.currentIndex() == 1 else self.settings_table
if current_table and current_table.currentRow() >= 0 and current_table.currentColumn() >= 0:
# Get the position of the current cell
row = current_table.currentRow()
col = current_table.currentColumn()
item_rect = current_table.visualRect(current_table.model().index(row, col))
# Convert to global coordinates
global_pos = current_table.mapToGlobal(item_rect.topRight())
# Position the tooltip near the cell
self.gamepad_tooltip.setFixedSize(required_width, required_height)
self.gamepad_tooltip.move(global_pos.x(), global_pos.y())
self.gamepad_tooltip.setVisible(True)
else:
self.gamepad_tooltip.setVisible(False)
else:
self.gamepad_tooltip.setVisible(False)
def get_current_description(self):
"""Get the description text for the currently selected row in the active table."""
# Determine which table is active
current_table = self.advanced_table if self.tab_widget.currentIndex() == 1 else self.settings_table
current_row = current_table.currentRow()
if current_row >= 0:
# Get the description from column 2
desc_item = current_table.item(current_row, 2)
if desc_item:
return desc_item.text()
return ""
def on_table_selection_changed(self):
"""Called when table selection changes to update the gamepad tooltip."""
# Only show the tooltip if we have a gamepad connected and we're in the description column
if self.input_manager and self.input_manager.gamepad:
current_table = self.advanced_table if self.tab_widget.currentIndex() == 1 else self.settings_table
current_column = current_table.currentColumn() if current_table else -1
# Only show tooltip when focused on the description column (column 2)
if current_column == 2:
description = self.get_current_description()
self.show_gamepad_tooltip(show=True, text=description)
else:
self.show_gamepad_tooltip(show=False)
else:
self.show_gamepad_tooltip(show=False)
def reject(self):
# Hide virtual keyboard if visible
if hasattr(self, 'keyboard') and self.keyboard.isVisible():
self.keyboard.hide()
# Hide gamepad tooltip
self.gamepad_tooltip.setVisible(False)
if self.input_manager:
self.input_manager.disable_settings_mode()
super().reject()
def accept(self):
# Hide virtual keyboard if visible
if hasattr(self, 'keyboard') and self.keyboard.isVisible():
self.keyboard.hide()
# Hide gamepad tooltip
self.gamepad_tooltip.setVisible(False)
if self.input_manager:
self.input_manager.disable_settings_mode()
super().accept()

View File

@@ -577,7 +577,7 @@ def get_egs_game_description_async(
"https://launcher.store.epicgames.com/graphql",
json=search_query,
headers=headers,
timeout=5
timeout=10
)
response.raise_for_status()
data = orjson.loads(response.content)
@@ -597,7 +597,7 @@ def get_egs_game_description_async(
def fetch_legacy_description(url: str) -> str:
"""Fetches description from the legacy API, handling DNS failures."""
try:
response = requests.get(url, headers=headers, timeout=5)
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
data = orjson.loads(response.content)
if not isinstance(data, dict):
@@ -619,6 +619,9 @@ def get_egs_game_description_async(
except requests.exceptions.ConnectionError as e:
logger.error("DNS resolution failed for legacy API %s: %s", url, str(e))
return ""
except requests.exceptions.Timeout:
logger.warning("Request timeout for legacy API %s", url)
return ""
except requests.RequestException as e:
logger.warning("Failed to fetch legacy API for %s: %s", app_name, str(e))
return ""
@@ -670,7 +673,7 @@ def get_egs_game_description_async(
url = "https://graphql.epicgames.com/graphql"
try:
response = requests.post(url, json=search_query, headers=headers, timeout=5)
response = requests.post(url, json=search_query, headers=headers, timeout=10)
response.raise_for_status()
data = orjson.loads(response.content)
if namespace:
@@ -689,6 +692,9 @@ def get_egs_game_description_async(
for substring in ["bundle", "pack", "edition", "dlc", "upgrade", "chapter", "набор", "пак", "дополнение"])):
return element.get("description", ""), element.get("productSlug", "")
return "", ""
except requests.exceptions.Timeout:
logger.warning("GraphQL request timeout for %s with locale %s", app_name, locale)
return "", ""
except requests.RequestException as e:
logger.warning("Failed to fetch GraphQL data for %s with locale %s: %s", app_name, locale, str(e))
return "", ""
@@ -717,6 +723,10 @@ def get_egs_game_description_async(
logger.debug("Fetched description from legacy API for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description)
except requests.exceptions.ConnectionError:
logger.error("Skipping legacy API due to DNS resolution failure for %s", app_name)
except requests.exceptions.Timeout:
logger.warning("Legacy API request timed out for %s", app_name)
except Exception as e:
logger.error("Unexpected error fetching legacy API for %s: %s", app_name, str(e))
# Step 3: If still no description and no namespace, try GraphQL with title
if not description and not namespace:

View File

@@ -1,7 +1,6 @@
from PySide6.QtGui import QPainter, QColor, QDesktopServices
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
from collections.abc import Callable
from portprotonqt.image_utils import load_pixmap_async, round_corners
from portprotonqt.localization import _
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter, read_theme_from_config
@@ -10,8 +9,6 @@ from portprotonqt.custom_widgets import ClickableLabel
from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.downloader import Downloader
from portprotonqt.animations import GameCardAnimations
from typing import cast
class GameCard(QFrame):
borderWidthChanged = Signal()
@@ -102,7 +99,7 @@ class GameCard(QFrame):
self.favoriteLabel = ClickableLabel(self.coverWidget)
self.favoriteLabel.clicked.connect(self.toggle_favorite)
self.is_favorite = self.name in read_favorites()
self.is_favorite = self.name in set(read_favorites())
self.update_favorite_icon()
self.favoriteLabel.raise_()
@@ -203,13 +200,27 @@ class GameCard(QFrame):
self.update_cover_pixmap()
def update_cover_pixmap(self):
if self.base_pixmap:
# Check if the coverLabel still exists before trying to update it
# This prevents the "Internal C++ object already deleted" error when
# the widget has been destroyed but the async callback still executes
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
return
if self.base_pixmap and not self.base_pixmap.isNull():
scaled_width = int(self.base_card_width * self._scale)
scaled_pixmap = self.base_pixmap.scaled(scaled_width, int(scaled_width * 1.5), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
rounded_pixmap = round_corners(scaled_pixmap, int(15 * self._scale))
try:
self.coverLabel.setPixmap(rounded_pixmap)
except RuntimeError:
# Handle the case where the Qt object was deleted between the check and the call
pass
def _position_badges(self, current_width):
# Check if the card has been destroyed before updating
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
return
right_margin = int(8 * self._scale)
badge_spacing = int(current_width * 0.02)
top_y = int(10 * self._scale)
@@ -228,16 +239,28 @@ class GameCard(QFrame):
if is_visible:
badge_x = current_width - badge_width - right_margin
badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
try:
badge.move(int(badge_x), int(badge_y))
badge_y_positions.append(badge_y + badge.height())
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
try:
self.anticheatLabel.raise_()
self.protondbLabel.raise_()
self.portprotonLabel.raise_()
self.egsLabel.raise_()
self.steamLabel.raise_()
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
def update_scale(self):
# Check if the card has been destroyed before updating
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
return
scaled_width = int(self.base_card_width * self._scale)
scaled_height = int(self.base_card_width * 1.8 * self._scale)
scaled_extra = int(self.base_extra_margin * self._scale)
@@ -258,25 +281,42 @@ class GameCard(QFrame):
icon_space = int(scaled_width * 0.012)
for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]:
if label is not None:
try:
label.setFixedWidth(badge_width)
label.setIconSize(icon_size, icon_space)
label.setCardWidth(scaled_width)
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
self._position_badges(scaled_width)
if self.base_font_size is not None:
try:
font = self.nameLabel.font()
new_font_size = self.base_font_size * self._scale
if new_font_size > 0:
font.setPointSizeF(new_font_size)
self.nameLabel.setFont(font)
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
try:
self.shadow.setBlurRadius(int(20 * self._scale))
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
try:
self.updateGeometry()
self.update()
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
# Ensure parent layout is updated safely
try:
parent = self.parentWidget()
if parent:
layout = parent.layout()
@@ -285,6 +325,9 @@ class GameCard(QFrame):
layout.activate()
layout.update()
parent.updateGeometry()
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
def update_card_size(self, new_width: int):
self.base_card_width = new_width
@@ -292,6 +335,10 @@ class GameCard(QFrame):
self.update_scale()
def update_badge_visibility(self, display_filter: str):
# Check if the card has been destroyed before updating
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
return
self.display_filter = display_filter
self.steam_visible = (str(self.game_source).lower() == "steam" and self.display_filter in ("all", "favorites"))
self.egs_visible = (str(self.game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
@@ -299,11 +346,15 @@ class GameCard(QFrame):
protondb_visible = bool(self.getProtonDBText(self.protondb_tier))
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status))
try:
self.steamLabel.setVisible(self.steam_visible)
self.egsLabel.setVisible(self.egs_visible)
self.portprotonLabel.setVisible(self.portproton_visible)
self.protondbLabel.setVisible(protondb_visible)
self.anticheatLabel.setVisible(anticheat_visible)
except RuntimeError:
# Handle the case where the Qt object was deleted
return
scaled_width = int(self.base_card_width * self._scale)
self._position_badges(scaled_width)
@@ -398,27 +449,43 @@ class GameCard(QFrame):
QDesktopServices.openUrl(url)
def update_favorite_icon(self):
# Check if the card has been destroyed before updating
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
return
try:
if self.is_favorite:
self.favoriteLabel.setText("")
else:
self.favoriteLabel.setText("")
self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
except RuntimeError:
# Handle the case where the Qt object was deleted
return
try:
parent = self.parent()
while parent:
if hasattr(parent, 'game_library_manager'):
QTimer.singleShot(0, parent.game_library_manager.update_game_grid) # type: ignore[attr-defined]
# Access using getattr with default to avoid Ruff B009 warning
manager = getattr(parent, 'game_library_manager', None)
if manager is not None:
QTimer.singleShot(0, manager.update_game_grid)
break
parent = parent.parent()
except RuntimeError:
# Handle the case where the Qt object was deleted
pass
def toggle_favorite(self):
favorites = read_favorites()
favorites_set = set(favorites)
if self.is_favorite:
if self.name in favorites:
if self.name in favorites_set:
favorites.remove(self.name)
self.is_favorite = False
else:
if self.name not in favorites:
if self.name not in favorites_set:
favorites.append(self.name)
self.is_favorite = True
save_favorites(favorites)
@@ -451,9 +518,9 @@ class GameCard(QFrame):
self.update_scale()
self.scaleChanged.emit()
borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged))
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged))
scale = Property(float, getScale, setScale, None, "", notify=cast(Callable[[], None], scaleChanged))
borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=borderWidthChanged)
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=gradientAngleChanged)
scale = Property(float, getScale, setScale, None, "", notify=scaleChanged)
def paintEvent(self, event):

View File

@@ -1,5 +1,6 @@
from typing import Protocol
from portprotonqt.game_card import GameCard
from portprotonqt.search_utils import SearchOptimizer, ThreadedSearch
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller
from PySide6.QtCore import Qt, QTimer
from portprotonqt.custom_widgets import FlowLayout
@@ -56,6 +57,9 @@ class GameLibraryManager:
self.pending_deletions = deque()
self.is_filtering = False
self.dirty = False
# Initialize search optimizer
self.search_optimizer = SearchOptimizer()
self.search_thread: ThreadedSearch | None = None
def create_games_library_widget(self):
"""Creates the games library widget with search, grid, and slider."""
@@ -163,12 +167,18 @@ class GameLibraryManager:
if is_focused:
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)
except RuntimeError:
pass # Card already deleted
self.main_window.current_hovered_card = None
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
try:
self.main_window.current_focused_card._focused = False
self.main_window.current_focused_card.clearFocus()
except RuntimeError:
pass # Card already deleted
self.main_window.current_focused_card = card
else:
if self.main_window.current_focused_card == card:
@@ -189,11 +199,19 @@ class GameLibraryManager:
if is_hovered:
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
try:
if self.main_window.current_focused_card:
self.main_window.current_focused_card._focused = False
self.main_window.current_focused_card.clearFocus()
except RuntimeError:
pass # Card already deleted
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
try:
if self.main_window.current_hovered_card:
self.main_window.current_hovered_card._hovered = False
self.main_window.current_hovered_card.leaveEvent(None)
except RuntimeError:
pass # Card already deleted
self.main_window.current_hovered_card = card
else:
if self.main_window.current_hovered_card == card:
@@ -212,6 +230,10 @@ class GameLibraryManager:
if games_list is not None:
self.filtered_games = games_list
self.dirty = True # Full rebuild only for non-filter
else:
# When filtering, we want to update with the current filtered_games
# which has already been set by _perform_search
pass
self.is_filtering = is_filter
self._pending_update = True
@@ -222,13 +244,17 @@ class GameLibraryManager:
def force_update_cards_library(self):
if self.gamesListWidget and self.gamesListLayout:
# Use singleShot to ensure UI updates happen after all other operations complete
# This prevents potential freezing in PySide 6.10.1
QTimer.singleShot(0, self._perform_force_update)
def _perform_force_update(self):
"""Perform the actual force update on the layout."""
if self.gamesListLayout:
self.gamesListLayout.invalidate()
if self.gamesListWidget:
self.gamesListWidget.adjustSize()
self.gamesListWidget.updateGeometry()
widget = self.gamesListWidget
QTimer.singleShot(0, lambda: (
widget.adjustSize(),
widget.updateGeometry()
))
def _update_game_grid_immediate(self):
"""Updates the game grid with the provided or current game list."""
@@ -238,8 +264,9 @@ class GameLibraryManager:
search_text = self.main_window.searchEdit.text().strip().lower()
if self.is_filtering:
# Filter mode: do not change layout, only hide/show cards
self._apply_filter_visibility(search_text)
# Filter mode: use the pre-computed filtered_games from optimized search
# This means we already have the exact games to show
self._update_search_results()
else:
# Full update: sorting, removal/addition, reorganization
games_list = self.filtered_games if self.filtered_games else self.games
@@ -267,8 +294,9 @@ class GameLibraryManager:
return (fav_order, -game[10] if game[10] else 0, -game[11] if game[11] else 0)
# Quick partition: Sort favorites and non-favorites separately, then merge
fav_games = [g for g in games_list if g[0] in favorites]
non_fav_games = [g for g in games_list if g[0] not in favorites]
favorites_set = set(favorites) # Convert to set for O(1) lookup
fav_games = [g for g in games_list if g[0] in favorites_set]
non_fav_games = [g for g in games_list if g[0] not in favorites_set]
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
@@ -363,8 +391,73 @@ class GameLibraryManager:
self.is_filtering = False # Reset flag in any case
def _update_search_results(self):
"""Update the grid with pre-computed search results."""
if self.gamesListLayout is None or self.gamesListWidget is None:
return
# Batch layout updates
self.gamesListWidget.setUpdatesEnabled(False)
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(False) # Disable layout during batch
try:
# Create set of keys for current filtered games for fast lookup
filtered_keys = {(game[0], game[4]) for game in self.filtered_games} # (name, exec_line)
# Process existing cards: show cards that are in filtered results, hide others
cards_to_hide = []
for card_key, card in self.game_card_cache.items():
if card_key in filtered_keys:
# Card should be visible
if not card.isVisible():
card.setVisible(True)
else:
# Card should be hidden
if card.isVisible():
card.setVisible(False)
cards_to_hide.append(card_key)
# Now add any missing cards that are in filtered results but not in cache
cards_to_add = []
for game_data in self.filtered_games:
game_name = game_data[0]
exec_line = game_data[4]
game_key = (game_name, exec_line)
if game_key not in self.game_card_cache:
if self.context_menu_manager is None:
continue
card = self._create_game_card(game_data)
self.game_card_cache[game_key] = card
card.setVisible(True) # New cards should be visible
cards_to_add.append((game_key, card))
# Add new cards to layout
for _game_key, card in cards_to_add:
self.gamesListLayout.addWidget(card)
# Remove cards that are no longer needed (if any)
# Note: we're not removing them completely as they might be needed later
# Instead, we just hide them and they'll be reused if needed
finally:
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(True)
self.gamesListWidget.setUpdatesEnabled(True)
if self.gamesListLayout is not None:
self.gamesListLayout.update()
self.gamesListWidget.updateGeometry()
self.main_window._last_card_width = self.card_width
self.force_update_cards_library()
def _apply_filter_visibility(self, search_text: str):
"""Applies visibility to cards based on search, without changing the layout."""
# This method is used for simple substring matching
# For the new optimized search, we'll use a different approach in update_game_grid
# when is_filter=True
visible_count = 0
for game_key, card in self.game_card_cache.items():
game_name = card.name # Assume GameCard has 'name' attribute
@@ -397,6 +490,7 @@ class GameLibraryManager:
select_callback=self.main_window.openGameDetailPage,
theme=self.theme,
card_width=self.card_width,
parent=self.gamesListWidget,
context_menu_manager=self.context_menu_manager
)
@@ -419,6 +513,11 @@ class GameLibraryManager:
def _flush_deletions(self):
"""Delete pending widgets off the main update cycle."""
for card in list(self.pending_deletions):
# Clear any references to this card if it's currently focused/hovered
if self.main_window.current_focused_card == card:
self.main_window.current_focused_card = None
if self.main_window.current_hovered_card == card:
self.main_window.current_hovered_card = None
card.deleteLater()
self.pending_deletions.remove(card)
@@ -426,24 +525,61 @@ class GameLibraryManager:
"""Clears all widgets from the layout."""
if layout is None:
return
# Remove all widgets from the layout and clean up caches
while layout.count():
child = layout.takeAt(0)
if child.widget():
widget = child.widget()
# Clean up cache if widget exists in it
for key, card in list(self.game_card_cache.items()):
if card == widget:
del self.game_card_cache[key]
if key in self.pending_images:
del self.pending_images[key]
break
# Always schedule widget for deletion regardless of cache state
widget.deleteLater()
# Also clear the cache completely if needed (in case layout wasn't in sync)
self.game_card_cache.clear()
self.pending_images.clear()
def set_games(self, games: list[tuple]):
"""Sets the games list and updates the filtered games."""
self.games = games
self.filtered_games = self.games
# Build search indices for fast searching
self._build_search_indices(games)
self.dirty = True # Full resort needed
self.update_game_grid()
def _build_search_indices(self, games: list[tuple]):
"""Build search indices for fast searching."""
# Prepare items for indexing: (search_key, game_data)
# We'll index by game name (index 0) and potentially other fields
items = []
for game in games:
# game is a tuple: (name, description, cover, appid, exec_line, controller_support,
# last_launch, formatted_playtime, protondb_tier, anticheat_status,
# last_played_timestamp, playtime_seconds, game_source)
name = str(game[0]).lower() if game[0] else ""
description = str(game[1]).lower() if game[1] else ""
# Create multiple search entries for better matching
items.append((name, game)) # Exact name
# Add other searchable fields if needed
if description:
items.append((description, game))
# Also add individual words from the name for partial matching
for word in name.split():
if len(word) > 2: # Only index words longer than 2 characters
items.append((word, game))
self.search_optimizer.build_indices(items)
def add_game_incremental(self, game_data: tuple):
"""Add a single game without full reload."""
self.games.append(game_data)
@@ -467,4 +603,54 @@ class GameLibraryManager:
def filter_games_delayed(self):
"""Filters games based on search text and updates the grid."""
search_text = self.main_window.searchEdit.text().strip().lower()
if not search_text:
# If search is empty, show all games
self.filtered_games = self.games
self.update_game_grid(is_filter=True)
else:
# Use the optimized search
self._perform_search(search_text)
def _perform_search(self, search_text: str):
"""Perform the actual search using optimized search algorithms."""
if not search_text:
self.filtered_games = self.games
self.update_game_grid(is_filter=True)
return
# Use exact search first
exact_result = self.search_optimizer.exact_search(search_text)
if exact_result:
# If exact match found, show only that game
self.filtered_games = [exact_result]
self.update_game_grid(is_filter=True)
return
# Try prefix search
prefix_results = self.search_optimizer.prefix_search(search_text)
if prefix_results:
# Get the actual game data from the prefix matches
filtered_games = []
for _match_text, game_data in prefix_results:
if game_data not in filtered_games: # Avoid duplicates
filtered_games.append(game_data)
self.filtered_games = filtered_games
self.update_game_grid(is_filter=True)
return
# Finally, try fuzzy search
fuzzy_results = self.search_optimizer.fuzzy_search(search_text, limit=20, min_score=60.0)
if fuzzy_results:
# Get the actual game data from the fuzzy matches
filtered_games = []
for _match_text, game_data, _score in fuzzy_results:
if game_data not in filtered_games: # Avoid duplicates
filtered_games.append(game_data)
self.filtered_games = filtered_games
self.update_game_grid(is_filter=True)
else:
# If no results found, show empty list
self.filtered_games = []
self.update_game_grid(is_filter=True)

View File

@@ -36,6 +36,17 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
current_theme_name = read_theme_from_config()
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"))
painter = QPainter(placeholder_pixmap)
painter.setPen(QPen(QColor("white")))
painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
painter.end()
callback(placeholder_pixmap)
else:
scaled = pixmap.scaled(width, height, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
x = (scaled.width() - width) // 2
y = (scaled.height() - height) // 2
@@ -58,6 +69,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
local_path = os.path.join(image_folder, f"{appid}.jpg")
if os.path.exists(local_path):
pixmap = QPixmap(local_path)
# Check if the pixmap loaded successfully
if pixmap.isNull():
logger.warning(f"Failed to load image from {local_path}")
finish_with(pixmap)
return
@@ -69,6 +83,8 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
if placeholder_path and QFile.exists(placeholder_path):
pixmap.load(placeholder_path)
if pixmap.isNull():
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
else:
pixmap = QPixmap(width, height)
pixmap.fill(QColor("#333333"))
@@ -93,6 +109,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
if os.path.exists(local_path):
pixmap = QPixmap(local_path)
# Check if the pixmap loaded successfully
if pixmap.isNull():
logger.warning(f"Failed to load image from {local_path}")
finish_with(pixmap)
return
@@ -104,6 +123,8 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
if placeholder_path and QFile.exists(placeholder_path):
pixmap.load(placeholder_path)
if pixmap.isNull():
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
else:
pixmap = QPixmap(width, height)
pixmap.fill(QColor("#333333"))
@@ -125,6 +146,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
local_path = os.path.join(image_folder, f"{app_name}.jpg")
if os.path.exists(local_path):
pixmap = QPixmap(local_path)
# Check if the pixmap loaded successfully
if pixmap.isNull():
logger.warning(f"Failed to load image from {local_path}")
finish_with(pixmap)
return
@@ -136,6 +160,8 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
if placeholder_path and QFile.exists(placeholder_path):
pixmap.load(placeholder_path)
if pixmap.isNull():
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
else:
pixmap = QPixmap(width, height)
pixmap.fill(QColor("#333333"))
@@ -152,6 +178,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
if cover and QFile.exists(cover):
pixmap = QPixmap(cover)
# Check if the pixmap loaded successfully
if pixmap.isNull():
logger.warning(f"Failed to load image from {cover}")
finish_with(pixmap)
return
@@ -159,6 +188,8 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
pixmap = QPixmap()
if placeholder_path and QFile.exists(placeholder_path):
pixmap.load(placeholder_path)
if pixmap.isNull():
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
else:
pixmap = QPixmap(width, height)
pixmap.fill(QColor("#333333"))
@@ -168,9 +199,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
painter.end()
finish_with(pixmap)
with queue_lock:
image_load_queue.put(process_image)
image_executor.submit(lambda: image_load_queue.get()())
# Submit the process_image function directly to the executor
# This avoids the potential blocking issue with queue.get() in PySide 6.10.1
image_executor.submit(process_image)
def round_corners(pixmap, radius):
"""
@@ -178,7 +209,15 @@ def round_corners(pixmap, radius):
"""
if pixmap.isNull():
return pixmap
# Check if radius is valid to prevent issues
if radius <= 0:
return pixmap
size = pixmap.size()
if size.width() <= 0 or size.height() <= 0:
return pixmap
rounded = QPixmap(size)
rounded.fill(QColor(0, 0, 0, 0))
painter = QPainter(rounded)
@@ -281,6 +320,17 @@ class FullscreenDialog(QDialog):
QApplication.processEvents()
pixmap, caption = self.images[self.current_index]
# 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(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT)
placeholder_pixmap.fill(QColor("#333333"))
painter = QPainter(placeholder_pixmap)
painter.setPen(QPen(QColor("white")))
painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
painter.end()
self.imageLabel.setPixmap(placeholder_pixmap)
else:
# Учитываем devicePixelRatio для масштабирования высокого качества
device_pixel_ratio = get_device_pixel_ratio()
target_width = int((self.FIXED_WIDTH - 80) * device_pixel_ratio)

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
"POT-Creation-Date: 2025-11-30 13:20+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n"
@@ -76,10 +76,6 @@ msgstr ""
msgid "Legendary executable not found at {path}"
msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
msgid "Success"
msgstr ""
@@ -124,6 +120,10 @@ msgstr ""
msgid "Removed '{game_name}' from favorites"
msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
#, python-brace-format
msgid "Launch game \"{name}\" with PortProton"
msgstr ""
@@ -217,6 +217,10 @@ msgstr ""
msgid "Failed to copy cover image: {error}"
msgstr ""
#, python-brace-format
msgid "Unsupported image format: {extension}"
msgstr ""
#, python-brace-format
msgid "Failed to add '{game_name}' to Steam: {error}"
msgstr ""
@@ -279,6 +283,12 @@ msgstr ""
msgid "Next Tab"
msgstr ""
msgid "Save"
msgstr ""
msgid "Search"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
@@ -314,6 +324,9 @@ msgstr ""
msgid "Custom Cover:"
msgstr ""
msgid "Enter local path or URL for cover image"
msgstr ""
msgid "Cover Preview:"
msgstr ""
@@ -365,6 +378,45 @@ msgstr ""
msgid "Components installed successfully."
msgstr ""
msgid "Exe Settings"
msgstr ""
msgid "Search:"
msgstr ""
msgid "Search settings..."
msgstr ""
msgid "Main"
msgstr ""
msgid "Advanced"
msgstr ""
msgid "Setting"
msgstr ""
msgid "Value"
msgstr ""
msgid "Description"
msgstr ""
msgid "disabled"
msgstr ""
msgid "Info"
msgstr ""
msgid "No changes to apply."
msgstr ""
msgid "Failed to apply changes. Check logs."
msgstr ""
msgid "Settings updated successfully."
msgstr ""
msgid "Loading Epic Games Store games..."
msgstr ""
@@ -407,6 +459,9 @@ msgstr ""
msgid "Unknown Game"
msgstr ""
msgid "Starting PortProton..."
msgstr ""
msgid "Library"
msgstr ""
@@ -428,7 +483,7 @@ msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Search"
msgid "Refresh Grid"
msgstr ""
msgid "Installation already in progress."
@@ -450,6 +505,9 @@ msgstr ""
msgid "Installation error."
msgstr ""
msgid "Game library refreshed"
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -462,6 +520,15 @@ msgstr ""
msgid "Find Games ..."
msgstr ""
msgid "A refresh is already in progress..."
msgstr ""
msgid "Refreshing..."
msgstr ""
msgid "Refreshing game library..."
msgstr ""
#, python-brace-format
msgid "Added '{name}'"
msgstr ""
@@ -512,14 +579,21 @@ msgstr ""
msgid "Are you sure you want to clear prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgid "Clearing prefix..."
msgstr ""
msgid "Failed to start prefix clear process."
msgstr ""
msgid "Prefix cleared successfully."
msgstr ""
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgid "Prefix clear failed with exit code {}."
msgstr ""
#, python-brace-format
msgid "Failed to run clear prefix command: {}"
msgstr ""
msgid "Failed to start backup process."
@@ -704,6 +778,10 @@ msgstr ""
msgid "Error applying theme '{0}'"
msgstr ""
#, python-brace-format
msgid "Executable not found: {0}"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
@@ -762,6 +840,262 @@ msgstr ""
msgid "File not found: {0}"
msgstr ""
msgid ""
"Using FPS and system load monitoring (Turns on and off by the key "
"combination - right Shift + F12)"
msgstr ""
msgid "Forced use of MANGOHUD system settings (GOverlay, etc.)"
msgstr ""
msgid ""
"Enable vkBasalt by default to improve graphics in games running on "
"Vulkan. (The HOME hotkey disables vkbasalt)"
msgstr ""
msgid "Forced use of VKBASALT system settings (GOverlay, etc.)"
msgstr ""
msgid ""
"Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, "
"DirectDraw 1-7, Direct3D 2-9) on all 3D API."
msgstr ""
msgid ""
"Super + F : Toggle fullscreen\n"
"Super + N : Toggle nearest neighbour filtering\n"
"Super + U : Toggle FSR upscaling\n"
"Super + Y : Toggle NIS upscaling\n"
"Super + I : Increase FSR sharpness by 1\n"
"Super + O : Decrease FSR sharpness by 1\n"
"Super + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\n"
"Super + G : Toggle keyboard grab\n"
"Super + C : Update clipboard"
msgstr ""
msgid "Enable in-process synchronization primitives based on eventfd."
msgstr ""
msgid "Enable futex-based in-process synchronization primitives."
msgstr ""
msgid "Enable in-process synchronization via the Linux ntsync driver."
msgstr ""
msgid "Enable vkd3d support - Ray Tracing"
msgstr ""
msgid "Enable DLSS on supported NVIDIA graphics cards"
msgstr ""
msgid "Enable OptiScaler (replacement upscaler / frame generator)"
msgstr ""
msgid "Enable Lossless Scaling frame generation (experimental)"
msgstr ""
msgid "FSR upscaling in fullscreen with ProtonGE below native resolution"
msgstr ""
msgid "Disguise all NVIDIA GPU features"
msgstr ""
msgid "Run the application in WINE virtual desktop"
msgstr ""
msgid "Run the application in a terminal"
msgstr ""
msgid "Use system GameMode for performance optimization"
msgstr ""
msgid "Enable forced use of third-party DirectX libraries"
msgstr ""
msgid "Fix pink-tinted video playback in some games"
msgstr ""
msgid "Reduce PulseAudio latency to fix intermittent sound"
msgstr ""
msgid "Force US keyboard layout"
msgstr ""
msgid "Use GStreamer for in-game clips (WMF support)"
msgstr ""
msgid "Use WINE shader caching"
msgstr ""
msgid "Force use of built-in DXGI library"
msgstr ""
msgid "Enable Easy Anti-Cheat and BattlEye runtimes"
msgstr ""
msgid "Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"
msgstr ""
msgid "Enable OBS Studio capture via obs-vkcapture"
msgstr ""
msgid "Disable desktop compositing for performance"
msgstr ""
msgid "Use container launch mode (recommended default)"
msgstr ""
msgid "Force DirectInput protocol instead of XInput"
msgstr ""
msgid "Enable experimental native Wayland support"
msgstr ""
msgid "Enable HDR settings under native Wayland"
msgstr ""
msgid "Use Gallium Zink (OpenGL via Vulkan)"
msgstr ""
msgid "Use Gallium Nine (native DirectX 9 for Mesa)"
msgstr ""
msgid "Use WineD3D Vulkan backend (Damavand)"
msgstr ""
msgid "Use bundled dxvk/vkd3d from Wine/Proton"
msgstr ""
msgid "Use async dxvk-sarek (experimental)"
msgstr ""
msgid "Wine Version"
msgstr ""
msgid "Select the Wine or Proton version to use for this executable."
msgstr ""
msgid "Prefix Name"
msgstr ""
msgid "Specify the Wine prefix to run this game with"
msgstr ""
msgid "Newest"
msgstr ""
msgid "Stable"
msgstr ""
msgid "Vulkan Backend"
msgstr ""
msgid ""
"Select the DirectX → Vulkan/OpenGL backend:\n"
"\n"
"• Newest latest DXVK + VKD3D (best compatibility/performance, requires "
"modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
"• Stable older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
"driver)\n"
"• Sarek experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
"Vulkan 1.1+)\n"
"• WINED3D OpenGL fallback (lowest performance, use only if others fail)"
msgstr ""
msgid "Windows version"
msgstr ""
msgid ""
"Changing the WINDOWS emulation version may be required to run older "
"games. WINDOWS versions below 10 do not support new games with DirectX 12"
msgstr ""
msgid "DLL Overrides"
msgstr ""
msgid ""
"Forced to use/disable the library only for the given application.\n"
"\n"
"A brief instruction:\n"
"* libraries are written WITHOUT the .dll file extension\n"
"* libraries are separated by semicolons - ;\n"
"* library=n - use the WINDOWS (third-party) library\n"
"* library=b - use WINE (built-in) library\n"
"* library=n,b - use WINDOWS library and then WINE\n"
"* library=b,n - use WINE library and then WINDOWS\n"
"* library= - disable the use of this library\n"
"\n"
"Example: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
msgstr ""
msgid "Launch Arguments"
msgstr ""
msgid ""
"Adding an argument after the .exe file, just like you would add an "
"argument in a shortcut on a WINDOWS system.\n"
"\n"
"Example: -dx11 -skipintro 1"
msgstr ""
msgid "CPU Cores Limit"
msgstr ""
msgid ""
"Limiting the number of CPU cores is useful for Unity games (It is "
"recommended to set the value equal to 8)"
msgstr ""
msgid "OpenGL Version"
msgstr ""
msgid ""
"You can select the required OpenGL version, some games require a forced "
"Compatibility Profile (COMP)."
msgstr ""
msgid "VKD3D Feature Level"
msgstr ""
msgid "You can set a forced feature level VKD3D for games on DirectX12"
msgstr ""
msgid "Locale"
msgstr ""
msgid "Force certain locale for an app. Fixes encoding issues in legacy software"
msgstr ""
msgid "Window Mode"
msgstr ""
msgid ""
"Window mode (for Vulkan and OpenGL):\n"
"fifo - First in, first out. Limits the frame rate + no tearing. (VSync)\n"
"immediate - Unlimited frame rate + tearing.\n"
"mailbox - Triple buffering. Unlimited frame rate + no tearing.\n"
"relaxed - Same as fifo but allows tearing when below the monitors refresh"
" rate."
msgstr ""
msgid "AMD Vulkan Driver"
msgstr ""
msgid ""
"Select needed AMD vulkan implementation. Choosing which implementation of"
" vulkan will be used to run the game"
msgstr ""
msgid "NUMA Node"
msgstr ""
msgid ""
"NUMA node for CPU affinity. In multi-core systems, CPUs are split into "
"NUMA nodes, each with its own local memory and cores. Binding a game to a"
" single node reduces memory-access latency and limits costly core-to-core"
" switches."
msgstr ""
msgid "Reboot"
msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
"POT-Creation-Date: 2025-11-30 13:20+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n"
@@ -76,10 +76,6 @@ msgstr ""
msgid "Legendary executable not found at {path}"
msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
msgid "Success"
msgstr ""
@@ -124,6 +120,10 @@ msgstr ""
msgid "Removed '{game_name}' from favorites"
msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
#, python-brace-format
msgid "Launch game \"{name}\" with PortProton"
msgstr ""
@@ -217,6 +217,10 @@ msgstr ""
msgid "Failed to copy cover image: {error}"
msgstr ""
#, python-brace-format
msgid "Unsupported image format: {extension}"
msgstr ""
#, python-brace-format
msgid "Failed to add '{game_name}' to Steam: {error}"
msgstr ""
@@ -279,6 +283,12 @@ msgstr ""
msgid "Next Tab"
msgstr ""
msgid "Save"
msgstr ""
msgid "Search"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
@@ -314,6 +324,9 @@ msgstr ""
msgid "Custom Cover:"
msgstr ""
msgid "Enter local path or URL for cover image"
msgstr ""
msgid "Cover Preview:"
msgstr ""
@@ -365,6 +378,45 @@ msgstr ""
msgid "Components installed successfully."
msgstr ""
msgid "Exe Settings"
msgstr ""
msgid "Search:"
msgstr ""
msgid "Search settings..."
msgstr ""
msgid "Main"
msgstr ""
msgid "Advanced"
msgstr ""
msgid "Setting"
msgstr ""
msgid "Value"
msgstr ""
msgid "Description"
msgstr ""
msgid "disabled"
msgstr ""
msgid "Info"
msgstr ""
msgid "No changes to apply."
msgstr ""
msgid "Failed to apply changes. Check logs."
msgstr ""
msgid "Settings updated successfully."
msgstr ""
msgid "Loading Epic Games Store games..."
msgstr ""
@@ -407,6 +459,9 @@ msgstr ""
msgid "Unknown Game"
msgstr ""
msgid "Starting PortProton..."
msgstr ""
msgid "Library"
msgstr ""
@@ -428,7 +483,7 @@ msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Search"
msgid "Refresh Grid"
msgstr ""
msgid "Installation already in progress."
@@ -450,6 +505,9 @@ msgstr ""
msgid "Installation error."
msgstr ""
msgid "Game library refreshed"
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -462,6 +520,15 @@ msgstr ""
msgid "Find Games ..."
msgstr ""
msgid "A refresh is already in progress..."
msgstr ""
msgid "Refreshing..."
msgstr ""
msgid "Refreshing game library..."
msgstr ""
#, python-brace-format
msgid "Added '{name}'"
msgstr ""
@@ -512,14 +579,21 @@ msgstr ""
msgid "Are you sure you want to clear prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgid "Clearing prefix..."
msgstr ""
msgid "Failed to start prefix clear process."
msgstr ""
msgid "Prefix cleared successfully."
msgstr ""
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgid "Prefix clear failed with exit code {}."
msgstr ""
#, python-brace-format
msgid "Failed to run clear prefix command: {}"
msgstr ""
msgid "Failed to start backup process."
@@ -704,6 +778,10 @@ msgstr ""
msgid "Error applying theme '{0}'"
msgstr ""
#, python-brace-format
msgid "Executable not found: {0}"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
@@ -762,6 +840,262 @@ msgstr ""
msgid "File not found: {0}"
msgstr ""
msgid ""
"Using FPS and system load monitoring (Turns on and off by the key "
"combination - right Shift + F12)"
msgstr ""
msgid "Forced use of MANGOHUD system settings (GOverlay, etc.)"
msgstr ""
msgid ""
"Enable vkBasalt by default to improve graphics in games running on "
"Vulkan. (The HOME hotkey disables vkbasalt)"
msgstr ""
msgid "Forced use of VKBASALT system settings (GOverlay, etc.)"
msgstr ""
msgid ""
"Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, "
"DirectDraw 1-7, Direct3D 2-9) on all 3D API."
msgstr ""
msgid ""
"Super + F : Toggle fullscreen\n"
"Super + N : Toggle nearest neighbour filtering\n"
"Super + U : Toggle FSR upscaling\n"
"Super + Y : Toggle NIS upscaling\n"
"Super + I : Increase FSR sharpness by 1\n"
"Super + O : Decrease FSR sharpness by 1\n"
"Super + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\n"
"Super + G : Toggle keyboard grab\n"
"Super + C : Update clipboard"
msgstr ""
msgid "Enable in-process synchronization primitives based on eventfd."
msgstr ""
msgid "Enable futex-based in-process synchronization primitives."
msgstr ""
msgid "Enable in-process synchronization via the Linux ntsync driver."
msgstr ""
msgid "Enable vkd3d support - Ray Tracing"
msgstr ""
msgid "Enable DLSS on supported NVIDIA graphics cards"
msgstr ""
msgid "Enable OptiScaler (replacement upscaler / frame generator)"
msgstr ""
msgid "Enable Lossless Scaling frame generation (experimental)"
msgstr ""
msgid "FSR upscaling in fullscreen with ProtonGE below native resolution"
msgstr ""
msgid "Disguise all NVIDIA GPU features"
msgstr ""
msgid "Run the application in WINE virtual desktop"
msgstr ""
msgid "Run the application in a terminal"
msgstr ""
msgid "Use system GameMode for performance optimization"
msgstr ""
msgid "Enable forced use of third-party DirectX libraries"
msgstr ""
msgid "Fix pink-tinted video playback in some games"
msgstr ""
msgid "Reduce PulseAudio latency to fix intermittent sound"
msgstr ""
msgid "Force US keyboard layout"
msgstr ""
msgid "Use GStreamer for in-game clips (WMF support)"
msgstr ""
msgid "Use WINE shader caching"
msgstr ""
msgid "Force use of built-in DXGI library"
msgstr ""
msgid "Enable Easy Anti-Cheat and BattlEye runtimes"
msgstr ""
msgid "Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"
msgstr ""
msgid "Enable OBS Studio capture via obs-vkcapture"
msgstr ""
msgid "Disable desktop compositing for performance"
msgstr ""
msgid "Use container launch mode (recommended default)"
msgstr ""
msgid "Force DirectInput protocol instead of XInput"
msgstr ""
msgid "Enable experimental native Wayland support"
msgstr ""
msgid "Enable HDR settings under native Wayland"
msgstr ""
msgid "Use Gallium Zink (OpenGL via Vulkan)"
msgstr ""
msgid "Use Gallium Nine (native DirectX 9 for Mesa)"
msgstr ""
msgid "Use WineD3D Vulkan backend (Damavand)"
msgstr ""
msgid "Use bundled dxvk/vkd3d from Wine/Proton"
msgstr ""
msgid "Use async dxvk-sarek (experimental)"
msgstr ""
msgid "Wine Version"
msgstr ""
msgid "Select the Wine or Proton version to use for this executable."
msgstr ""
msgid "Prefix Name"
msgstr ""
msgid "Specify the Wine prefix to run this game with"
msgstr ""
msgid "Newest"
msgstr ""
msgid "Stable"
msgstr ""
msgid "Vulkan Backend"
msgstr ""
msgid ""
"Select the DirectX → Vulkan/OpenGL backend:\n"
"\n"
"• Newest latest DXVK + VKD3D (best compatibility/performance, requires "
"modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
"• Stable older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
"driver)\n"
"• Sarek experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
"Vulkan 1.1+)\n"
"• WINED3D OpenGL fallback (lowest performance, use only if others fail)"
msgstr ""
msgid "Windows version"
msgstr ""
msgid ""
"Changing the WINDOWS emulation version may be required to run older "
"games. WINDOWS versions below 10 do not support new games with DirectX 12"
msgstr ""
msgid "DLL Overrides"
msgstr ""
msgid ""
"Forced to use/disable the library only for the given application.\n"
"\n"
"A brief instruction:\n"
"* libraries are written WITHOUT the .dll file extension\n"
"* libraries are separated by semicolons - ;\n"
"* library=n - use the WINDOWS (third-party) library\n"
"* library=b - use WINE (built-in) library\n"
"* library=n,b - use WINDOWS library and then WINE\n"
"* library=b,n - use WINE library and then WINDOWS\n"
"* library= - disable the use of this library\n"
"\n"
"Example: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
msgstr ""
msgid "Launch Arguments"
msgstr ""
msgid ""
"Adding an argument after the .exe file, just like you would add an "
"argument in a shortcut on a WINDOWS system.\n"
"\n"
"Example: -dx11 -skipintro 1"
msgstr ""
msgid "CPU Cores Limit"
msgstr ""
msgid ""
"Limiting the number of CPU cores is useful for Unity games (It is "
"recommended to set the value equal to 8)"
msgstr ""
msgid "OpenGL Version"
msgstr ""
msgid ""
"You can select the required OpenGL version, some games require a forced "
"Compatibility Profile (COMP)."
msgstr ""
msgid "VKD3D Feature Level"
msgstr ""
msgid "You can set a forced feature level VKD3D for games on DirectX12"
msgstr ""
msgid "Locale"
msgstr ""
msgid "Force certain locale for an app. Fixes encoding issues in legacy software"
msgstr ""
msgid "Window Mode"
msgstr ""
msgid ""
"Window mode (for Vulkan and OpenGL):\n"
"fifo - First in, first out. Limits the frame rate + no tearing. (VSync)\n"
"immediate - Unlimited frame rate + tearing.\n"
"mailbox - Triple buffering. Unlimited frame rate + no tearing.\n"
"relaxed - Same as fifo but allows tearing when below the monitors refresh"
" rate."
msgstr ""
msgid "AMD Vulkan Driver"
msgstr ""
msgid ""
"Select needed AMD vulkan implementation. Choosing which implementation of"
" vulkan will be used to run the game"
msgstr ""
msgid "NUMA Node"
msgstr ""
msgid ""
"NUMA node for CPU affinity. In multi-core systems, CPUs are split into "
"NUMA nodes, each with its own local memory and cores. Binding a game to a"
" single node reduces memory-access latency and limits costly core-to-core"
" switches."
msgstr ""
msgid "Reboot"
msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
"POT-Creation-Date: 2025-11-30 13:20+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -74,10 +74,6 @@ msgstr ""
msgid "Legendary executable not found at {path}"
msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
msgid "Success"
msgstr ""
@@ -122,6 +118,10 @@ msgstr ""
msgid "Removed '{game_name}' from favorites"
msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
#, python-brace-format
msgid "Launch game \"{name}\" with PortProton"
msgstr ""
@@ -215,6 +215,10 @@ msgstr ""
msgid "Failed to copy cover image: {error}"
msgstr ""
#, python-brace-format
msgid "Unsupported image format: {extension}"
msgstr ""
#, python-brace-format
msgid "Failed to add '{game_name}' to Steam: {error}"
msgstr ""
@@ -277,6 +281,12 @@ msgstr ""
msgid "Next Tab"
msgstr ""
msgid "Save"
msgstr ""
msgid "Search"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
@@ -312,6 +322,9 @@ msgstr ""
msgid "Custom Cover:"
msgstr ""
msgid "Enter local path or URL for cover image"
msgstr ""
msgid "Cover Preview:"
msgstr ""
@@ -363,6 +376,45 @@ msgstr ""
msgid "Components installed successfully."
msgstr ""
msgid "Exe Settings"
msgstr ""
msgid "Search:"
msgstr ""
msgid "Search settings..."
msgstr ""
msgid "Main"
msgstr ""
msgid "Advanced"
msgstr ""
msgid "Setting"
msgstr ""
msgid "Value"
msgstr ""
msgid "Description"
msgstr ""
msgid "disabled"
msgstr ""
msgid "Info"
msgstr ""
msgid "No changes to apply."
msgstr ""
msgid "Failed to apply changes. Check logs."
msgstr ""
msgid "Settings updated successfully."
msgstr ""
msgid "Loading Epic Games Store games..."
msgstr ""
@@ -405,6 +457,9 @@ msgstr ""
msgid "Unknown Game"
msgstr ""
msgid "Starting PortProton..."
msgstr ""
msgid "Library"
msgstr ""
@@ -426,7 +481,7 @@ msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Search"
msgid "Refresh Grid"
msgstr ""
msgid "Installation already in progress."
@@ -448,6 +503,9 @@ msgstr ""
msgid "Installation error."
msgstr ""
msgid "Game library refreshed"
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -460,6 +518,15 @@ msgstr ""
msgid "Find Games ..."
msgstr ""
msgid "A refresh is already in progress..."
msgstr ""
msgid "Refreshing..."
msgstr ""
msgid "Refreshing game library..."
msgstr ""
#, python-brace-format
msgid "Added '{name}'"
msgstr ""
@@ -510,14 +577,21 @@ msgstr ""
msgid "Are you sure you want to clear prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgid "Clearing prefix..."
msgstr ""
msgid "Failed to start prefix clear process."
msgstr ""
msgid "Prefix cleared successfully."
msgstr ""
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgid "Prefix clear failed with exit code {}."
msgstr ""
#, python-brace-format
msgid "Failed to run clear prefix command: {}"
msgstr ""
msgid "Failed to start backup process."
@@ -702,6 +776,10 @@ msgstr ""
msgid "Error applying theme '{0}'"
msgstr ""
#, python-brace-format
msgid "Executable not found: {0}"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
@@ -760,6 +838,262 @@ msgstr ""
msgid "File not found: {0}"
msgstr ""
msgid ""
"Using FPS and system load monitoring (Turns on and off by the key "
"combination - right Shift + F12)"
msgstr ""
msgid "Forced use of MANGOHUD system settings (GOverlay, etc.)"
msgstr ""
msgid ""
"Enable vkBasalt by default to improve graphics in games running on "
"Vulkan. (The HOME hotkey disables vkbasalt)"
msgstr ""
msgid "Forced use of VKBASALT system settings (GOverlay, etc.)"
msgstr ""
msgid ""
"Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, "
"DirectDraw 1-7, Direct3D 2-9) on all 3D API."
msgstr ""
msgid ""
"Super + F : Toggle fullscreen\n"
"Super + N : Toggle nearest neighbour filtering\n"
"Super + U : Toggle FSR upscaling\n"
"Super + Y : Toggle NIS upscaling\n"
"Super + I : Increase FSR sharpness by 1\n"
"Super + O : Decrease FSR sharpness by 1\n"
"Super + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\n"
"Super + G : Toggle keyboard grab\n"
"Super + C : Update clipboard"
msgstr ""
msgid "Enable in-process synchronization primitives based on eventfd."
msgstr ""
msgid "Enable futex-based in-process synchronization primitives."
msgstr ""
msgid "Enable in-process synchronization via the Linux ntsync driver."
msgstr ""
msgid "Enable vkd3d support - Ray Tracing"
msgstr ""
msgid "Enable DLSS on supported NVIDIA graphics cards"
msgstr ""
msgid "Enable OptiScaler (replacement upscaler / frame generator)"
msgstr ""
msgid "Enable Lossless Scaling frame generation (experimental)"
msgstr ""
msgid "FSR upscaling in fullscreen with ProtonGE below native resolution"
msgstr ""
msgid "Disguise all NVIDIA GPU features"
msgstr ""
msgid "Run the application in WINE virtual desktop"
msgstr ""
msgid "Run the application in a terminal"
msgstr ""
msgid "Use system GameMode for performance optimization"
msgstr ""
msgid "Enable forced use of third-party DirectX libraries"
msgstr ""
msgid "Fix pink-tinted video playback in some games"
msgstr ""
msgid "Reduce PulseAudio latency to fix intermittent sound"
msgstr ""
msgid "Force US keyboard layout"
msgstr ""
msgid "Use GStreamer for in-game clips (WMF support)"
msgstr ""
msgid "Use WINE shader caching"
msgstr ""
msgid "Force use of built-in DXGI library"
msgstr ""
msgid "Enable Easy Anti-Cheat and BattlEye runtimes"
msgstr ""
msgid "Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"
msgstr ""
msgid "Enable OBS Studio capture via obs-vkcapture"
msgstr ""
msgid "Disable desktop compositing for performance"
msgstr ""
msgid "Use container launch mode (recommended default)"
msgstr ""
msgid "Force DirectInput protocol instead of XInput"
msgstr ""
msgid "Enable experimental native Wayland support"
msgstr ""
msgid "Enable HDR settings under native Wayland"
msgstr ""
msgid "Use Gallium Zink (OpenGL via Vulkan)"
msgstr ""
msgid "Use Gallium Nine (native DirectX 9 for Mesa)"
msgstr ""
msgid "Use WineD3D Vulkan backend (Damavand)"
msgstr ""
msgid "Use bundled dxvk/vkd3d from Wine/Proton"
msgstr ""
msgid "Use async dxvk-sarek (experimental)"
msgstr ""
msgid "Wine Version"
msgstr ""
msgid "Select the Wine or Proton version to use for this executable."
msgstr ""
msgid "Prefix Name"
msgstr ""
msgid "Specify the Wine prefix to run this game with"
msgstr ""
msgid "Newest"
msgstr ""
msgid "Stable"
msgstr ""
msgid "Vulkan Backend"
msgstr ""
msgid ""
"Select the DirectX → Vulkan/OpenGL backend:\n"
"\n"
"• Newest latest DXVK + VKD3D (best compatibility/performance, requires "
"modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
"• Stable older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
"driver)\n"
"• Sarek experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
"Vulkan 1.1+)\n"
"• WINED3D OpenGL fallback (lowest performance, use only if others fail)"
msgstr ""
msgid "Windows version"
msgstr ""
msgid ""
"Changing the WINDOWS emulation version may be required to run older "
"games. WINDOWS versions below 10 do not support new games with DirectX 12"
msgstr ""
msgid "DLL Overrides"
msgstr ""
msgid ""
"Forced to use/disable the library only for the given application.\n"
"\n"
"A brief instruction:\n"
"* libraries are written WITHOUT the .dll file extension\n"
"* libraries are separated by semicolons - ;\n"
"* library=n - use the WINDOWS (third-party) library\n"
"* library=b - use WINE (built-in) library\n"
"* library=n,b - use WINDOWS library and then WINE\n"
"* library=b,n - use WINE library and then WINDOWS\n"
"* library= - disable the use of this library\n"
"\n"
"Example: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
msgstr ""
msgid "Launch Arguments"
msgstr ""
msgid ""
"Adding an argument after the .exe file, just like you would add an "
"argument in a shortcut on a WINDOWS system.\n"
"\n"
"Example: -dx11 -skipintro 1"
msgstr ""
msgid "CPU Cores Limit"
msgstr ""
msgid ""
"Limiting the number of CPU cores is useful for Unity games (It is "
"recommended to set the value equal to 8)"
msgstr ""
msgid "OpenGL Version"
msgstr ""
msgid ""
"You can select the required OpenGL version, some games require a forced "
"Compatibility Profile (COMP)."
msgstr ""
msgid "VKD3D Feature Level"
msgstr ""
msgid "You can set a forced feature level VKD3D for games on DirectX12"
msgstr ""
msgid "Locale"
msgstr ""
msgid "Force certain locale for an app. Fixes encoding issues in legacy software"
msgstr ""
msgid "Window Mode"
msgstr ""
msgid ""
"Window mode (for Vulkan and OpenGL):\n"
"fifo - First in, first out. Limits the frame rate + no tearing. (VSync)\n"
"immediate - Unlimited frame rate + tearing.\n"
"mailbox - Triple buffering. Unlimited frame rate + no tearing.\n"
"relaxed - Same as fifo but allows tearing when below the monitors refresh"
" rate."
msgstr ""
msgid "AMD Vulkan Driver"
msgstr ""
msgid ""
"Select needed AMD vulkan implementation. Choosing which implementation of"
" vulkan will be used to run the game"
msgstr ""
msgid "NUMA Node"
msgstr ""
msgid ""
"NUMA node for CPU affinity. In multi-core systems, CPUs are split into "
"NUMA nodes, each with its own local memory and cores. Binding a game to a"
" single node reduces memory-access latency and limits costly core-to-core"
" switches."
msgstr ""
msgid "Reboot"
msgstr ""

View File

@@ -9,8 +9,8 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
"PO-Revision-Date: 2025-10-16 14:54+0500\n"
"POT-Creation-Date: 2025-11-30 13:20+0500\n"
"PO-Revision-Date: 2025-11-30 13:18+0500\n"
"Last-Translator: \n"
"Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n"
@@ -77,10 +77,6 @@ msgstr "Остановлен(а) '{game_name}'"
msgid "Legendary executable not found at {path}"
msgstr "Legendary не найден по пути {path}"
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr "start.sh не найден по адресу {path}"
msgid "Success"
msgstr "Успешно"
@@ -127,6 +123,10 @@ msgstr "'{game_name}' был(а) добавлен(а) в избранное"
msgid "Removed '{game_name}' from favorites"
msgstr "'{game_name}' был(а) удалён(а) из избранного"
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr "start.sh не найден по адресу {path}"
#, python-brace-format
msgid "Launch game \"{name}\" with PortProton"
msgstr "Запустить игру \"{name}\" с помощью PortProton"
@@ -222,6 +222,10 @@ msgstr "Не удалось сохранить файл .desktop: {error}"
msgid "Failed to copy cover image: {error}"
msgstr "Не удалось скопировать обложку: {error}"
#, python-brace-format
msgid "Unsupported image format: {extension}"
msgstr "Неподдерживаемый формат изображения: {extension}"
#, python-brace-format
msgid "Failed to add '{game_name}' to Steam: {error}"
msgstr "Не удалось добавить '{game_name}' в Steam: {error}"
@@ -286,6 +290,12 @@ msgstr "Предыдущая вкладка"
msgid "Next Tab"
msgstr "Следующая вкладка"
msgid "Save"
msgstr "Сохранить"
msgid "Search"
msgstr "Поиск"
#, python-brace-format
msgid "Launching {0}"
msgstr "Идёт запуск {0}"
@@ -321,6 +331,9 @@ msgstr "Обзор..."
msgid "Custom Cover:"
msgstr "Обложка:"
msgid "Enter local path or URL for cover image"
msgstr "Введите локальный путь или URL обложки"
msgid "Cover Preview:"
msgstr "Предпросмотр обложки:"
@@ -372,6 +385,45 @@ msgstr "Установка не удалась. Проверьте журнал
msgid "Components installed successfully."
msgstr "Компоненты успешно установлены."
msgid "Exe Settings"
msgstr "Настройки EXE"
msgid "Search:"
msgstr "Поиск:"
msgid "Search settings..."
msgstr "Поиск настроек..."
msgid "Main"
msgstr "Основные"
msgid "Advanced"
msgstr "Расширенные"
msgid "Setting"
msgstr "Параметр"
msgid "Value"
msgstr "Значение"
msgid "Description"
msgstr "Описание"
msgid "disabled"
msgstr "отключено"
msgid "Info"
msgstr "Информация"
msgid "No changes to apply."
msgstr "Изменений для применения нет."
msgid "Failed to apply changes. Check logs."
msgstr "Не удалось применить изменения. Проверьте логи."
msgid "Settings updated successfully."
msgstr "Настройки успешно обновлены."
msgid "Loading Epic Games Store games..."
msgstr "Загрузка игр из Epic Games Store..."
@@ -414,6 +466,9 @@ msgstr "В ожидании"
msgid "Unknown Game"
msgstr "Неизвестная игра"
msgid "Starting PortProton..."
msgstr "Инициализация PortProton"
msgid "Library"
msgstr "Библиотека"
@@ -435,8 +490,8 @@ msgstr "Назад"
msgid "Fullscreen"
msgstr "Полный экран"
msgid "Search"
msgstr "Поиск"
msgid "Refresh Grid"
msgstr "Обновить"
msgid "Installation already in progress."
msgstr "Установка уже выполняется."
@@ -457,6 +512,9 @@ msgstr "Установка не удалась."
msgid "Installation error."
msgstr "Ошибка установки."
msgid "Game library refreshed"
msgstr "Игровая библиотека обновлена"
msgid "Loading Steam games..."
msgstr "Загрузка игр из Steam..."
@@ -469,6 +527,15 @@ msgstr "Игровая библиотека"
msgid "Find Games ..."
msgstr "Найти игры..."
msgid "A refresh is already in progress..."
msgstr "Обновление уже выполняется..."
msgid "Refreshing..."
msgstr "Обновление..."
msgid "Refreshing game library..."
msgstr "Обновление игровой библиотеки..."
#, python-brace-format
msgid "Added '{name}'"
msgstr "'{name}' добавлен(а)"
@@ -519,17 +586,22 @@ msgstr "Подтвердите очистку"
msgid "Are you sure you want to clear prefix '{}'?"
msgstr "Вы уверены, что хотите очистить префикс «{}»?"
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgstr "Префикс '{}' успешно удален."
msgid "Clearing prefix..."
msgstr "Очистка префикса..."
msgid "Failed to start prefix clear process."
msgstr "Не удалось запустить процесс очистки префикса."
msgid "Prefix cleared successfully."
msgstr "Префикс удален успешно."
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgstr ""
"Префикс '{}' очищен с ошибками:\n"
"{}"
msgid "Prefix clear failed with exit code {}."
msgstr "Очистка префикса завершилась с кодом завершения {}."
#, python-brace-format
msgid "Failed to run clear prefix command: {}"
msgstr "Не удалось выполнить команду очистки префикса: {}"
msgid "Failed to start backup process."
msgstr "Не удалось запустить процесс резервного копирования."
@@ -715,6 +787,10 @@ msgstr "Тема '{0}' применена успешно"
msgid "Error applying theme '{0}'"
msgstr "Ошибка при применение темы '{0}'"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "Исполняемый файл не найден: {0}"
msgid "LAST LAUNCH"
msgstr "Последний запуск"
@@ -773,6 +849,329 @@ msgstr "Неправильный формат команды (flatpak)"
msgid "File not found: {0}"
msgstr "Файл не найден: {0}"
msgid ""
"Using FPS and system load monitoring (Turns on and off by the key "
"combination - right Shift + F12)"
msgstr ""
"Использование мониторинга FPS и нагрузки системы (включается и "
"выключается комбинацией клавиш - правая Shift + F12)"
msgid "Forced use of MANGOHUD system settings (GOverlay, etc.)"
msgstr "Принудительное использование системных настроек MANGOHUD (GOverlay и т.д.)"
msgid ""
"Enable vkBasalt by default to improve graphics in games running on "
"Vulkan. (The HOME hotkey disables vkbasalt)"
msgstr ""
"Включить vkBasalt по умолчанию для улучшения графики в играх на Vulkan. "
"(Горячая клавиша HOME отключает vkbasalt)"
msgid "Forced use of VKBASALT system settings (GOverlay, etc.)"
msgstr "Принудительное использование системных настроек VKBASALT (GOverlay и т.д.)"
msgid ""
"Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, "
"DirectDraw 1-7, Direct3D 2-9) on all 3D API."
msgstr ""
"Включить dgVoodoo2. Принудительное использование всех библиотек dgVoodoo2"
" (Glide 2.11-3.1, DirectDraw 1-7, Direct3D 2-9) на всех 3D API."
msgid ""
"Super + F : Toggle fullscreen\n"
"Super + N : Toggle nearest neighbour filtering\n"
"Super + U : Toggle FSR upscaling\n"
"Super + Y : Toggle NIS upscaling\n"
"Super + I : Increase FSR sharpness by 1\n"
"Super + O : Decrease FSR sharpness by 1\n"
"Super + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\n"
"Super + G : Toggle keyboard grab\n"
"Super + C : Update clipboard"
msgstr ""
"Super + F: Переключить полноэкранный режим\n"
"Super + N: Переключить фильтрацию ближайшего соседа\n"
"Super + U: Переключить апскейлинг FSR\n"
"Super + Y: Переключить апскейлинг NIS\n"
"Super + I: Увеличить резкость FSR на 1\n"
"Super + O: Уменьшить резкость FSR на 1\n"
"Super + S: Сделать скриншот (сейчас сохраняется в "
"/tmp/gamescope_DATE.png)\n"
"Super + G: Переключить захват клавиатуры\n"
"Super + C: Обновить буфер обмена"
msgid "Enable in-process synchronization primitives based on eventfd."
msgstr "Включить примитивы синхронизации в процессе на основе eventfd."
msgid "Enable futex-based in-process synchronization primitives."
msgstr "Включить примитивы синхронизации в процессе на основе futex."
msgid "Enable in-process synchronization via the Linux ntsync driver."
msgstr "Включить синхронизацию в процессе через драйвер ntsync в Linux."
msgid "Enable vkd3d support - Ray Tracing"
msgstr "Включить поддержку vkd3d — трассировка лучей"
msgid "Enable DLSS on supported NVIDIA graphics cards"
msgstr "Включить DLSS на поддерживаемых видеокартах NVIDIA"
msgid "Enable OptiScaler (replacement upscaler / frame generator)"
msgstr "Включить OptiScaler (замена апскейлера / генератора кадров)"
msgid "Enable Lossless Scaling frame generation (experimental)"
msgstr "Включить генерацию кадров Lossless Scaling (экспериментально)"
msgid "FSR upscaling in fullscreen with ProtonGE below native resolution"
msgstr "Апскейлинг FSR в полноэкранном режиме с ProtonGE ниже родного разрешения"
msgid "Disguise all NVIDIA GPU features"
msgstr "Маскировать все функции GPU NVIDIA"
msgid "Run the application in WINE virtual desktop"
msgstr "Запускать приложение в виртуальном рабочем столе WINE"
msgid "Run the application in a terminal"
msgstr "Запускать приложение в терминале"
msgid "Use system GameMode for performance optimization"
msgstr "Использовать системный GameMode для оптимизации производительности"
msgid "Enable forced use of third-party DirectX libraries"
msgstr "Включить принудительное использование сторонних библиотек DirectX"
msgid "Fix pink-tinted video playback in some games"
msgstr "Исправить розовый оттенок видео в некоторых играх"
msgid "Reduce PulseAudio latency to fix intermittent sound"
msgstr "Уменьшить задержку PulseAudio для исправления прерывистого звука"
msgid "Force US keyboard layout"
msgstr "Принудительно использовать раскладку клавиатуры US"
msgid "Use GStreamer for in-game clips (WMF support)"
msgstr "Использовать GStreamer для внутриигровых клипов (поддержка WMF)"
msgid "Use WINE shader caching"
msgstr "Использовать кэширование шейдеров WINE"
msgid "Force use of built-in DXGI library"
msgstr "Принудительно использовать встроенную библиотеку DXGI"
msgid "Enable Easy Anti-Cheat and BattlEye runtimes"
msgstr "Включить среды выполнения Easy Anti-Cheat и BattlEye"
msgid "Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"
msgstr "Использовать системные слои Vulkan (MangoHud, vkBasalt, OBS и т.д.)"
msgid "Enable OBS Studio capture via obs-vkcapture"
msgstr "Включить захват OBS Studio через obs-vkcapture"
msgid "Disable desktop compositing for performance"
msgstr "Отключить композицию рабочего стола для производительности"
msgid "Use container launch mode (recommended default)"
msgstr "Использовать режим запуска в контейнере (рекомендуемый по умолчанию)"
msgid "Force DirectInput protocol instead of XInput"
msgstr "Принудительно использовать протокол DirectInput вместо XInput"
msgid "Enable experimental native Wayland support"
msgstr "Включить экспериментальную нативную поддержку Wayland"
msgid "Enable HDR settings under native Wayland"
msgstr "Включить настройки HDR под нативным Wayland"
msgid "Use Gallium Zink (OpenGL via Vulkan)"
msgstr "Использовать Gallium Zink (OpenGL через Vulkan)"
msgid "Use Gallium Nine (native DirectX 9 for Mesa)"
msgstr "Использовать Gallium Nine (нативный DirectX 9 для Mesa)"
msgid "Use WineD3D Vulkan backend (Damavand)"
msgstr "Использовать бэкенд Vulkan WineD3D (Damavand)"
msgid "Use bundled dxvk/vkd3d from Wine/Proton"
msgstr "Использовать встроенные dxvk/vkd3d из Wine/Proton"
msgid "Use async dxvk-sarek (experimental)"
msgstr "Использовать асинхронный dxvk-sarek (экспериментально)"
msgid "Wine Version"
msgstr "Версия Wine"
msgid "Select the Wine or Proton version to use for this executable."
msgstr "Выбор версии Wine или Proton для использования с этим исполняемым файлом."
msgid "Prefix Name"
msgstr "Имя префикса"
msgid "Specify the Wine prefix to run this game with"
msgstr "Укажите префикс Wine для запуска этой игры"
msgid "Newest"
msgstr "Новейший"
msgid "Stable"
msgstr "Стабильный"
msgid "Vulkan Backend"
msgstr "Vulkan рендеринг"
msgid ""
"Select the DirectX → Vulkan/OpenGL backend:\n"
"\n"
"• Newest latest DXVK + VKD3D (best compatibility/performance, requires "
"modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
"• Stable older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
"driver)\n"
"• Sarek experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
"Vulkan 1.1+)\n"
"• WINED3D OpenGL fallback (lowest performance, use only if others fail)"
msgstr ""
"Выберите бэкэнд DirectX → Vulkan/OpenGL:\n"
"\n"
"• Новейший — последние версии DXVK + VKD3D (наилучшая "
"совместимость/производительность, требует современных драйверов: AMD Mesa"
" 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
"• Стабильный — более старая, хорошо протестированная версия DXVK + VKD3D "
"(работает с любыми драйверами Vulkan 1.3+)\n"
"• Sarek — экспериментальная версия DXVK-Sarek + VKD3D-Sarek (поддерживает"
" более старые драйверы, Vulkan 1.1+)\n"
"• WINED3D — резервный вариант OpenGL (наименьшая производительность, "
"используйте только в случае сбоя других вариантов)"
msgid "Windows version"
msgstr "Версия Windows"
msgid ""
"Changing the WINDOWS emulation version may be required to run older "
"games. WINDOWS versions below 10 do not support new games with DirectX 12"
msgstr ""
"Изменение версии эмуляции WINDOWS может потребоваться для запуска старых "
"игр. Версии WINDOWS ниже 10 не поддерживают новые игры с DirectX 12"
msgid "DLL Overrides"
msgstr "Переопределения DLL"
msgid ""
"Forced to use/disable the library only for the given application.\n"
"\n"
"A brief instruction:\n"
"* libraries are written WITHOUT the .dll file extension\n"
"* libraries are separated by semicolons - ;\n"
"* library=n - use the WINDOWS (third-party) library\n"
"* library=b - use WINE (built-in) library\n"
"* library=n,b - use WINDOWS library and then WINE\n"
"* library=b,n - use WINE library and then WINDOWS\n"
"* library= - disable the use of this library\n"
"\n"
"Example: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
msgstr ""
"Принудительное использование/отключение библиотеки только для данного "
"приложения.\n"
"\n"
"Краткая инструкция:\n"
"* библиотеки пишутся БЕЗ расширения .dll\n"
"* библиотеки разделяются точкой с запятой - ;\n"
"* library=n — использовать библиотеку WINDOWS (стороннюю)\n"
"* library=b — использовать библиотеку WINE (встроенную)\n"
"* library=n,b — использовать библиотеку WINDOWS, затем WINE\n"
"* library=b,n — использовать библиотеку WINE, затем WINDOWS\n"
"* library= — отключить использование этой библиотеки\n"
"\n"
"Пример: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
msgid "Launch Arguments"
msgstr "Аргументы запуска"
msgid ""
"Adding an argument after the .exe file, just like you would add an "
"argument in a shortcut on a WINDOWS system.\n"
"\n"
"Example: -dx11 -skipintro 1"
msgstr ""
"Добавление аргумента после файла .exe, как вы бы добавили аргумент в "
"ярлыке на системе WINDOWS.\n"
"\n"
"Пример: -dx11 -skipintro 1"
msgid "CPU Cores Limit"
msgstr "Ограничение ядер CPU"
msgid ""
"Limiting the number of CPU cores is useful for Unity games (It is "
"recommended to set the value equal to 8)"
msgstr ""
"Ограничение количества ядер CPU полезно для игр на Unity (рекомендуется "
"установить значение равным 8)"
msgid "OpenGL Version"
msgstr "Версия OpenGL"
msgid ""
"You can select the required OpenGL version, some games require a forced "
"Compatibility Profile (COMP)."
msgstr ""
"Вы можете выбрать требуемую версию OpenGL, некоторые игры требуют "
"принудительного профиля совместимости (COMP)."
msgid "VKD3D Feature Level"
msgstr "Уровень возможностей VKD3D"
msgid "You can set a forced feature level VKD3D for games on DirectX12"
msgstr ""
"Вы можете установить принудительный уровень возможностей VKD3D для игр на"
" DirectX12"
msgid "Locale"
msgstr "Локаль"
msgid "Force certain locale for an app. Fixes encoding issues in legacy software"
msgstr ""
"Принудительно установить определённую локаль для приложения. Исправляет "
"проблемы с кодировкой в устаревшем ПО"
msgid "Window Mode"
msgstr "Режим окна"
msgid ""
"Window mode (for Vulkan and OpenGL):\n"
"fifo - First in, first out. Limits the frame rate + no tearing. (VSync)\n"
"immediate - Unlimited frame rate + tearing.\n"
"mailbox - Triple buffering. Unlimited frame rate + no tearing.\n"
"relaxed - Same as fifo but allows tearing when below the monitors refresh"
" rate."
msgstr ""
"Режим окна (для Vulkan и OpenGL):\n"
"fifo — Первый вошёл, первый вышел. Ограничивает частоту кадров + без "
"разрывов. (VSync)\n"
"immediate — Неограниченная частота кадров + разрывы.\n"
"mailbox — Трёхбуферная. Неограниченная частота кадров + без разрывов.\n"
"relaxed — То же, что fifo, но позволяет разрывы при частоте ниже частоты "
"обновления монитора."
msgid "AMD Vulkan Driver"
msgstr "Драйвер Vulkan AMD"
msgid ""
"Select needed AMD vulkan implementation. Choosing which implementation of"
" vulkan will be used to run the game"
msgstr ""
"Выберите нужную реализацию Vulkan AMD. Выбор, какая реализация Vulkan "
"будет использоваться для запуска игры"
msgid "NUMA Node"
msgstr "Узел NUMA"
msgid ""
"NUMA node for CPU affinity. In multi-core systems, CPUs are split into "
"NUMA nodes, each with its own local memory and cores. Binding a game to a"
" single node reduces memory-access latency and limits costly core-to-core"
" switches."
msgstr ""
"Узел NUMA для аффинности CPU. В многоядерных системах CPU разделены на "
"узлы NUMA, каждый со своей локальной памятью и ядрами. Привязка игры к "
"одному узлу уменьшает задержку доступа к памяти и ограничивает "
"дорогостоящие переключения между ядрами."
msgid "Reboot"
msgstr "Перезагрузить"

View File

@@ -6,14 +6,13 @@ import subprocess
import sys
import psutil
import re
from portprotonqt.logger import get_logger
from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog, ExeSettingsDialog
from portprotonqt.game_card import GameCard
from portprotonqt.animations import DetailPageAnimations
from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel, FlowLayout
from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.input_manager import InputManager
from portprotonqt.input_manager import InputManager, MainWindowProtocol
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
from portprotonqt.system_overlay import SystemOverlay
from portprotonqt.input_manager import GamepadType
@@ -61,6 +60,7 @@ class MainWindow(QMainWindow):
self.is_exiting = False
selected_theme = read_theme_from_config()
self.current_theme_name = selected_theme
# Apply theme but defer heavy font loading
self.theme = self.theme_manager.apply_theme(selected_theme)
self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
self.card_width = read_card_size()
@@ -133,6 +133,13 @@ class MainWindow(QMainWindow):
self.update_progress.connect(self.progress_bar.setValue)
self.update_status_message.connect(self.statusBar().showMessage)
# Show immediate startup progress to indicate the app is loading
self.progress_bar.setVisible(True)
self.progress_bar.setRange(0, 0) # Indeterminate initially
self.progress_bar.setValue(0) # Reset value
self.update_status_message.emit(_("Starting PortProton..."), 0)
QApplication.processEvents() # Process to show progress bar immediately
self.installing = False
self.current_install_script = None
self.install_process = None
@@ -153,7 +160,7 @@ class MainWindow(QMainWindow):
headerLayout.setContentsMargins(0, 0, 0, 0)
headerLayout.addStretch()
self.input_manager = InputManager(self) # type: ignore
self.input_manager = InputManager(cast(MainWindowProtocol, self))
self.input_manager.button_event.connect(self.updateControlHints)
self.input_manager.dpad_moved.connect(self.updateControlHints)
@@ -220,7 +227,6 @@ class MainWindow(QMainWindow):
self.keyboard = VirtualKeyboard(self, self.theme)
self.detail_animations = DetailPageAnimations(self, self.theme)
QTimer.singleShot(0, self.loadGames)
if read_fullscreen_config():
self.showFullScreen()
@@ -231,6 +237,14 @@ class MainWindow(QMainWindow):
else:
self.showNormal()
# Process events to ensure UI is responsive before starting heavy operations
QApplication.processEvents()
# Delay game loading until after the UI is fully displayed to prevent blocking
# Use a longer delay to ensure window is fully rendered and responsive
# Use a custom event processing approach to make sure UI stays responsive
QTimer.singleShot(500, self.loadGames) # Reduced delay but ensure UI gets event processing
def on_slider_released(self) -> None:
"""Delegate to game library manager."""
if hasattr(self, 'game_library_manager'):
@@ -256,7 +270,7 @@ class MainWindow(QMainWindow):
GamepadType.PLAYSTATION: "ps_options",
},
'menu': {
GamepadType.XBOX: "xbox_view",
GamepadType.XBOX: "xbox_view", # Select button on Xbox
GamepadType.PLAYSTATION: "ps_share",
},
'search': {
@@ -267,6 +281,10 @@ class MainWindow(QMainWindow):
GamepadType.XBOX: "xbox_y",
GamepadType.PLAYSTATION: "ps_square",
},
'guide_select': {
GamepadType.XBOX: "xbox_xbox", # Xbox Guide button
GamepadType.PLAYSTATION: "ps_ps", # PS button
},
}
return mappings.get(action, {}).get(gtype, "placeholder")
@@ -306,6 +324,7 @@ class MainWindow(QMainWindow):
("context_menu", _("Menu")),
("menu", _("Fullscreen")),
("search", _("Search")),
("guide_select", _("Refresh Grid")),
]
keyboard_hints = [
@@ -314,6 +333,7 @@ class MainWindow(QMainWindow):
("key_e", _("Add Game")),
("key_context", _("Menu")),
("key_f11", _("Fullscreen")),
("key_f5", _("Refresh Grid")),
]
self.hintsLabels = []
@@ -361,8 +381,99 @@ class MainWindow(QMainWindow):
hintsLayout.addWidget(container)
# Special function to create combination hint for Guide+Select
def makeCombinationHint(action_text: str, action: str | None = None):
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 5, 0, 0)
layout.setSpacing(6)
# First icon (Guide button)
guide_icon = QLabel()
guide_icon.setFixedSize(26, 26)
guide_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
pixmap = QPixmap()
for candidate in (
self.theme_manager.get_theme_image("xbox_xbox", self.current_theme_name), # Xbox Guide
self.theme_manager.get_theme_image("ps_ps", self.current_theme_name), # PS Button
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
):
if candidate is not None and pixmap.load(str(candidate)):
break
if not pixmap.isNull():
guide_icon.setPixmap(pixmap.scaled(
26, 26,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
layout.addWidget(guide_icon)
# Plus sign between icons
plus_icon = QLabel()
plus_icon.setFixedSize(26, 26)
plus_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
plus_pixmap = QPixmap()
for candidate in (
self.theme_manager.get_theme_image("key_+", self.current_theme_name),
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
):
if candidate is not None and plus_pixmap.load(str(candidate)):
break
if not plus_pixmap.isNull():
plus_icon.setPixmap(plus_pixmap.scaled(
26, 26,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
layout.addWidget(plus_icon)
# Second icon (Select button)
select_icon = QLabel()
select_icon.setFixedSize(26, 26)
select_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
pixmap2 = QPixmap()
for candidate in (
self.theme_manager.get_theme_image("xbox_view", self.current_theme_name), # Xbox Select
self.theme_manager.get_theme_image("ps_share", self.current_theme_name), # PS Share
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
):
if candidate is not None and pixmap2.load(str(candidate)):
break
if not pixmap2.isNull():
select_icon.setPixmap(pixmap2.scaled(
26, 26,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
layout.addWidget(select_icon)
# текст действия
text_label = QLabel(action_text)
text_label.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
layout.addWidget(text_label)
# For gamepad combination hints
container.setVisible(False)
self.hintsLabels.append((container, [guide_icon, plus_icon, select_icon], action)) # Store all three elements for dynamic update
hintsLayout.addWidget(container)
# Create gamepad hints
for action, text in gamepad_actions:
if action == "guide_select":
# For the Guide+Select combination, create a special combination hint
makeCombinationHint(text, action)
else:
makeHint("placeholder", text, True, action) # Initial placeholder
# Create keyboard hints
@@ -418,12 +529,88 @@ class MainWindow(QMainWindow):
gtype = self.input_manager.gamepad_type
logger.debug("Updating control hints, gamepad connected: %s, type: %s", is_gamepad_connected, gtype.value)
gamepad_actions = ['confirm', 'back', 'add_game', 'context_menu', 'menu', 'search']
gamepad_actions = ['confirm', 'back', 'add_game', 'context_menu', 'menu', 'search', 'guide_select']
for container, icon_label, action in self.hintsLabels:
for container, icon_element, action in self.hintsLabels:
if action in gamepad_actions: # Gamepad hint
if is_gamepad_connected:
container.setVisible(True)
# Check if this is a combination hint (array of icons) or single icon hint
if isinstance(icon_element, list) and len(icon_element) == 3 and action == "guide_select":
# This is a combination hint for Guide+Select
guide_icon, plus_icon, select_icon = icon_element
# Determine guide icon based on gamepad type
if gtype == GamepadType.XBOX:
guide_icon_name = "xbox_xbox" # Xbox Guide button
elif gtype == GamepadType.PLAYSTATION:
guide_icon_name = "ps_ps" # PS Button
else:
guide_icon_name = "xbox_xbox" # Default to Xbox guide
# Load Guide icon
guide_pixmap = QPixmap()
for candidate in (
self.theme_manager.get_theme_image(guide_icon_name, self.current_theme_name),
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
):
if candidate is not None and guide_pixmap.load(str(candidate)):
break
if not guide_pixmap.isNull():
guide_icon.setPixmap(guide_pixmap.scaled(
26, 26,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
# Load Plus icon
plus_pixmap = QPixmap()
for candidate in (
self.theme_manager.get_theme_image("key_+", self.current_theme_name),
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
):
if candidate is not None and plus_pixmap.load(str(candidate)):
break
if not plus_pixmap.isNull():
plus_icon.setPixmap(plus_pixmap.scaled(
26, 26,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
# Determine select icon based on gamepad type
if gtype == GamepadType.XBOX:
select_icon_name = "xbox_view" # Xbox Select button
elif gtype == GamepadType.PLAYSTATION:
select_icon_name = "ps_share" # PS Share button
else:
select_icon_name = "xbox_view" # Default to Xbox Select
# Load Select icon
select_pixmap = QPixmap()
for candidate in (
self.theme_manager.get_theme_image(select_icon_name, self.current_theme_name),
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
):
if candidate is not None and select_pixmap.load(str(candidate)):
break
if not select_pixmap.isNull():
select_icon.setPixmap(select_pixmap.scaled(
26, 26,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
else:
# This is a regular single-icon hint
# Verify that icon_element is not a list before assigning
if isinstance(icon_element, list):
# This shouldn't happen based on current logic, but added for safety
logger.warning(f"Unexpected list found for single-icon hint with action: {action}")
continue # Skip this iteration to prevent error
icon_label = icon_element
# Update icon based on type
icon_name = self.get_button_icon(action, gtype)
icon_path = self.theme_manager.get_theme_image(icon_name, self.current_theme_name)
@@ -564,6 +751,18 @@ class MainWindow(QMainWindow):
def on_games_loaded(self, games: list[tuple]):
self.game_library_manager.set_games(games)
self.progress_bar.setVisible(False)
self.progress_bar.setRange(0, 100) # Reset to determinate state for next use
self.progress_bar.setValue(0)
# Clear the refresh in progress flag
if hasattr(self, '_refresh_in_progress'):
self._refresh_in_progress = False
# Re-enable the refresh button if it exists
if hasattr(self, 'refreshButton'):
self.refreshButton.setEnabled(True)
self.refreshButton.setText(_("Refresh Grid"))
self.update_status_message.emit(_("Game library refreshed"), 3000)
def open_portproton_forum_topic(self, topic_name: str):
"""Open the PortProton forum topic or search page for this game."""
@@ -580,8 +779,21 @@ class MainWindow(QMainWindow):
favorites = read_favorites()
self.pending_games = []
self.games = []
# Show initial progress bar immediately
self.progress_bar.setRange(0, 100) # Set to determinate range
self.progress_bar.setValue(0)
self.progress_bar.setVisible(True)
# Process events to keep UI responsive
QApplication.processEvents()
def start_loading():
# Make sure progress bar is still visible
self.progress_bar.setValue(0)
self.progress_bar.setVisible(True)
QApplication.processEvents() # Allow UI to update
if display_filter == "steam":
self._load_steam_games_async(lambda games: self.games_loaded.emit(games))
elif display_filter == "portproton":
@@ -595,43 +807,99 @@ class MainWindow(QMainWindow):
self.update_status_message.emit
)
elif display_filter == "favorites":
def on_all_games(portproton_games, steam_games, epic_games):
def on_all_games_favorites(portproton_games, steam_games, epic_games):
games = [game for game in portproton_games + steam_games + epic_games if game[0] in favorites]
self.games_loaded.emit(games)
self._load_portproton_games_async(
lambda pg: self._load_steam_games_async(
lambda sg: load_egs_games_async(
# Load games from different sources in parallel to prevent blocking
results = {'portproton': [], 'steam': [], 'epic': []}
completed = {'portproton': False, 'steam': False, 'epic': False}
def check_completion():
if all(completed.values()):
QApplication.processEvents() # Keep UI responsive
on_all_games_favorites(results['portproton'], results['steam'], results['epic'])
def portproton_callback(games):
results['portproton'] = games
completed['portproton'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
def steam_callback(games):
results['steam'] = games
completed['steam'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
def epic_callback(games):
results['epic'] = games
completed['epic'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
self._load_portproton_games_async(portproton_callback)
self._load_steam_games_async(steam_callback)
load_egs_games_async(
self.legendary_path,
lambda eg: on_all_games(pg, sg, eg),
epic_callback,
self.downloader,
self.update_progress.emit,
self.update_status_message.emit
)
)
)
else:
def on_all_games(portproton_games, steam_games, epic_games):
# For 'all' filter - load games from different sources in parallel to prevent blocking
results = {'portproton': [], 'steam': [], 'epic': []}
completed = {'portproton': False, 'steam': False, 'epic': False}
def on_all_games():
seen = set()
games = []
for game in portproton_games + steam_games + epic_games:
for game in results['portproton'] + results['steam'] + results['epic']:
# Уникальный ключ: имя + exec_line
key = (game[0], game[4])
if key not in seen:
seen.add(key)
games.append(game)
QApplication.processEvents() # Keep UI responsive
self.games_loaded.emit(games)
self._load_portproton_games_async(
lambda pg: self._load_steam_games_async(
lambda sg: load_egs_games_async(
def check_completion():
if all(completed.values()):
QApplication.processEvents() # Keep UI responsive
on_all_games()
def portproton_callback(games):
results['portproton'] = games
completed['portproton'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
def steam_callback(games):
results['steam'] = games
completed['steam'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
def epic_callback(games):
results['epic'] = games
completed['epic'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
# Load all sources in parallel
self._load_portproton_games_async(portproton_callback)
self._load_steam_games_async(steam_callback)
load_egs_games_async(
self.legendary_path,
lambda eg: on_all_games(pg, sg, eg),
epic_callback,
self.downloader,
self.update_progress.emit,
self.update_status_message.emit
)
)
)
return []
# Run loading immediately to show status without delay
start_loading()
def _load_steam_games_async(self, callback: Callable[[list[tuple]], None]):
steam_games = []
@@ -846,19 +1114,17 @@ class MainWindow(QMainWindow):
if mgr.gamesListWidget and mgr.gamesListLayout:
games_layout = mgr.gamesListLayout
games_widget = mgr.gamesListWidget
QTimer.singleShot(0, lambda: (
games_layout.invalidate(),
games_widget.adjustSize(),
games_widget.updateGeometry()
))
# Update layout updates to be more compatible with PySide 6.10.1
QTimer.singleShot(0, lambda: games_layout.invalidate())
QTimer.singleShot(10, lambda: games_widget.adjustSize())
QTimer.singleShot(15, lambda: games_widget.updateGeometry())
if hasattr(self, "autoInstallContainer") and hasattr(self, "autoInstallContainerLayout"):
auto_layout = self.autoInstallContainerLayout
auto_widget = self.autoInstallContainer
QTimer.singleShot(0, lambda: (
auto_layout.invalidate(),
auto_widget.adjustSize(),
auto_widget.updateGeometry()
))
# Update layout updates to be more compatible with PySide 6.10.1
QTimer.singleShot(0, lambda: auto_layout.invalidate())
QTimer.singleShot(10, lambda: auto_widget.adjustSize())
QTimer.singleShot(15, lambda: auto_widget.updateGeometry())
def openSystemOverlay(self):
@@ -881,7 +1147,16 @@ class MainWindow(QMainWindow):
self.addGameButton.setStyleSheet(self.theme.ADDGAME_BACK_BUTTON_STYLE)
self.addGameButton.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.addGameButton.clicked.connect(self.openAddGameDialog)
layout.addWidget(self.addGameButton, alignment=Qt.AlignmentFlag.AlignRight)
layout.addWidget(self.addGameButton)
# Refresh button
self.refreshButton = AutoSizeButton(_("Refresh Grid"), icon=self.theme_manager.get_icon("update"))
self.refreshButton.setStyleSheet(self.theme.ADDGAME_BACK_BUTTON_STYLE)
self.refreshButton.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.refreshButton.clicked.connect(self.refreshGames)
layout.addWidget(self.refreshButton)
layout.addStretch() # Add stretch to push search to the right
self.searchEdit = CustomLineEdit(self, theme=self.theme)
icon: QIcon = cast(QIcon, self.theme_manager.get_icon("search"))
@@ -895,12 +1170,62 @@ class MainWindow(QMainWindow):
self.searchEdit.textChanged.connect(self.startSearchDebounce)
self.searchDebounceTimer = QTimer(self)
self.searchDebounceTimer.setSingleShot(True)
self.searchDebounceTimer.setInterval(300)
self.searchDebounceTimer.setInterval(150) # Reduced debounce time for better responsiveness
self.searchDebounceTimer.timeout.connect(self.on_search_changed)
layout.addWidget(self.searchEdit)
return self.container, self.searchEdit
def refreshGames(self):
"""Refresh the game grid by reloading all games without restarting the application."""
# Prevent multiple refreshes at once
if hasattr(self, '_refresh_in_progress') and self._refresh_in_progress:
# If refresh is already in progress, just update the status
self.update_status_message.emit(_("A refresh is already in progress..."), 3000)
return
# Mark that a refresh is in progress
self._refresh_in_progress = True
# Clear the search field to ensure all games are shown after refresh
self.searchEdit.clear()
# Disable the refresh button during refresh to prevent multiple clicks
self.refreshButton.setEnabled(False)
self.refreshButton.setText(_("Refreshing..."))
# Show progress bar
self.progress_bar.setVisible(True)
self.progress_bar.setRange(0, 0) # Indeterminate
self.update_status_message.emit(_("Refreshing game library..."), 0)
# Clear the game card cache and layout to force reload of custom data
if hasattr(self, 'game_library_manager') and self.game_library_manager:
# Clear the cache to ensure custom data is reloaded
self.game_library_manager.game_card_cache.clear()
self.game_library_manager.pending_images.clear()
# Clear search indices to rebuild with fresh data
if hasattr(self.game_library_manager, '_build_search_indices'):
# Mark for full rebuild of search indices
self.game_library_manager.dirty = True # Force full update
# Also clear the layout to ensure old widgets are removed
if (hasattr(self.game_library_manager, 'gamesListLayout') and
self.game_library_manager.gamesListLayout and
hasattr(self.game_library_manager, 'gamesListWidget') and
self.game_library_manager.gamesListWidget):
# Remove all widgets from the layout
self.game_library_manager.clear_layout(self.game_library_manager.gamesListLayout)
# Force layout update to ensure UI changes are visible
self.game_library_manager.gamesListWidget.updateGeometry()
if hasattr(self.game_library_manager, 'gamesListLayout'):
self.game_library_manager.gamesListLayout.update()
# Reload games using the existing loadGames functionality
# Use a small delay to allow UI to update before starting the refresh
QTimer.singleShot(50, lambda: self.loadGames())
def on_search_text_changed(self, text: str):
"""Search text change handler with debounce."""
self.searchDebounceTimer.stop()
@@ -1190,7 +1515,9 @@ class MainWindow(QMainWindow):
while self.autoInstallContainerLayout.count():
child = self.autoInstallContainerLayout.takeAt(0)
if child:
child.widget().deleteLater()
widget = child.widget()
if widget:
widget.deleteLater()
self.autoInstallGameCards.clear()
self.allAutoInstallCards.clear()
@@ -2269,6 +2596,7 @@ class MainWindow(QMainWindow):
def openGameDetailPage(self, name, description, cover_path=None, appid="", exec_line="", controller_support="", last_launch="", formatted_playtime="", protondb_tier="", game_source="", anticheat_status=""):
detailPage = QWidget()
if not hasattr(self, '_animations'):
self._animations = {}
imageLabel = QLabel()
imageLabel.setFixedSize(300, 450)
@@ -2277,25 +2605,34 @@ class MainWindow(QMainWindow):
# Функция загрузки изображения и обновления стилей
def load_image_and_restore_effect():
if not detailPage or detailPage.isHidden():
logger.warning("Detail page is None or hidden, skipping image load")
# Check if detail page still exists and is valid
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
logger.warning("Detail page is None, hidden, or no longer valid, skipping image load")
return
try:
detailPage.setWindowOpacity(1.0)
except RuntimeError:
logger.warning("Detail page already deleted, skipping opacity set")
return
if cover_path:
def on_pixmap_ready(pixmap):
if not detailPage or detailPage.isHidden():
logger.warning("Detail page is None or hidden, skipping pixmap update")
# Check if detail page still exists and is valid
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
logger.warning("Detail page is None, hidden, or no longer valid, skipping pixmap update")
return
try:
rounded = round_corners(pixmap, 10)
imageLabel.setPixmap(rounded)
logger.debug("Pixmap set for imageLabel")
def on_palette_ready(palette):
if not detailPage or detailPage.isHidden():
logger.warning("Detail page is None or hidden, skipping palette update")
# Check if detail page still exists and is valid
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
logger.warning("Detail page is None, hidden, or no longer valid, skipping palette update")
return
try:
dark_palette = [self.darkenColor(color, factor=200) for color in palette]
stops = ",\n".join(
[f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))]
@@ -2303,12 +2640,18 @@ class MainWindow(QMainWindow):
detailPage.setStyleSheet(self.theme.detail_page_style(stops))
detailPage.update()
logger.debug("Stylesheet updated with palette")
except RuntimeError:
logger.warning("Detail page already deleted, skipping palette stylesheet update")
self.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready)
except RuntimeError:
logger.warning("Detail page already deleted, skipping pixmap update")
load_pixmap_async(cover_path, 300, 450, on_pixmap_ready)
else:
try:
detailPage.setStyleSheet(self.theme.DETAIL_PAGE_NO_COVER_STYLE)
detailPage.update()
except RuntimeError:
logger.warning("Detail page already deleted, skipping no-cover stylesheet update")
def cleanup_animation():
if detailPage in self._animations:
@@ -2549,6 +2892,9 @@ class MainWindow(QMainWindow):
return
if not self._current_detail_page or self._current_detail_page.isHidden() or not self._current_detail_page.parent():
return
# Additional check: make sure the detail page in the stacked widget is still our current detail page
if self.stackedWidget.currentWidget() != self._current_detail_page and self._current_detail_page not in [self.stackedWidget.widget(i) for i in range(self.stackedWidget.count())]:
return
if results:
game = results[0] # Берем первый результат
@@ -2572,6 +2918,7 @@ class MainWindow(QMainWindow):
has_data = False
if main_story_time is not None:
try:
mainStoryTitle = QLabel(_("MAIN STORY"))
mainStoryTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE)
mainStoryValue = QLabel(main_story_time)
@@ -2580,8 +2927,11 @@ class MainWindow(QMainWindow):
hltbLayout.addWidget(mainStoryValue)
hltbLayout.addSpacing(30)
has_data = True
except RuntimeError:
logger.warning("Detail page already deleted, skipping main story time update")
if main_extra_time is not None:
try:
mainExtraTitle = QLabel(_("MAIN + SIDES"))
mainExtraTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE)
mainExtraValue = QLabel(main_extra_time)
@@ -2590,8 +2940,11 @@ class MainWindow(QMainWindow):
hltbLayout.addWidget(mainExtraValue)
hltbLayout.addSpacing(30)
has_data = True
except RuntimeError:
logger.warning("Detail page already deleted, skipping main extra time update")
if completionist_time is not None:
try:
completionistTitle = QLabel(_("COMPLETIONIST"))
completionistTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE)
completionistValue = QLabel(completionist_time)
@@ -2599,6 +2952,8 @@ class MainWindow(QMainWindow):
hltbLayout.addWidget(completionistTitle)
hltbLayout.addWidget(completionistValue)
has_data = True
except RuntimeError:
logger.warning("Detail page already deleted, skipping completionist time update")
# Если есть данные, добавляем layout во вторую строку
if has_data:
@@ -2642,6 +2997,7 @@ class MainWindow(QMainWindow):
file_to_check = entry_exec_split[0]
current_exe = os.path.basename(file_to_check) if file_to_check else None
buttons_layout = QHBoxLayout()
if self.target_exe is not None and current_exe == self.target_exe:
playButton = AutoSizeButton(_("Stop"), icon=self.theme_manager.get_icon("stop"))
else:
@@ -2649,8 +3005,9 @@ class MainWindow(QMainWindow):
playButton.setFixedSize(120, 40)
playButton.setStyleSheet(self.theme.PLAY_BUTTON_STYLE)
playButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
playButton.clicked.connect(lambda: self.toggleGame(exec_line, playButton))
detailsLayout.addWidget(playButton, alignment=Qt.AlignmentFlag.AlignLeft)
buttons_layout.addWidget(playButton, alignment=Qt.AlignmentFlag.AlignLeft)
# Settings button
settings_icon = self.theme_manager.get_icon("settings")
@@ -2658,7 +3015,9 @@ class MainWindow(QMainWindow):
settings_button.setFixedSize(120, 40)
settings_button.setStyleSheet(self.theme.PLAY_BUTTON_STYLE)
settings_button.clicked.connect(lambda: self.open_exe_settings(file_to_check))
detailsLayout.addWidget(settings_button, alignment=Qt.AlignmentFlag.AlignLeft)
buttons_layout.addWidget(settings_button, alignment=Qt.AlignmentFlag.AlignLeft)
buttons_layout.addStretch()
detailsLayout.addLayout(buttons_layout)
contentFrameLayout.addWidget(detailsWidget)
mainLayout.addStretch()
@@ -2672,6 +3031,61 @@ class MainWindow(QMainWindow):
# Анимация
self.detail_animations.animate_detail_page(detailPage, load_image_and_restore_effect, cleanup_animation)
# Update page reference
self.currentDetailPage = detailPage
original_load = load_image_and_restore_effect
def enhanced_load():
original_load()
QTimer.singleShot(50, try_set_focus)
def try_set_focus():
if not (playButton and not playButton.isHidden()):
return
# Ensure page is active
self.stackedWidget.setCurrentWidget(detailPage)
detailPage.setFocus(Qt.FocusReason.OtherFocusReason)
playButton.setFocus(Qt.FocusReason.OtherFocusReason)
playButton.update()
detailPage.raise_()
self.activateWindow()
if playButton.hasFocus():
logger.debug("Play button successfully received focus")
else:
logger.debug("Retrying focus...")
QTimer.singleShot(20, retry_focus)
def retry_focus():
if not (playButton and not playButton.isHidden() and not playButton.hasFocus()):
return
QApplication.processEvents()
self.activateWindow()
self.stackedWidget.setCurrentWidget(detailPage)
detailPage.raise_()
playButton.setFocus(Qt.FocusReason.OtherFocusReason)
playButton.update()
if not playButton.hasFocus():
logger.debug("Final retry...")
playButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
playButton.setFocus(Qt.FocusReason.OtherFocusReason)
QApplication.processEvents()
if playButton.hasFocus():
logger.debug("Play button received focus after final retry")
else:
logger.debug("Play button still doesn't have focus")
self.detail_animations.animate_detail_page(
detailPage,
enhanced_load,
cleanup_animation
)
def toggleFavoriteInDetailPage(self, game_name, label):
favorites = read_favorites()
if game_name in favorites:
@@ -2735,29 +3149,59 @@ class MainWindow(QMainWindow):
def cleanup():
"""Helper function to clean up after animation."""
try:
if page in self._animations:
animation = self._animations[page]
# Stop and clean up any existing animations for this page
if hasattr(self, '_animations') and page in self._animations:
try:
animation = self._animations[page]
if isinstance(animation, QAbstractAnimation):
if animation.state() == QAbstractAnimation.State.Running:
animation.stop()
except RuntimeError:
pass # Animation already deleted
finally:
# Since animation is set to delete when stopped, we don't manually delete it
del self._animations[page]
except (KeyError, RuntimeError):
pass # Animation already deleted or not found
# Ensure page is still valid before trying to remove it
# Check if page is still in the stacked widget by iterating through all widgets
page_found = False
for i in range(self.stackedWidget.count()):
if self.stackedWidget.widget(i) is page:
page_found = True
break
if page_found:
self.stackedWidget.setCurrentIndex(0)
self.stackedWidget.removeWidget(page)
page.deleteLater()
else:
logger.debug("Page not found in stacked widget, may have been removed already")
# Clear references to avoid dangling references
if hasattr(self, 'currentDetailPage'):
self.currentDetailPage = None
if hasattr(self, 'current_exec_line'):
self.current_exec_line = None
if hasattr(self, 'current_play_button'):
self.current_play_button = None
self._exit_animation_in_progress = False
except RuntimeError:
# Widget was already deleted, which is expected after deleteLater()
logger.debug("Detail page already deleted during cleanup")
self._exit_animation_in_progress = False
except Exception as e:
logger.error(f"Error in cleanup: {e}", exc_info=True)
logger.error(f"Unexpected error in cleanup: {e}", exc_info=True)
self._exit_animation_in_progress = False
# Start exit animation
try:
# Check if the page is still valid before starting animation
if page and not page.isHidden() and page.parent() is not None:
self.detail_animations.animate_detail_page_exit(page, cleanup)
else:
logger.warning("Detail page not valid, bypassing animation and cleaning up directly")
self._exit_animation_in_progress = False
cleanup()
except Exception as e:
logger.error(f"Error starting exit animation: {e}", exc_info=True)
self._exit_animation_in_progress = False
@@ -2785,7 +3229,10 @@ class MainWindow(QMainWindow):
# Игра стартовала устанавливаем флаг, обновляем кнопку на "Stop"
self._gameLaunched = True
if self.current_running_button is not None:
try:
self.current_running_button.setText(_("Stop"))
except RuntimeError:
self.current_running_button = None
#self._inhibit_screensaver()
elif not child_running:
# Игра завершилась сбрасываем флаг, сбрасываем кнопку и останавливаем таймер
@@ -2804,6 +3251,7 @@ class MainWindow(QMainWindow):
Вызывается, когда игра завершилась (не по нажатию кнопки).
"""
if self.current_running_button is not None:
try:
self.current_running_button.setText(_("Play"))
icon = self.theme_manager.get_icon("play")
if isinstance(icon, str):
@@ -2811,6 +3259,8 @@ class MainWindow(QMainWindow):
elif icon is None:
icon = QIcon() # Use empty QIcon as fallback
self.current_running_button.setIcon(icon)
except RuntimeError:
pass
self.current_running_button = None
self.target_exe = None
@@ -2863,6 +3313,7 @@ class MainWindow(QMainWindow):
pass
self.game_processes = []
if update_button:
try:
update_button.setText(_("Play"))
icon = self.theme_manager.get_icon("play")
if isinstance(icon, str):
@@ -2870,6 +3321,8 @@ class MainWindow(QMainWindow):
elif icon is None:
icon = QIcon()
update_button.setIcon(icon)
except RuntimeError:
pass
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None:
self.checkProcessTimer.stop()
self.checkProcessTimer.deleteLater()
@@ -2891,6 +3344,7 @@ class MainWindow(QMainWindow):
self.game_processes.append(process)
save_last_launch(exe_name, datetime.now())
if update_button:
try:
update_button.setText(_("Launching"))
icon = self.theme_manager.get_icon("stop")
if isinstance(icon, str):
@@ -2898,6 +3352,8 @@ class MainWindow(QMainWindow):
elif icon is None:
icon = QIcon()
update_button.setIcon(icon)
except RuntimeError:
pass
self.checkProcessTimer = QTimer(self)
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
@@ -2954,6 +3410,7 @@ class MainWindow(QMainWindow):
pass
self.game_processes = []
if update_button:
try:
update_button.setText(_("Play"))
icon = self.theme_manager.get_icon("play")
if isinstance(icon, str):
@@ -2961,6 +3418,8 @@ class MainWindow(QMainWindow):
elif icon is None:
icon = QIcon()
update_button.setIcon(icon)
except RuntimeError:
pass
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None:
self.checkProcessTimer.stop()
self.checkProcessTimer.deleteLater()
@@ -2982,6 +3441,7 @@ class MainWindow(QMainWindow):
self.game_processes.append(process)
save_last_launch(exe_name, datetime.now())
if update_button:
try:
update_button.setText(_("Launching"))
icon = self.theme_manager.get_icon("stop")
if isinstance(icon, str):
@@ -2989,6 +3449,8 @@ class MainWindow(QMainWindow):
elif icon is None:
icon = QIcon()
update_button.setIcon(icon)
except RuntimeError:
pass
self.checkProcessTimer = QTimer(self)
self.checkProcessTimer.timeout.connect(self.checkTargetExe)

View File

@@ -254,6 +254,8 @@ class PortProtonAPI:
try:
mod_time = os.path.getmtime(cache_file)
if time.time() - mod_time < AUTOINSTALL_CACHE_DURATION:
# Add timeout protection for file operations
start_time = time.time()
with open(cache_file, "rb") as f:
data = orjson.loads(f.read())
# Check signature
@@ -261,6 +263,10 @@ class PortProtonAPI:
current_signature = self._compute_scripts_signature(
os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
)
# Check for timeout during signature computation
if time.time() - start_time > 3: # 3 second timeout
logger.warning("Cache loading took too long, skipping cache")
return None
if cached_signature != current_signature:
logger.info("Scripts signature mismatch; invalidating cache")
return None
@@ -287,21 +293,26 @@ class PortProtonAPI:
def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None:
"""Start loading auto-install games in a background thread. Returns the thread for management."""
# Check cache first (sync, fast)
cached_games = self._load_autoinstall_cache()
if cached_games is not None:
# Emit via callback immediately if cached
QThread.msleep(0) # Yield to Qt event loop
callback(cached_games)
return None # No thread needed
# No cache: Start background thread
class AutoinstallWorker(QThread):
finished = Signal(list)
api: "PortProtonAPI"
portproton_location: str | None
def run(self):
import time
# Check cache in this background thread, not in main thread
start_time = time.time()
cached_games = self.api._load_autoinstall_cache()
# If cache loading took too long (>2 seconds), skip cache and load directly
if time.time() - start_time > 2:
logger.warning("Cache loading took too long, proceeding without cache")
cached_games = None
if cached_games is not None:
self.finished.emit(cached_games)
return
# No cache: Load games from scratch
games = []
auto_dir = os.path.join(
self.portproton_location or "", "data", "scripts", "pw_autoinstall"

View File

@@ -0,0 +1,379 @@
"""
Utility module for search optimizations including Trie, hash tables, and fuzzy matching.
"""
import concurrent.futures
import threading
from collections.abc import Callable
from typing import Any
from rapidfuzz import fuzz
from threading import Lock
from portprotonqt.logger import get_logger
from PySide6.QtCore import QThread, QRunnable, Signal, QObject, QTimer
import requests
logger = get_logger(__name__)
class TrieNode:
"""Node in the Trie data structure."""
def __init__(self):
self.children = {}
self.is_end_word = False
self.payload = None # Store the original data in leaf nodes
class Trie:
"""Trie data structure for efficient prefix-based searching."""
def __init__(self):
self.root = TrieNode()
self._lock = Lock() # Thread safety for concurrent access
def insert(self, key: str, payload: Any):
"""Insert a key with payload into the Trie."""
with self._lock:
node = self.root
for char in key.lower():
if char not in node.children:
node.children[char] = TrieNode()
node = node.children[char]
node.is_end_word = True
node.payload = payload
def search_prefix(self, prefix: str) -> list[tuple[str, Any]]:
"""Find all entries with the given prefix."""
with self._lock:
node = self.root
for char in prefix.lower():
if char not in node.children:
return []
node = node.children[char]
results = []
self._collect_all(node, prefix.lower(), results)
return results
def _collect_all(self, node: TrieNode, current_prefix: str, results: list[tuple[str, Any]]):
"""Collect all entries from the current node."""
if node.is_end_word:
results.append((current_prefix, node.payload))
for char, child_node in node.children.items():
self._collect_all(child_node, current_prefix + char, results)
class FuzzySearchIndex:
"""Index for fuzzy string matching with rapidfuzz."""
def __init__(self, items: list[tuple[str, Any]] | None = None):
self.items: list[tuple[str, Any]] = items or []
self.normalized_items: list[tuple[str, Any]] = []
self._lock = Lock()
self._build_normalized_index()
def _build_normalized_index(self):
"""Build a normalized index for fuzzy matching."""
with self._lock:
self.normalized_items = [(self._normalize(item[0]), item[1]) for item in self.items]
def _normalize(self, s: str) -> str:
"""Normalize string for fuzzy matching."""
s = s.lower()
for ch in ["", "®"]:
s = s.replace(ch, "")
for ch in ["-", ":", ","]:
s = s.replace(ch, " ")
s = " ".join(s.split())
for suffix in ["bin", "app"]:
if s.endswith(suffix):
s = s[:-len(suffix)].strip()
keywords_to_remove = {"ultimate", "edition", "definitive", "complete", "remastered"}
words = s.split()
filtered_words = [word for word in words if word not in keywords_to_remove]
return " ".join(filtered_words)
def fuzzy_search(self, query: str, limit: int = 5, min_score: float = 60.0) -> list[tuple[str, Any, float]]:
"""Perform fuzzy search using rapidfuzz."""
with self._lock:
if not query or not self.normalized_items:
return []
query_normalized = self._normalize(query)
results = []
for i, (item_text, item_data) in enumerate(self.normalized_items):
score = fuzz.ratio(query_normalized, item_text)
if score >= min_score:
results.append((self.items[i][0], item_data, score))
# Sort by score descending
results.sort(key=lambda x: x[2], reverse=True)
return results[:limit]
class SearchOptimizer:
"""Main search optimization class combining multiple approaches."""
def __init__(self):
self.hash_index: dict[str, Any] = {}
self.trie_index = Trie()
self.fuzzy_index = None
self._lock = Lock()
def build_indices(self, items: list[tuple[str, Any]]):
"""Build all search indices from items."""
with self._lock:
self.hash_index = {item[0].lower(): item[1] for item in items}
self.trie_index = Trie()
for key, value in self.hash_index.items():
self.trie_index.insert(key, value)
self.fuzzy_index = FuzzySearchIndex(items)
def exact_search(self, key: str) -> Any | None:
"""Perform exact hash-based lookup."""
with self._lock:
return self.hash_index.get(key.lower())
def prefix_search(self, prefix: str) -> list[tuple[str, Any]]:
"""Perform prefix search using Trie."""
with self._lock:
return self.trie_index.search_prefix(prefix)
def fuzzy_search(self, query: str, limit: int = 5, min_score: float = 60.0) -> list[tuple[str, Any, float]]:
"""Perform fuzzy search."""
if self.fuzzy_index:
return self.fuzzy_index.fuzzy_search(query, limit, min_score)
return []
class RequestRunnable(QRunnable):
"""Runnable for executing HTTP requests in a thread."""
def __init__(self, method: str, url: str, on_success=None, on_error=None, **kwargs):
super().__init__()
self.method = method
self.url = url
self.kwargs = kwargs
self.result = None
self.error = None
self.on_success: Callable | None = on_success
self.on_error: Callable | None = on_error
def run(self):
try:
if self.method.lower() == 'get':
self.result = requests.get(self.url, **self.kwargs)
elif self.method.lower() == 'post':
self.result = requests.post(self.url, **self.kwargs)
else:
raise ValueError(f"Unsupported HTTP method: {self.method}")
# Execute success callback if provided
if self.on_success is not None:
success_callback = self.on_success # Capture the callback
def success_handler():
if success_callback is not None: # Re-check to satisfy Pyright
success_callback(self.result)
QTimer.singleShot(0, success_handler)
except Exception as e:
self.error = e
# Execute error callback if provided
if self.on_error is not None:
error_callback = self.on_error # Capture the callback
captured_error = e # Capture the exception
def error_handler():
error_callback(captured_error)
QTimer.singleShot(0, error_handler)
def run_request_in_thread(method: str, url: str, on_success: Callable | None = None, on_error: Callable | None = None, **kwargs):
"""Run HTTP request in a separate thread using Qt's thread system."""
runnable = RequestRunnable(method, url, on_success=on_success, on_error=on_error, **kwargs)
# Use QThreadPool to execute the runnable
from PySide6.QtCore import QThreadPool
thread_pool = QThreadPool.globalInstance()
thread_pool.start(runnable)
return runnable # Return the runnable to allow for potential cancellation if needed
def run_function_in_thread(func, *args, on_success: Callable | None = None, on_error: Callable | None = None, **kwargs):
"""Run a function in a separate thread."""
def execute():
try:
result = func(*args, **kwargs)
if on_success:
on_success(result)
except Exception as e:
if on_error:
on_error(e)
thread = threading.Thread(target=execute)
thread.daemon = True
thread.start()
return thread
def run_in_thread(func, *args, **kwargs):
"""Run a function in a separate thread."""
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(func, *args, **kwargs)
return future.result()
def run_in_thread_async(func, *args, callback: Callable | None = None, **kwargs):
"""Run a function in a separate thread asynchronously."""
import threading
def target():
try:
result = func(*args, **kwargs)
if callback:
callback(result)
except Exception as e:
if callback:
callback(None) # or handle error in callback
logger.error(f"Error in threaded operation: {e}")
thread = threading.Thread(target=target)
thread.daemon = True
thread.start()
return thread
# Threaded search implementation using QThread for performance optimization
class ThreadedSearchWorker(QObject):
"""
A threaded worker for performing search operations without blocking the UI.
"""
search_started = Signal()
search_finished = Signal(list)
search_error = Signal(str)
def __init__(self):
super().__init__()
self.search_optimizer = SearchOptimizer()
self.games_data = []
def set_games_data(self, games_data: list):
"""Set the games data to be searched."""
self.games_data = games_data
# Build indices from the games data (name, description, etc.)
items = [(game[0], game) for game in games_data] # game[0] is the name
self.search_optimizer.build_indices(items)
def execute_search(self, search_text: str, search_type: str = "auto"):
"""
Execute search in a separate thread.
Args:
search_text: Text to search for
search_type: Type of search ("exact", "prefix", "fuzzy", "auto")
"""
try:
self.search_started.emit()
import time
start_time = time.time()
results = []
if search_type == "exact" or (search_type == "auto" and len(search_text) > 2):
exact_result = self.search_optimizer.exact_search(search_text)
if exact_result:
results = [exact_result]
elif search_type == "prefix":
results = self.search_optimizer.prefix_search(search_text)
elif search_type == "fuzzy" or search_type == "auto":
results = self.search_optimizer.fuzzy_search(search_text, limit=20, min_score=50.0)
else:
# Auto-detect search type based on input
if len(search_text) < 3:
results = self.search_optimizer.prefix_search(search_text)
else:
# Try exact first, then fuzzy
exact_result = self.search_optimizer.exact_search(search_text)
if exact_result:
results = [exact_result]
else:
results = self.search_optimizer.fuzzy_search(search_text, limit=20, min_score=50.0)
end_time = time.time()
print(f"Search completed in {end_time - start_time:.4f} seconds")
self.search_finished.emit(results)
except Exception as e:
self.search_error.emit(str(e))
class ThreadedSearch(QThread):
"""
QThread implementation for running search operations in the background.
"""
search_started = Signal()
search_finished = Signal(list)
search_error = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.worker = ThreadedSearchWorker()
self.search_text = ""
self.search_type = "auto"
self.games_data = []
# Connect worker signals to thread signals
self.worker.search_started.connect(self.search_started)
self.worker.search_finished.connect(self.search_finished)
self.worker.search_error.connect(self.search_error)
def set_search_params(self, search_text: str, games_data: list, search_type: str = "auto"):
"""Set parameters for the search operation."""
self.search_text = search_text
self.games_data = games_data
self.search_type = search_type
def set_games_data(self, games_data: list):
"""Set the games data to be searched."""
self.games_data = games_data
self.worker.set_games_data(games_data)
def run(self):
"""Run the search operation in the thread."""
self.worker.execute_search(self.search_text, self.search_type)
class SearchThreadPool:
"""
A simple thread pool for managing multiple search operations.
"""
def __init__(self, max_threads: int = 3):
self.max_threads = max_threads
self.active_threads = []
self.thread_queue = []
def submit_search(self, search_text: str, games_data: list, search_type: str = "auto",
on_start: Callable | None = None, on_finish: Callable | None = None, on_error: Callable | None = None):
"""
Submit a search operation to the pool.
Args:
search_text: Text to search for
games_data: List of game data tuples to search in
search_type: Type of search ("exact", "prefix", "fuzzy", "auto")
on_start: Callback when search starts
on_finish: Callback when search finishes (receives results)
on_error: Callback when search errors (receives error message)
"""
search_thread = ThreadedSearch()
search_thread.set_search_params(search_text, games_data, search_type)
# Connect callbacks if provided
if on_start:
search_thread.search_started.connect(on_start)
if on_finish:
search_thread.search_finished.connect(on_finish)
if on_error:
search_thread.search_error.connect(on_error)
# Start the thread
search_thread.start()
self.active_threads.append(search_thread)
# Clean up finished threads
self.active_threads = [thread for thread in self.active_threads if thread.isRunning()]
return search_thread

View File

@@ -0,0 +1,229 @@
def get_toggle_settings():
"""Get predefined toggle settings with descriptions."""
from portprotonqt.localization import _
return {
'PW_MANGOHUD': _("Using FPS and system load monitoring (Turns on and off by the key combination - right Shift + F12)"),
'PW_MANGOHUD_USER_CONF': _("Forced use of MANGOHUD system settings (GOverlay, etc.)"),
'PW_VKBASALT': _("Enable vkBasalt by default to improve graphics in games running on Vulkan. (The HOME hotkey disables vkbasalt)"),
'PW_VKBASALT_USER_CONF': _("Forced use of VKBASALT system settings (GOverlay, etc.)"),
'PW_DGVOODOO2': _("Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, DirectDraw 1-7, Direct3D 2-9) on all 3D API."),
'PW_GAMESCOPE': _("Super + F : Toggle fullscreen\nSuper + N : Toggle nearest neighbour filtering\nSuper + U : Toggle FSR upscaling\nSuper + Y : Toggle NIS upscaling\nSuper + I : Increase FSR sharpness by 1\nSuper + O : Decrease FSR sharpness by 1\nSuper + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\nSuper + G : Toggle keyboard grab\nSuper + C : Update clipboard"),
'PW_USE_ESYNC': _("Enable in-process synchronization primitives based on eventfd."),
'PW_USE_FSYNC': _("Enable futex-based in-process synchronization primitives."),
'PW_USE_NTSYNC': _("Enable in-process synchronization via the Linux ntsync driver."),
'PW_USE_RAY_TRACING': _("Enable vkd3d support - Ray Tracing"),
'PW_USE_NVAPI_AND_DLSS': _("Enable DLSS on supported NVIDIA graphics cards"),
'PW_USE_OPTISCALER': _("Enable OptiScaler (replacement upscaler / frame generator)"),
'PW_USE_LS_FRAME_GEN': _("Enable Lossless Scaling frame generation (experimental)"),
'PW_WINE_FULLSCREEN_FSR': _("FSR upscaling in fullscreen with ProtonGE below native resolution"),
'PW_HIDE_NVIDIA_GPU': _("Disguise all NVIDIA GPU features"),
'PW_VIRTUAL_DESKTOP': _("Run the application in WINE virtual desktop"),
'PW_USE_TERMINAL': _("Run the application in a terminal"),
'PW_USE_GAMEMODE': _("Use system GameMode for performance optimization"),
'PW_USE_D3D_EXTRAS': _("Enable forced use of third-party DirectX libraries"),
'PW_FIX_VIDEO_IN_GAME': _("Fix pink-tinted video playback in some games"),
'PW_REDUCE_PULSE_LATENCY': _("Reduce PulseAudio latency to fix intermittent sound"),
'PW_USE_US_LAYOUT': _("Force US keyboard layout"),
'PW_USE_GSTREAMER': _("Use GStreamer for in-game clips (WMF support)"),
'PW_USE_SHADER_CACHE': _("Use WINE shader caching"),
'PW_USE_WINE_DXGI': _("Force use of built-in DXGI library"),
'PW_USE_EAC_AND_BE': _("Enable Easy Anti-Cheat and BattlEye runtimes"),
'PW_USE_SYSTEM_VK_LAYERS': _("Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"),
'PW_USE_OBS_VKCAPTURE': _("Enable OBS Studio capture via obs-vkcapture"),
'PW_DISABLE_COMPOSITING': _("Disable desktop compositing for performance"),
'PW_USE_RUNTIME': _("Use container launch mode (recommended default)"),
'PW_DINPUT_PROTOCOL': _("Force DirectInput protocol instead of XInput"),
'PW_USE_NATIVE_WAYLAND': _("Enable experimental native Wayland support"),
'PW_USE_DXVK_HDR': _("Enable HDR settings under native Wayland"),
'PW_USE_GALLIUM_ZINK': _("Use Gallium Zink (OpenGL via Vulkan)"),
'PW_USE_GALLIUM_NINE': _("Use Gallium Nine (native DirectX 9 for Mesa)"),
'PW_USE_WINED3D_VULKAN': _("Use WineD3D Vulkan backend (Damavand)"),
'PW_USE_SUPPLIED_DXVK_VKD3D': _("Use bundled dxvk/vkd3d from Wine/Proton"),
'PW_USE_SAREK_ASYNC': _("Use async dxvk-sarek (experimental)")
}
def get_advanced_settings(disabled_text, logical_core_options, locale_options,
amd_vulkan_drivers, is_amd, numa_nodes, dist_options=None, prefix_options=None):
"""Get advanced settings configuration."""
from portprotonqt.localization import _
advanced_settings = []
if dist_options is None:
dist_options = []
if prefix_options is None:
prefix_options = []
# 1. Wine Version
advanced_settings.append({
'key': 'PW_WINE_USE',
'name': _("Wine Version"),
'description': _("Select the Wine or Proton version to use for this executable."),
'type': 'combo',
'options': dist_options,
'default': ''
})
# 2. Prefix Name
advanced_settings.append({
'key': 'PW_PREFIX_NAME',
'name': _("Prefix Name"),
'description': _("Specify the Wine prefix to run this game with"),
'type': 'combo',
'options': prefix_options,
'default': 'DEFAULT'
})
# 3. Vulkan Backend
vulkan_options = [
_("Newest"), # → 6
_("Stable"), # → 2
("Sarek"), # → 1
("WINED3D OpenGL") # → 0
]
# Маппинг: отображаемый текст → реальное значение в ppdb
vulkan_value_map = {
vulkan_options[0]: "6",
vulkan_options[1]: "2",
vulkan_options[2]: "1",
vulkan_options[3]: "0",
}
advanced_settings.append({
'key': 'PW_VULKAN_USE',
'name': _("Vulkan Backend"),
'description': _(
"Select the DirectX → Vulkan/OpenGL backend:\n\n"
"• Newest latest DXVK + VKD3D (best compatibility/performance, requires modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
"• Stable older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ driver)\n"
"• Sarek experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, Vulkan 1.1+)\n"
"• WINED3D OpenGL fallback (lowest performance, use only if others fail)"
),
'type': 'combo',
'options': vulkan_options,
'default': '6',
'_value_map': vulkan_value_map
})
# 4. Windows version
advanced_settings.append({
'key': 'PW_WINDOWS_VER',
'name': _("Windows version"),
'description': _("Changing the WINDOWS emulation version may be required to run older games. WINDOWS versions below 10 do not support new games with DirectX 12"),
'type': 'combo',
'options': ['11', '10', '7', 'XP'],
'default': '10'
})
# 5. DLL Overrides
advanced_settings.append({
'key': 'WINEDLLOVERRIDES',
'name': _("DLL Overrides"),
'description': _("Forced to use/disable the library only for the given application.\n\nA brief instruction:\n* libraries are written WITHOUT the .dll file extension\n* libraries are separated by semicolons - ;\n* library=n - use the WINDOWS (third-party) library\n* library=b - use WINE (built-in) library\n* library=n,b - use WINDOWS library and then WINE\n* library=b,n - use WINE library and then WINDOWS\n* library= - disable the use of this library\n\nExample: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"),
'type': 'text',
'default': ''
})
# 6. Launch arguments
advanced_settings.append({
'key': 'LAUNCH_PARAMETERS',
'name': _("Launch Arguments"),
'description': _("Adding an argument after the .exe file, just like you would add an argument in a shortcut on a WINDOWS system.\n\nExample: -dx11 -skipintro 1"),
'type': 'text',
'default': ''
})
# 7. CPU cores limit
advanced_settings.append({
'key': 'PW_WINE_CPU_TOPOLOGY',
'name': _("CPU Cores Limit"),
'description': _("Limiting the number of CPU cores is useful for Unity games (It is recommended to set the value equal to 8)"),
'type': 'combo',
'options': [disabled_text] + logical_core_options,
'default': disabled_text
})
# 8. OpenGL version
advanced_settings.append({
'key': 'PW_MESA_GL_VERSION_OVERRIDE',
'name': _("OpenGL Version"),
'description': _("You can select the required OpenGL version, some games require a forced Compatibility Profile (COMP)."),
'type': 'combo',
'options': [disabled_text, '4.6COMPAT', '4.5COMPAT', '4.3COMPAT', '4.1COMPAT', '3.3COMPAT', '3.2COMPAT'],
'default': disabled_text
})
# 9. VKD3D feature level
advanced_settings.append({
'key': 'PW_VKD3D_FEATURE_LEVEL',
'name': _("VKD3D Feature Level"),
'description': _("You can set a forced feature level VKD3D for games on DirectX12"),
'type': 'combo',
'options': [disabled_text, '12_2', '12_1', '12_0', '11_1', '11_0'],
'default': disabled_text
})
# 10. Locale
advanced_settings.append({
'key': 'PW_LOCALE_SELECT',
'name': _("Locale"),
'description': _("Force certain locale for an app. Fixes encoding issues in legacy software"),
'type': 'combo',
'options': [disabled_text] + locale_options,
'default': disabled_text
})
# 11. Present mode
advanced_settings.append({
'key': 'PW_MESA_VK_WSI_PRESENT_MODE',
'name': _("Window Mode"),
'description': _("Window mode (for Vulkan and OpenGL):\nfifo - First in, first out. Limits the frame rate + no tearing. (VSync)\nimmediate - Unlimited frame rate + tearing.\nmailbox - Triple buffering. Unlimited frame rate + no tearing.\nrelaxed - Same as fifo but allows tearing when below the monitors refresh rate."),
'type': 'combo',
'options': [disabled_text, 'fifo', 'immediate', 'mailbox', 'relaxed'],
'default': disabled_text
})
# 12. AMD Vulkan driver
amd_options = [disabled_text] + amd_vulkan_drivers if is_amd and amd_vulkan_drivers else [disabled_text]
advanced_settings.append({
'key': 'PW_AMD_VULKAN_USE',
'name': _("AMD Vulkan Driver"),
'description': _("Select needed AMD vulkan implementation. Choosing which implementation of vulkan will be used to run the game"),
'type': 'combo',
'options': amd_options,
'default': disabled_text
})
# 13. NUMA node
numa_ids = sorted(numa_nodes.keys())
numa_options = [disabled_text] + numa_ids if len(numa_ids) > 1 else [disabled_text]
advanced_settings.append({
'key': 'PW_CPU_NUMA_NODE_INDEX',
'name': _("NUMA Node"),
'description': _("NUMA node for CPU affinity. In multi-core systems, CPUs are split into NUMA nodes, each with its own local memory and cores. Binding a game to a single node reduces memory-access latency and limits costly core-to-core switches."),
'type': 'combo',
'options': numa_options,
'default': disabled_text
})
return advanced_settings
# Keys that should be recognized as advanced settings
ADVANCED_SETTING_KEYS = [
'PW_WINE_USE',
'PW_PREFIX_NAME',
'PW_VULKAN_USE',
'PW_WINDOWS_VER',
'WINEDLLOVERRIDES',
'LAUNCH_PARAMETERS',
'PW_WINE_CPU_TOPOLOGY',
'PW_MESA_GL_VERSION_OVERRIDE',
'PW_VKD3D_FEATURE_LEVEL',
'PW_LOCALE_SELECT',
'PW_MESA_VK_WSI_PRESENT_MODE',
'PW_AMD_VULKAN_USE',
'PW_CPU_NUMA_NODE_INDEX',
]

View File

@@ -420,7 +420,7 @@ def fetch_sgdb_cover(game_name: str) -> str:
try:
encoded = urllib.parse.quote(game_name)
url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
resp = requests.get(url, timeout=5)
resp = requests.get(url, timeout=10)
if resp.status_code != 200:
logger.warning("SGDB request failed for %s: %s", game_name, resp.status_code)
return ""
@@ -431,17 +431,30 @@ def fetch_sgdb_cover(game_name: str) -> str:
if text:
logger.info("Fetched SGDB cover for %s: %s", game_name, text)
return text
except requests.exceptions.Timeout:
logger.warning(f"SGDB request timed out for {game_name}")
return ""
except requests.exceptions.RequestException as e:
logger.warning(f"SGDB request error for {game_name}: {e}")
return ""
except Exception as e:
logger.warning("Failed to fetch SGDB cover for %s: %s", game_name, e)
logger.warning(f"Unexpected error while fetching SGDB cover for {game_name}: {e}")
return ""
def check_url_exists(url: str) -> bool:
"""Check whether a URL returns HTTP 200."""
try:
r = requests.head(url, timeout=3)
r = requests.head(url, timeout=5)
return r.status_code == 200
except Exception:
except requests.exceptions.Timeout:
logger.warning(f"URL check timed out for: {url}")
return False
except requests.exceptions.RequestException as e:
logger.warning(f"Request error when checking URL {url}: {e}")
return False
except Exception as e:
logger.warning(f"Unexpected error when checking URL {url}: {e}")
return False

View File

@@ -5,6 +5,9 @@ from portprotonqt.logger import get_logger
from PySide6.QtGui import QIcon, QFontDatabase, QPixmap
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
# Icon caching for performance optimization
_icon_cache = {}
logger = get_logger(__name__)
# Папка, где располагаются все дополнительные темы
@@ -108,7 +111,20 @@ def load_theme_fonts(theme_name):
logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping")
return
def load_fonts_delayed():
global _loaded_theme
try:
# Only remove fonts if this is a theme change (not initial load)
current_loaded_theme = _loaded_theme # Capture the current value
if current_loaded_theme is not None and current_loaded_theme != theme_name:
# Run font removal in the GUI thread with delay
QFontDatabase.removeAllApplicationFonts()
import time
import os
start_time = time.time()
timeout = 3 # Reduced timeout to 3 seconds for faster loading
fonts_folder = None
if theme_name == "standart":
base_dir = os.path.dirname(os.path.abspath(__file__))
@@ -125,8 +141,19 @@ def load_theme_fonts(theme_name):
logger.error(f"Fonts folder not found for theme '{theme_name}'")
return
font_files = []
for filename in os.listdir(fonts_folder):
if filename.lower().endswith((".ttf", ".otf")):
font_files.append(filename)
# Limit number of fonts loaded to prevent too much blocking
font_files = font_files[:10] # Only load first 10 fonts to prevent too much blocking
for filename in font_files:
if time.time() - start_time > timeout:
logger.warning(f"Font loading timed out for theme '{theme_name}' after loading {len(font_files)} fonts")
break
font_path = os.path.join(fonts_folder, filename)
font_id = QFontDatabase.addApplicationFont(font_path)
if font_id != -1:
@@ -135,7 +162,14 @@ def load_theme_fonts(theme_name):
else:
logger.error(f"Error loading font: {filename}")
# Update the global variable in the main thread
_loaded_theme = theme_name
except Exception as e:
logger.error(f"Error loading fonts for theme '{theme_name}': {e}")
# Use QTimer to delay font loading until after the UI is rendered
from PySide6.QtCore import QTimer
QTimer.singleShot(100, load_fonts_delayed) # Delay font loading by 100ms
class ThemeWrapper:
"""
@@ -232,6 +266,14 @@ class ThemeManager:
а если файл не найден, то из стандартной темы.
Если as_path=True, возвращает путь к иконке вместо QIcon.
"""
# Create cache key
cache_key = f"{icon_name}_{theme_name or self.current_theme_name}_{as_path}"
# Check if we already have this icon cached
if cache_key in _icon_cache:
logger.debug(f"Using cached icon for {icon_name}")
return _icon_cache[cache_key]
icon_path = None
theme_name = theme_name or self.current_theme_name
supported_extensions = ['.svg', '.png', '.jpg', '.jpeg']
@@ -279,12 +321,20 @@ class ThemeManager:
# Если иконка всё равно не найдена
if not icon_path or not os.path.exists(icon_path):
logger.error(f"Warning: icon '{icon_name}' not found")
return QIcon() if not as_path else None
result = QIcon() if not as_path else None
# Cache the result even if it's None
_icon_cache[cache_key] = result
return result
if as_path:
# Cache the path
_icon_cache[cache_key] = icon_path
return icon_path
return QIcon(icon_path)
# Create QIcon and cache it
icon = QIcon(icon_path)
_icon_cache[cache_key] = icon
return icon
def get_theme_image(self, image_name, theme_name=None):
"""

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m24 9.185c-2.0302 0-3.7027 1.6725-3.7027 3.7027v7.4096h-7.4096c-2.0302 0-3.7027 1.6725-3.7027 3.7027s1.6725 3.7027 3.7027 3.7027h7.4096v7.4096c0 2.0302 1.6725 3.7027 3.7027 3.7027s3.7027-1.6725 3.7027-3.7027v-7.4096h7.4096c2.0302 0 3.7027-1.6725 3.7027-3.7027s-1.6725-3.7027-3.7027-3.7027h-7.4096v-7.4096c0-2.0302-1.6725-3.7027-3.7027-3.7027zm0 2.9613c0.41396 0 0.74137 0.32742 0.74137 0.74137v10.371h10.371c0.41396 0 0.74137 0.32742 0.74137 0.74137s-0.32742 0.74137-0.74137 0.74137h-10.371v10.371c0 0.41396-0.32742 0.74137-0.74137 0.74137s-0.74137-0.32742-0.74137-0.74137v-10.371h-10.371c-0.41396 0-0.74137-0.32742-0.74137-0.74137s0.32742-0.74137 0.74137-0.74137h10.371v-10.371c0-0.41396 0.32742-0.74137 0.74137-0.74137z" fill="#3f424d" stop-color="#000000" stroke-width="1.0662"/><path d="m24 11.494c1.1462 0 2.0844 0.93819 2.0844 2.0844v8.3375h8.3375c1.1462 0 2.0844 0.93819 2.0844 2.0844s-0.93819 2.0844-2.0844 2.0844h-8.3375v8.3375c0 1.1462-0.93819 2.0844-2.0844 2.0844s-2.0844-0.93819-2.0844-2.0844v-8.3375h-8.3375c-1.1462 0-2.0844-0.93819-2.0844-2.0844s0.93819-2.0844 2.0844-2.0844h8.3375v-8.3375c0-1.1462 0.93819-2.0844 2.0844-2.0844z" fill="#fff" stop-color="#000000" stroke-width="0"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m15.222 18.538h8.5002v1.8684h-5.8744v2.6763h5.3189v1.8684h-5.3189v4.511h-2.6258zm13.6 11.092q-1.1614 0-3.4842-0.18515v-2.0367q2.0872 0.38714 3.1476 0.38714 0.92576 0 1.3466-0.20198 0.4208-0.21882 0.4208-0.67328v-1.6327q0-0.45446-0.30298-0.63962-0.30298-0.20198-0.99309-0.20198h-3.3664v-5.908h6.9011v1.8852h-4.4436v2.2218h1.7169q0.97626 0 1.902 0.3703 0.50496 0.21882 0.80794 0.67328t0.30298 1.0604v2.2892q0 0.65645-0.25248 1.1782-0.25248 0.50496-0.63962 0.77427-0.33664 0.25248-0.90893 0.40397-0.55546 0.15149-1.0772 0.20198-0.60595 0.03366-1.0772 0.03366z" fill="#3f424d" stroke-width="0" aria-label="F5"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m20.375 10.836v21.898l4.4513 1.4106v-18.447c0-1.1417 0.63761-1.8498 1.4528-1.5795 1.2195 0.33732 1.2205 1.7172 1.2205 2.2383v7.34c0.69194 0.30705 1.3479 0.46456 1.9511 0.46456 0.90817 0 1.6684-0.36594 2.2003-1.06 0.57517-0.75032 0.87844-1.8745 0.87844-3.2519 0-3.9851-1.3499-5.7267-5.562-7.1711-1.3341-0.45192-4.3372-1.3851-6.5925-1.8413zm-1.6344 13.688-6.8797 2.441c-0.02379 0.0087-1.7358 0.57835-2.724 1.3092-0.35029 0.25948-0.50605 0.57737-0.44766 0.89955 0.11028 0.60112 0.89455 1.164 2.099 1.5035 2.5191 0.83248 5.1622 1.0445 7.7159 0.62504l0.23228-0.03801v-1.9131l-2.0863 0.75596c-0.96438 0.34597-2.458 0.42428-3.2646 0.16049-0.5795-0.19028-0.70734-0.49524-0.70951-0.71795-0.0043-0.26596 0.17441-0.64535 1.022-0.95023l5.0426-1.8033zm13.269 1.1529c-0.59973-0.0075-1.1869 0.016-1.7442 0.07603-2.1515 0.23785-3.7118 0.78326-3.7291 0.78975l-0.09714 0.0338v2.3692l5.0299-1.7695c0.96439-0.34597 2.4538-0.42428 3.2604-0.16048 0.5795 0.19028 0.70734 0.49523 0.70951 0.71795 0.0021 0.26596-0.17234 0.64329-1.0178 0.94601l-7.9819 2.8465v2.2594l10.706-3.8432c0.01297-0.0043 1.421-0.53074 1.968-1.2205 0.19245-0.24218 0.25306-0.4904 0.17737-0.73907-0.09298-0.30488-0.49051-0.89192-2.0863-1.3979-1.4887-0.56436-3.3954-0.88519-5.1946-0.908z" fill="#3f424d" stroke-width="2.1623"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m22.671 37.279c-2.0532-0.1967-4.1317-0.93396-5.9172-2.099-1.4962-0.97578-1.8338-1.3776-1.8338-2.1783 0-1.6086 1.7688-4.4266 4.7953-7.6383 1.7183-1.8246 4.1122-3.962 4.3709-3.9044 0.50369 0.11245 4.5276 4.0376 6.0343 5.8857 2.3821 2.9211 3.4769 5.3138 2.9205 6.3804-0.42284 0.81067-3.0472 2.3955-4.9749 3.0042-1.5891 0.50183-3.6761 0.71433-5.395 0.54984zm-9.772-5.9498c-1.2434-1.9076-1.8716-3.7854-2.1746-6.5015-0.10037-0.89679-0.06505-1.4095 0.22706-3.2507 0.36336-2.2923 1.6697-4.947 3.2402-6.5795 0.66849-0.69389 0.72796-0.71247 1.5433-0.43678 0.98817 0.33455 2.0445 1.0644 3.6832 2.5463l0.95719 0.8655-0.52351 0.64123c-2.4249 2.9769-4.9842 7.1991-5.9476 9.8105-0.52351 1.4188-0.73416 2.8437-0.50802 3.4369 0.15179 0.40084 0.01239 0.25153-0.49873-0.53095zm21.824 0.32433c0.12298-0.59972-0.03253-1.7006-0.39651-2.8115-0.78837-2.4054-3.4242-6.88-5.8445-9.9226l-0.76204-0.95781 0.82461-0.75677c1.0761-0.98817 1.8233-1.5798 2.63-2.0826 0.63596-0.39651 1.5451-0.74748 1.9361-0.74748 0.24069 0 1.0892 0.88285 1.7738 1.8431 1.0607 1.4869 1.8407 3.2929 2.2359 5.1701 0.25556 1.2143 0.27694 3.8102 0.0412 5.0214-0.19516 0.99375-0.60405 2.2818-1.0006 3.1556-0.30048 0.65455-1.0408 1.9262-1.3661 2.34-0.16728 0.21281-0.16728 0.2125-0.07435-0.24658zm-11.832-17.733c-1.117-0.56688-2.84-1.1756-3.7916-1.3398-0.33331-0.05731-0.90236-0.08983-1.2639-0.07125-0.78558 0.03965-0.75058-0.0012 0.50895-0.59631 1.047-0.4947 1.9206-0.78558 3.107-1.0346 1.3336-0.28034 3.8412-0.28344 5.1537-0.0068 1.4172 0.29893 3.0866 0.92002 4.027 1.4993l0.28003 0.17161-0.64123-0.03222c-1.275-0.06443-3.133 0.45072-5.128 1.4209-0.60158 0.29304-1.1245 0.52661-1.1629 0.52042-0.0381-0.0074-0.52847-0.24627-1.0904-0.53126z" fill="#3f424d" stroke-width=".30977"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -25,6 +25,7 @@ color_e = "#404554"
color_f = "#ffffff"
color_g = "rgba(0, 0, 0, 0)"
color_h = "transparent"
color_i = "rgba(40, 42, 51, 0.9)"
GAME_CARD_ANIMATION = {
# Тип анимации при входе и выходе на детальную страницу
@@ -217,54 +218,40 @@ CONTEXT_MENU_STYLE = f"""
}}
"""
VIRTUAL_KEYBOARD_STYLE = """
VirtualKeyboard {
background-color: rgba(30, 30, 30, 200);
border-radius: 0px;
border: none;
}
QPushButton {
VIRTUAL_KEYBOARD_STYLE = f"""
QWidget {{
background: {color_i};
}}
QPushButton {{
font-size: 14px;
border: 1px solid #555;
border-top-color: #666;
border-left-color: #666;
border-radius: 3px;
border: {border_a} {color_h};
border-radius: {border_radius_a};
min-width: 30px;
min-height: 30px;
padding: 4px;
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #505050, stop:1 #404040);
color: #e0e0e0;
}
QPushButton:hover {
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #606060, stop:1 #505050);
border: 1px solid #666;
border-top-color: #777;
border-left-color: #777;
}
QPushButton:focus {
border: 2px solid #4a90e2;
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #5a5a5a, stop:1 #454545);
}
QPushButton:pressed {
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #3a3a3a, stop:1 #303030);
border: 1px solid #444;
border-bottom-color: #555;
border-right-color: #555;
padding-top: 5px;
padding-bottom: 3px;
padding-left: 5px;
padding-right: 3px;
}
QPushButton[checked="true"] {
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #4a90e2, stop:1 #3a7ad2);
color: white;
border: 1px solid #2a6ac2;
border-top-color: #5aa0f2;
border-left-color: #5aa0f2;
}
QPushButton[checked="true"]:focus {
border: 2px solid #6aa3f5;
}
padding: 5px;
background-color: {color_c};
color: {color_f};
}}
QPushButton:hover {{
background-color: {color_a};
border: {border_b} {color_a};
}}
QPushButton:focus {{
border: {border_b} {color_a};
background-color: {color_a};
}}
QPushButton:pressed {{
background-color: {color_c};
border: {border_a} {color_h};
}}
QPushButton[checked="true"] {{
background-color: {color_a};
color: {color_f};
border: {border_a} {color_h};
}}
QPushButton[checked="true"]:focus {{
border: {border_b} {color_f};
}}
"""
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН), ЛЭЙБЛОВ, КНОПОК
@@ -660,6 +647,9 @@ PLAY_BUTTON_STYLE = f"""
QPushButton:pressed {{
background: {color_a};
}}
QPushButton:focus {{
background: {color_a};
}}
"""
# СТИЛЬ КНОПКИ "ОБЗОР..." В ДИАЛОГЕ "ДОБАВИТЬ ИГРУ"
@@ -968,9 +958,8 @@ SETTINGS_CHECKBOX_STYLE = f"""
WINETRICKS_TAB_STYLE = f"""
QTabWidget::pane {{
border: 1px solid {color_d};
background: {color_b};
border-radius: {border_radius_a};
border-top: 1px solid {color_c};
background: {color_h};
}}
QTabBar::tab {{
background: {color_c};
@@ -985,15 +974,118 @@ QTabBar::tab:selected {{
color: {color_f};
}}
QTabBar::tab:hover {{
background: {color_e};
background: {color_a};
}}
"""
WINETRICKS_TABBLE_STYLE = f"""
QTableWidget {{
QComboBox {{
background: {color_c};
border: {border_c} {color_g};
border-radius: {border_radius_a};
padding-left: 12px;
color: {color_f};
gridline-color: {color_d};
font-family: '{font_family}';
font-size: {font_size_a};
min-width: 120px;
combobox-popup: 0;
}}
QComboBox:on {{
background: {color_b};
border: {border_c} {color_a};
border-bottom-style: none;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}}
QComboBox:hover {{
border: {border_c} {color_a};
background: {color_a};
}}
/* Состояние фокуса */
QComboBox:focus {{
border: {border_c} {color_a};
background-color: {color_a};
}}
QComboBox:disabled {{
background: #2a2c35;
border: {border_c} #2a2c35;
color: #777a84;
}}
QComboBox::drop-down {{
subcontrol-origin: padding;
subcontrol-position: center right;
border-left: {border_b} rgba(255, 255, 255, 0.05);
padding: 12px;
height: 12px;
width: 12px;
}}
QComboBox::down-arrow {{
image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)});
padding: 12px;
height: 12px;
width: 12px;
}}
QComboBox::down-arrow:on {{
image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)});
padding: 12px;
height: 12px;
width: 12px;
}}
/* Список при открытом комбобоксе */
QComboBox QAbstractItemView {{
outline: none;
background: {color_c};
border: {border_c} {color_a};
border-top-style: none;
border-top-left-radius: 0px;
border-top-right-radius: 0px;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
}}
QListView {{
background: {color_c};
}}
QListView::item {{
padding: 7px 7px 7px 12px;
margin: 3px;
border-radius: {border_radius_a};
color: {color_f};
}}
QListView::item:hover {{
background: {color_b};
}}
QListView::item:selected {{
background: {color_b};
}}
/* Выделение в списке при фокусе на элементе */
QListView::item:focus {{
background: {color_a};
color: {color_f};
}}
QLineEdit {{
background: {color_c};
border: {border_c} rgba(255, 255, 255, 0.01);
border-radius: {border_radius_a};
height: 34px;
padding-left: 12px;
color: {color_f};
font-family: '{font_family}';
font-size: {font_size_a};
}}
QLineEdit:hover {{
background: {color_c};
border: {border_c} {color_a};
}}
QLineEdit:focus {{
border: {border_c} {color_a};
background-color: {color_e};
}}
QTableWidget {{
background: {color_h};
color: {color_f};
gridline-color: {color_h};
alternate-background-color: {color_d};
border: {border_a};
border-radius: {border_radius_a};
@@ -1009,39 +1101,93 @@ QHeaderView::section {{
}}
QTableWidget::item {{
padding: 8px;
border-bottom: 1px solid {color_d};
border-bottom: {border_a } {color_c};
height: 36px;
}}
QTableWidget::item:selected {{
QTableWidget::item:selected,
QTableWidget::item:focus,
QTableWidget::item:selected:focus {{
background: {color_a};
color: {color_f};
selection-background-color: {color_a};
}}
QTableWidget::item:hover {{
background: {color_e};
background: {color_h};
}}
QTableWidget::indicator {{
width: 24px;
height: 24px;
border: {border_b} {color_a};
border: {border_c} {color_h};
border-radius: {border_radius_a};
background: rgba(255, 255, 255, 0.1);
background: {color_b};
}}
QTableWidget::indicator:unchecked {{
background: rgba(255, 255, 255, 0.1);
image: none;
}}
QTableWidget::indicator:checked {{
background: {color_a};
background: {color_b};
image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)});
border: {border_b} {color_f};
border: {border_c} {color_a};
}}
QTableWidget::indicator:hover {{
background: rgba(255, 255, 255, 0.2);
border: {border_b} {color_a};
}}
QTableWidget::indicator:focus {{
border: {border_c} {color_a};
}}
{SCROLL_AREA_STYLE}
QTableWidget::indicator:focus {{
background: rgba(255, 255, 255, 0.2);
border: {border_c} {color_a};
}}
QScrollBar:vertical {{
width: 10px;
border: {border_a};
border-radius: 5px;
background: rgba(20, 20, 20, 0.30);
}}
QScrollBar::handle:vertical {{
background: #bebebe;
border: {border_a};
border-radius: 5px;
}}
QScrollBar::add-line:vertical {{
border: {border_a};
background: none;
}}
QScrollBar::sub-line:vertical {{
border: {border_a};
background: none;
}}
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {{
border: {border_a};
width: 3px;
height: 3px;
background: none;
}}
QScrollBar:horizontal {{
height: 10px;
border: {border_a};
border-radius: 5px;
background: rgba(20, 20, 20, 0.30);
}}
QScrollBar::handle:horizontal {{
background: #bebebe;
border: {border_a};
border-radius: 5px;
}}
QScrollBar::add-line:horizontal {{
border: {border_a};
background: none;
}}
QScrollBar::sub-line:horizontal {{
border: {border_a};
background: none;
}}
QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal {{
border: {border_a};
width: 3px;
height: 3px;
background: none;
}}
"""
WINETRICKS_LOG_STYLE = f"""

View File

@@ -66,8 +66,11 @@ class VirtualKeyboard(QFrame):
if not self.current_input_widget or not isinstance(self.current_input_widget, QLineEdit):
return
try:
# Просто устанавливаем курсор на нужную позицию без выделения
self.current_input_widget.setCursorPosition(self.current_input_widget.cursorPosition())
except RuntimeError:
self.current_input_widget = None
def initUI(self):
layout = QVBoxLayout()
@@ -166,8 +169,10 @@ class VirtualKeyboard(QFrame):
# Очищаем предыдущие кнопки
while self.keyboard_layout.count():
item = self.keyboard_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
if item:
widget = item.widget()
if widget:
widget.deleteLater()
fixed_w = self.button_width
fixed_h = self.button_height
@@ -280,37 +285,51 @@ class VirtualKeyboard(QFrame):
if coords:
row, col = coords
item = self.keyboard_layout.itemAtPosition(row, col)
if item and item.widget():
item.widget().setFocus()
if item:
widget = item.widget()
if widget:
widget.setFocus()
def up_key(self):
"""Перемещает курсор в QLineEdit вверх/в начало, если клавиатура видима"""
if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
try:
self.current_input_widget.setCursorPosition(0)
self.current_input_widget.setFocus()
except RuntimeError:
self.current_input_widget = None
def down_key(self):
"""Перемещает курсор в QLineEdit вниз/в конец, если клавиатура видима"""
if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
try:
self.current_input_widget.setCursorPosition(len(self.current_input_widget.text()))
self.current_input_widget.setFocus()
except RuntimeError:
self.current_input_widget = None
def left_key(self):
"""Перемещает курсор в QLineEdit влево, если клавиатура видима"""
if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
try:
pos = self.current_input_widget.cursorPosition()
if pos > 0:
self.current_input_widget.setCursorPosition(pos - 1)
self.current_input_widget.setFocus()
except RuntimeError:
self.current_input_widget = None
def right_key(self):
"""Перемещает курсор в QLineEdit вправо, если клавиатура видима"""
if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
try:
pos = self.current_input_widget.cursorPosition()
text_len = len(self.current_input_widget.text())
if pos < text_len:
self.current_input_widget.setCursorPosition(pos + 1)
self.current_input_widget.setFocus()
except RuntimeError:
self.current_input_widget = None
def move_focus_up(self):
"""Перемещает фокус по кнопкам клавиатуры вверх с фиксированной скоростью"""
@@ -366,6 +385,7 @@ class VirtualKeyboard(QFrame):
self.on_shift_click(not self.shift_pressed)
self.highlight_cursor_position()
elif self.current_input_widget is not None:
try:
# Сохраняем текущую кнопку с фокусом
focused_button = self.focusWidget()
key_to_restore = None
@@ -387,13 +407,19 @@ class VirtualKeyboard(QFrame):
self.update_keyboard()
if key_to_restore and key_to_restore in self.buttons:
self.buttons[key_to_restore].setFocus()
except RuntimeError:
self.current_input_widget = None
def on_tab_click(self):
if self.current_input_widget is not None:
try:
self.current_input_widget.insert('\t')
self.keyPressed.emit('Tab')
if self.current_input_widget:
self.current_input_widget.setFocus()
self.highlight_cursor_position()
except RuntimeError:
self.current_input_widget = None
def on_caps_click(self):
"""Включаем/выключаем CapsLock"""
@@ -412,6 +438,7 @@ class VirtualKeyboard(QFrame):
def on_backspace_click(self):
"""Обработка одного нажатия Backspace"""
if self.current_input_widget is not None:
try:
cursor_pos = self.current_input_widget.cursorPosition()
text = self.current_input_widget.text()
@@ -421,6 +448,8 @@ class VirtualKeyboard(QFrame):
self.current_input_widget.setCursorPosition(cursor_pos - 1)
self.keyPressed.emit('Backspace')
self.highlight_cursor_position()
except RuntimeError:
self.current_input_widget = None
def on_backspace_pressed(self):
"""Обработка зажатого Backspace"""
@@ -444,15 +473,21 @@ class VirtualKeyboard(QFrame):
# TODO: тут подумать, как обрабатывать нажатие.
# Пока болванка перехода на новую строку, в QlineEdit работает как нажатие пробела
if self.current_input_widget is not None:
try:
self.current_input_widget.insert('\n')
self.keyPressed.emit('Enter')
except RuntimeError:
self.current_input_widget = None
def on_clear_click(self):
"""Чистим строку от введённого текста"""
if self.current_input_widget is not None:
try:
self.current_input_widget.clear()
self.keyPressed.emit('Clear')
self.highlight_cursor_position()
except RuntimeError:
self.current_input_widget = None
def on_lang_click(self):
"""Переключение раскладки"""
@@ -478,8 +513,11 @@ class VirtualKeyboard(QFrame):
def show_for_widget(self, widget):
self.current_input_widget = widget
if widget:
try:
widget.setFocus()
self.highlight_cursor_position()
except RuntimeError:
self.current_input_widget = None
# Позиционирование клавиатуры внизу родительского виджета
if self._parent and isinstance(self._parent, QWidget):
@@ -530,8 +568,9 @@ class VirtualKeyboard(QFrame):
search_col = current_col + col_span
while search_col < num_cols:
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled():
next_button = cast(QPushButton, item.widget())
widget = item.widget() if item else None
if widget and widget.isEnabled():
next_button = cast(QPushButton, widget)
next_button.setFocus()
found = True
break
@@ -544,8 +583,9 @@ class VirtualKeyboard(QFrame):
# Ищем первую кнопку в этой строке
while search_col < num_cols:
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled():
next_button = cast(QPushButton, item.widget())
widget = item.widget() if item else None
if widget and widget.isEnabled():
next_button = cast(QPushButton, widget)
next_button.setFocus()
found = True
break
@@ -557,8 +597,9 @@ class VirtualKeyboard(QFrame):
search_col = current_col - 1
while search_col >= 0:
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled():
next_button = cast(QPushButton, item.widget())
widget = item.widget() if item else None
if widget and widget.isEnabled():
next_button = cast(QPushButton, widget)
next_button.setFocus()
found = True
break
@@ -571,8 +612,9 @@ class VirtualKeyboard(QFrame):
# Ищем последнюю кнопку в этой строке
while search_col >= 0:
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled():
next_button = cast(QPushButton, item.widget())
widget = item.widget() if item else None
if widget and widget.isEnabled():
next_button = cast(QPushButton, widget)
next_button.setFocus()
found = True
break
@@ -584,8 +626,9 @@ class VirtualKeyboard(QFrame):
search_row = current_row + row_span
while search_row < num_rows:
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled():
next_button = cast(QPushButton, item.widget())
widget = item.widget() if item else None
if widget and widget.isEnabled():
next_button = cast(QPushButton, widget)
next_button.setFocus()
found = True
break
@@ -598,8 +641,9 @@ class VirtualKeyboard(QFrame):
# Ищем первую кнопку в этом столбце
while search_row < num_rows:
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled():
next_button = cast(QPushButton, item.widget())
widget = item.widget() if item else None
if widget and widget.isEnabled():
next_button = cast(QPushButton, widget)
next_button.setFocus()
found = True
break
@@ -611,8 +655,9 @@ class VirtualKeyboard(QFrame):
search_row = current_row - 1
while search_row >= 0:
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled():
next_button = cast(QPushButton, item.widget())
widget = item.widget() if item else None
if widget and widget.isEnabled():
next_button = cast(QPushButton, widget)
next_button.setFocus()
found = True
break
@@ -625,8 +670,9 @@ class VirtualKeyboard(QFrame):
# Ищем последнюю кнопку в этом столбце
while search_row >= 0:
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled():
next_button = cast(QPushButton, item.widget())
widget = item.widget() if item else None
if widget and widget.isEnabled():
next_button = cast(QPushButton, widget)
next_button.setFocus()
found = True
break
@@ -637,6 +683,7 @@ class VirtualKeyboard(QFrame):
for row in range(self.keyboard_layout.rowCount()):
for col in range(self.keyboard_layout.columnCount()):
item = self.keyboard_layout.itemAtPosition(row, col)
if item and item.widget() and item.widget().isEnabled():
return cast(QPushButton, item.widget())
widget = item.widget() if item else None
if widget and widget.isEnabled():
return cast(QPushButton, widget)
return None

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "portprotonqt"
version = "0.1.8"
version = "0.1.9"
description = "A project to rewrite PortProton (PortWINE) using PySide"
readme = "README.md"
license = { text = "GPL-3.0" }
@@ -31,11 +31,12 @@ dependencies = [
"evdev>=1.9.2",
"icoextract>=0.2.0",
"numpy>=2.2.4",
"orjson>=3.11.3",
"pillow>=11.3.0",
"psutil>=7.1.0",
"pyside6==6.9.1",
"pyudev>=0.24.3",
"orjson>=3.11.4",
"pillow>=12.0.0",
"psutil>=7.1.3",
"pyside6>=6.10.1",
"pyudev>=0.24.4",
"rapidfuzz>=3.14.3",
"requests>=2.32.5",
"tqdm>=4.67.1",
"vdf>=3.4",
@@ -103,7 +104,7 @@ ignore = [
[dependency-groups]
dev = [
"pre-commit>=4.3.0",
"pre-commit>=4.5.0",
"pyaspeller>=2.0.2",
"pyright>=1.1.406",
"pyright>=1.1.407",
]

View File

@@ -8,7 +8,7 @@
"enabled": true
},
"pre-commit": {
"enabled": true
"enabled": false
},
"packageRules": [
{
@@ -52,7 +52,7 @@
"groupName": "Python dependencies"
},
{
"matchPackageNames": ["numpy", "setuptools", "python", "pyside6"],
"matchPackageNames": ["numpy", "setuptools", "python"],
"enabled": false,
"description": "Disabled due to Python 3.10 incompatibility with numpy>=2.3.2 (requires Python>=3.11)"
},

1157
uv.lock generated

File diff suppressed because it is too large Load Diff